Compare commits

...

22 commits

Author SHA1 Message Date
Aditya kumar singh
d436792e77
docs: document self-hosted v0.0.5 model mixing bug and v0.0.7 resolution (#1450) (#1606) 2026-08-27 16:38:11 -07:00
MaheshtheDev
29c43984fe feat(web): pause Google Drive connect with a notify-me fallback (#1602)
![image.png](https://app.graphite.com/user-attachments/assets/6d7dd2cf-575b-450c-b7bb-c24b8ec834b9.png)

Google is not approving new authorizations while it re-reviews our app, so
every path that starts a new Google connection now shows a PAUSED badge and a
"Notify me" button instead of Connect.

- Existing connections are untouched: sync, file picking and history still work.
- Notify me fires a connector_paused_clicked PostHog event and remembers the
choice in localStorage, so we can pull who to email when the review clears.
- One list in lib/connector-availability.ts drives every surface; removing the
two entries turns Google back on.
2026-08-27 20:20:13 +00:00
MaheshtheDev
f11d8c4620 feat(web): drop the Web research row from Company Brain models (#1600)
The `research` / `researchEffort` model role no longer exists on the API,
so remove its card, its preset entries, and its types.
2026-08-26 19:07:56 +00:00
MaheshtheDev
9652478093 feat(web): offer a setup call alongside Slack install (#1599)
- First-landing modal on the Company Brain home: book a 30-min setup call, or install Slack yourself. Dismissal stored in localStorage.
- Setup call also reachable from a header icon, the user menu, and the onboarding research rail.
- Drops the desktop Invite button from the header; invite stays in the stats row and mobile menu.
2026-08-26 07:43:48 +00:00
MaheshtheDev
3f7b9667c6 chore: remove two dead onboarding routes and a note-content console.log (#1596)
Cherry-picks two cleanup PRs and finishes the job. Net 262 deletions.

- #1473 (@abhay-codes07): drops a `console.log` in the fullscreen note editor that printed the whole note body on every keystroke, which PostHog session replay can capture.
- #1563 (@ishaanxgupta): removes `/api/onboarding/research` and `/api/onboarding/extract-content`. Neither has a caller anywhere in the repo, and both spent metered Exa and xAI quota. This reverts the guards added for them in #1589, which only existed to make unreachable code safe.
- On top: `EXA_API_KEY`, `XAI_API_KEY` and the `@ai-sdk/xai` dependency are removed, since deleting those routes left them with no consumer.

Co-Authored-By: abhay-codes07 <182421137+abhay-codes07@users.noreply.github.com>
Co-Authored-By: ishaanxgupta <124028055+ishaanxgupta@users.noreply.github.com>
2026-08-25 10:19:47 +00:00
MaheshtheDev
e4afc770be feat(tools): apiKey option, type re-exports, and two reliability fixes (#1594)
Some checks failed
Publish Tools / publish (push) Has been cancelled
Cherry-picks four contributor PRs for `@supermemory/tools` onto one branch, and bumps the package to 2.2.0.

- #1244 (@rajarshidattapy): `withSupermemory` accepts `options.apiKey` instead of only reading `SUPERMEMORY_API_KEY`, matching the Vercel, Mastra and Voltagent integrations. Unblocks secrets managers, edge runtimes and per-request keys.
- #1574 (@Agnik47): re-exports `PromptTemplate`, `MemoryPromptData` and `WithSupermemoryOptions` from `ai-sdk`. `./vercel` is not a published subpath, so the documented custom-template example did not compile.
- #1488 (@abhinav7x94): malformed tool-call JSON returns an error result instead of throwing out of the request.
- #1507 (@abhinav7x94): VoltAgent `onEnd` awaits the conversation save, which was fire-and-forget and could be dropped when a serverless runtime tore down.

Dropped the `middleware.test.ts` added by #1244. Note that editing `packages/tools/package.json` triggers the npm publish workflow on merge.

Co-Authored-By: rajarshidattapy <138959719+rajarshidattapy@users.noreply.github.com>
Co-Authored-By: Agnik47 <140933190+Agnik47@users.noreply.github.com>
Co-Authored-By: abhinav7x94 <204053250+abhinav7x94@users.noreply.github.com>
2026-08-24 22:39:55 +00:00
MaheshtheDev
f051af098e fix(mcp): bound tool inputs and scope get_document to the active space (#1593)
Cherry-picks #1582, #1583, #1584 and #1585 from @Sravanjangam (security audit #1578) onto one branch.

- MCP: `get_document` scopes to the active space like its sibling read tools, `fetch-graph-data` bounds page/limit, `guided-save` caps prefill at 200k, and `whoAmI` no longer returns the transport session id.
- ai-sdk: search limit clamped to 1-50 with a 30s client timeout.
- validation: caps on `DocumentsWithMemoriesQuerySchema.limit` and `BulkDeleteMemoriesSchema.containerTags`.
- Raycast: `metadata.url` is parsed and only http(s) is offered to the OS opener.

Dropped his `add_memory` permission gate: it checked the target against the list of existing spaces, so writes to a new space failed and the no-active-space path surfaced `No write access to space "undefined"`. Write permission stays enforced in the API via `containerTagGate`.

Hardening and consistency rather than a security fix, since the API already enforces every permission boundary here.

Co-Authored-By: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com>
2026-08-24 21:36:04 +00:00
MaheshtheDev
6cae175852 fix(mcp): return 503 on auth-backend outages instead of invalid_token (#1591)
Cherry-picks #1587 from @Sravanjangam, plus the OAuth half on top.

When the auth backend is slow or returns a 5xx, the MCP server currently answers `invalid_token`. That is the protocol's signal to discard the credential and re-authenticate, so a brief upstream blip logs every connected client out, and `sm_` API key users have no automatic way back. These requests now return 503 with `Retry-After: 5` so clients retry instead.

His change covered the API key path only. This shares one `transientAuthErrorFor` helper between `validateApiKey` and `validateOAuthToken`, so a JWKS timeout or a 5xx also returns 503 on the OAuth path that Claude, Cursor and browser clients use.

Genuinely bad tokens are unaffected: bad signature, expired, and no-matching-key still resolve to 401. Verified across all seven cases.

Co-Authored-By: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com>
2026-08-24 15:53:06 +00:00
MaheshtheDev
3b0fc9c959 fix(web): authenticate and bound metered /api routes (#1589)
Cherry-picks #1579 and #1580 from @Sravanjangam (security audit #1578), plus improvements on top.

- `/api/og`, `/api/onboarding/extract-content` and `/api/onboarding/research` now verify the session against the auth backend; the middleware only checked that a cookie was present, so a forged cookie reached handlers that spend metered Exa/xAI quota.
- Bounds those routes: 2MB cap on fetched HTML, max 10 http(s) URLs per request, name/email length limits and a 60s timeout on the LLM call.
- De-duplicates URLs before calling Exa, and collapses whitespace in `name`/`email` so a newline can't forge extra prompt lines. Both adapted from @SEPURI-SAI-KRISHNA's #1528 and #1530.
- Deletes the unused, unauthenticated `account-status` route.

Verified locally: pre-fix `/api/og` returned 200 for a forged cookie, post-fix it returns 401. Five duplicate URLs collapse to two before reaching Exa, and a newline-laden `name` arrives as a single prompt line.

Supersedes #1528 and #1530.
2026-08-23 21:48:46 +00:00
Dhravya
3487666481
feat(web): add MCP connector directory (#1461)
<!-- VORFLUX_AGENT_PR_BODY_BEGIN -->
Adds the full 654-entry MCP directory without bundling records into client JavaScript, with explicit capability status and connector branding that degrades safely when no authoritative logo is available.

## Changes

- Lazy-load and validate the searchable, filterable, progressively rendered MCP catalog.
- Render same-origin proxied provider icons for 543 entries, with a reviewed domain allowlist and deterministic fallback marks for 111 unresolved or unbranded entries.
- Record OAuth discovery capability separately from end-to-end support; all directory setup actions remain suppressed until their authentication flow is verified.
- Add a reproducible OAuth metadata probe with HTTPS/private-network protections, stable URL keys, authorization-server scanning, and catalog fingerprint validation.
- Add Google Drive branding for the curated built-in connector.

## Testing

- **Passed:** Deterministic generation and catalog assertions.
  ```bash
  PATH="$HOME/.bun/bin:$PATH" python3 apps/web/scripts/generate-mcp-directory.py --output
  cmp apps/web/public/mcp-directory.json
  ```
  Verified 654 entries, 254 DCR discoveries, 27 preregistered OAuth discoveries, 373 unclassified entries, and zero directory setup actions.
- **Passed:** Stale OAuth metadata fingerprint is rejected by the generator.
- **Passed:** Touched-file Biome checks and `git diff --check`.
- **Passed:** Icon proxy returned 200 for an allowlisted domain and 400 for an unknown valid-looking domain.
- **Passed:** Authenticated desktop/mobile browser inspection and conservative capability labels.
- **Passed:** Public preview returned HTTP 200 and rendered the real app. Authentication cookies do not transfer to the public hostname, so the public screenshot shows login.
- **Partial:** Repository-wide TypeScript checks remain blocked by unrelated existing errors outside the touched MCP files.
- **Partial:** 111 entries intentionally retain deterministic fallback marks; endpoint-derived domains may not always be the canonical brand logo.
- **Blocked:** Google rejected the local HTTP OAuth callback, so live Google Drive consent, callback, persistence, tool discovery, disconnect, and reconnect were not completed.

Public preview: https://ar8ruchhbi65.preview.us1.vorflux.com/configure/tools

---
**Attached Images**

*[288.csv]*

*[mcp-directory-final.json]*

![mcp-directory-branding-desktop.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA0.3_UzR_OP9Jk228FYbrAPTXyqybRBlqwn5Uv4tksf_Y0.png)

![mcp-directory-branding-mobile.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA1.b5G6nsOBVm2s6DlEFWFiMFCcULAkV0MCCGZ8XVsA5js.png)

![mcp-directory-public-preview.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA2.ZrBAeBi62JX1xavAtaDLQ0fuixgBjN7x1NrqIxtdmKw.png)
<!-- VORFLUX_AGENT_PR_BODY_END -->

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/1cd0aab9-2a45-4818-aa13-f9bfe032ddba)
- Requested by: Dhravya Shah (dhravya@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how users pick MCP URLs and auth (OAuth vs API key) before hitting existing connect endpoints; no new backend auth logic in this diff, but misconfiguration or trusting bad URLs remains a user-risk surface.
>
> **Overview**
> Adds a **browseable MCP directory** on the Company Brain connectors page: the catalog is **not bundled in JS**—it loads from static **`/mcp-directory.json`** only after the user opens the directory (with validation, caching, and abort handling).
>
> The new **`McpDirectoryBrowser`** supports search, category/availability filters, and progressive “show more” rendering. Supported remote entries route into the existing custom MCP flow via **Set up**, which pre-fills name/URL and opens the connector dialog with context-specific copy.
>
> The custom connector dialog now uses an explicit **OAuth vs API key** toggle; API key fields only appear for API-key mode, and directory-backed connections get **stable slugs** (`-dir-` suffix) so names display cleanly on connected cards. **Middleware** excludes `mcp-directory.json` from the auth matcher so the asset can be fetched publicly.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b59bae84a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-08-20 22:58:39 +00:00
ishaanxgupta
dda56e766e Add plugin CLI command guide to integrations (#1534)
## Summary

- add an “Install plugins with one command” action beside the Plugins section

<img width="1280" height="651" alt="image" src="https://github.com/user-attachments/assets/a2e19a91-c94c-4a56-87c4-b72c4dbe89ba" />
<img width="1280" height="554" alt="image" src="https://github.com/user-attachments/assets/2cd4b3a4-ac30-4411-8221-baca09e90b0f" />
2026-08-20 18:07:31 +00:00
Dhravya Shah
818a83a381
fix(mcp): strip API extras from listMemories entries (#1539)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 14:05:52 -07:00
Aditya Mishra
7b1175cb1a
fix(browser-extension): run wxt prepare before type checking (#1557) 2026-08-19 19:23:52 +05:30
Rajarshi Datta
20410a6862
fix(ui): remove the unused, broken AnonymousAuth component (#1555) 2026-08-19 19:22:17 +05:30
Rajarshi Datta
7d59070ad6
fix(web): scope the ?view=mcp guest exemption to / (#1553) 2026-08-19 18:33:58 +05:30
Dhravya
18a2dfbe39
feat(mcp): accept Supermemory API keys as Bearer auth (#1537)
## Stack Context

Single-auth story for the Claude Code supermemory plugin rework: the plugin's hooks and its MCP surface share one credential (`sm_` API key from the existing browser connect flow). That requires `mcp.supermemory.ai` to accept plain API keys, which it currently rejects (OAuth JWT only).

## What?

- `validateApiKey()` in `server/auth`: `sm_`-prefixed Bearer tokens validate via the existing `fetchSession()` (`GET /v3/session`) and map to the same `AuthUser` shape as OAuth tokens (`userId` ← `user.id`, `organizationId` ← `org.id`, the key itself as `bearerToken` for downstream API calls). Successful lookups cached per isolate for 60s.
- `handleMcpRequest` routes by token shape: `sm_` keys → session validation, everything else → OAuth JWT verification (unchanged).
- `sessionInfoSchema` now types the `org.id` field the session endpoint already returns.

## Why?

MCP clients that already hold an API key (Claude Code plugin hooks, CLI, scripts) can connect without an OAuth dance or a second consent. OAuth behavior is untouched — the existing "rejects opaque API keys" test on the OAuth validator still passes; keys just get their own path. Malformed keys are rejected without an API round-trip.

Tests: 4 new cases (valid key → AuthUser, cache hit → single fetch, 401 → null, malformed → no request). `vitest run src/server/auth` 13/13, `tsc --noEmit` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds a new authentication path on the MCP entrypoint with in-memory key caching (60s TTL), so revoked keys may remain valid briefly within an isolate; OAuth behavior is unchanged.
>
> **Overview**
> MCP Bearer auth now accepts **`sm_` Supermemory API keys** in addition to OAuth JWTs, so clients that already hold an API key can connect without OAuth.
>
> **`validateApiKey`** treats keys matching `sm_` plus at least 17 non-space characters as API keys: it calls **`GET /v3/session`** with the key as Bearer, maps **`user.id`** and **`org.id`** into the same **`AuthUser`** shape as OAuth (key kept as **`bearerToken`** for downstream API calls), and caches successful results per isolate for **60s** (up to 1000 entries, full clear on overflow). Malformed keys are rejected locally with no HTTP call; session **401** yields unauthenticated.
>
> **`handleMcpRequest`** branches on token shape: API keys go through session validation; other tokens still use JWT verification unchanged.
>
> **`sessionInfoSchema`** now includes optional **`org.id`** typing for session responses used when resolving organization context from API keys.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e54fb11bf1598d07a807eb2b0b63a347aaa58fb6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-08-19 02:27:27 +00:00
MaheshtheDev
149589ae7e fix(brain): keep the confirmed company domain after checkout return (#1536)
Returning from Stripe remounts onboarding and reseeds the domain from the user's email, so the header showed the wrong company and a research retry would re-run on the wrong domain. Past the confirm step, read the org's stored brainWorkspaceDomain instead.
2026-08-18 23:13:12 +00:00
MaheshtheDev
c0eb81c887 refactor(brain): one isCompanyBrainOrg helper in the web app (#1535)
Two hand-rolled copies of the add-on/brainMode rule replaced by a single shared helper, and the one-line isCompanyBrainOrganization wrapper dropped. No behaviour change.
2026-08-18 23:13:12 +00:00
ishaanxgupta
e2be9c9edd Fix integrations layout and mobile promo responsiveness (#1481)
## Summary

- Reorder Apps & extensions so Import X bookmarks appears in the top row and Apple Shortcuts uses the open space below.
- Keep both Apple Shortcut actions inline on larger screens while allowing the card to grow only as much as needed.
- Rework the Company Brain promo on phones so its logo, copy, close control, and CTA remain readable and aligned.
2026-08-17 18:47:36 +00:00
Dhravya
5d2b5855fe
feat(auth): AgentID sign-in button on the web login page (#1467)
## What?

Adds a "Continue with AgentID" button to the web app's login page, matching the existing Google/GitHub buttons (same `ExternalAuthButton` pattern, PostHog `login_attempt` capture, last-used badge).

- `packages/lib/auth.ts`: adds the `genericOAuthClient` plugin — generic OAuth providers sign in via `signIn.oauth2({ providerId })`, not `signIn.social`.
- `apps/web/app/(auth)/login/page.tsx`: the button, gated the same way as the other social buttons — always shown on cloud (`NEXT_PUBLIC_HOST_ID === "supermemory"`), opt-in elsewhere via `NEXT_PUBLIC_AGENTID_AUTH_ENABLED` (added to `.env.example`).

## Why?

Companion to supermemoryai/mono#2908, which registers an `agentid` generic OAuth provider (OIDC against auth.agentid.com) on the API so agents can authenticate with their AgentID identity. The consumer app talks to the same better-auth server, so it gets the same sign-in option. mono#2916 additionally auto-invites the agent's verified human owner to the agent's workspace.

Requires mono#2908 to be deployed for the button to work; until then the API rejects the unknown provider and the page shows its normal error state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches authentication entry points and OAuth client configuration; risk is moderate because it extends login surface area but follows existing social sign-in patterns and is feature-flagged.
>
> **Overview**
> Adds **Continue with AgentID** on the web login page, using the same `ExternalAuthButton` flow as Google/GitHub (PostHog `login_attempt`, last-used badge, loading/error handling).
>
> The button calls **`signIn.oauth2({ providerId: "agentid" })`** instead of `signIn.social`, enabled by registering **`genericOAuthClient`** on the shared better-auth client in `packages/lib/auth.ts`.
>
> Visibility matches other social providers: shown on cloud when `NEXT_PUBLIC_HOST_ID === "supermemory"`, or elsewhere when **`NEXT_PUBLIC_AGENTID_AUTH_ENABLED`** is set (documented in `.env.example`). Depends on the API registering the `agentid` generic OAuth provider.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 90a32786a3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-08-16 23:20:38 +00:00
Dhravya Shah
d14b209f7c
feat(web): support discount code checkout (#1523) 2026-08-16 13:43:53 -07:00
Ishaan Gupta
e651045ac5
Remove paid plugin UI (#1403) 2026-08-15 18:41:46 +05:30
79 changed files with 3599 additions and 1526 deletions

View file

@ -9,10 +9,9 @@
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"check-types": "bun run compile",
"check-types": "wxt prepare && tsc --noEmit",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {

View file

@ -129,6 +129,14 @@ Use the dimension published for your chosen model. A mismatch with vectors alrea
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
> [!IMPORTANT]
> **Model Mixing Bug in v0.0.5 (Exact match returns nothing)**
>
> In version `v0.0.5`, there was a bug where the server could mix different embedding models between write and read paths (e.g., document ingestion using OpenAI but memory queries using local default embeddings). In multilingual contexts like Japanese (which lacks space tokenization for fallback lexical FTS matching), this caused exact-text memory searches through `/v4/search` and `/v4/profile` to silently return `{"results":[],"total":0}`.
>
> **Resolution:**
> This was fully resolved in `v0.0.7` by locking the embedding plan uniformly across all document and query embedding paths (enforced via a locked plan in the database store). If you are running `v0.0.5` and experiencing this issue, you should upgrade to `v0.0.7` or later.
## Related
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits

View file

@ -1,6 +1,11 @@
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { fetchSession, validateOAuthToken } from "./index"
import {
fetchSession,
TransientAuthError,
validateApiKey,
validateOAuthToken,
} from "./index"
const API_URL = "https://api.example.com"
const ISSUER = `${API_URL}/api/auth`
@ -120,4 +125,92 @@ describe("MCP authentication", () => {
status: 403,
})
})
function sessionResponse() {
return Response.json({
user: { id: "user_test", email: "test@example.com" },
org: { id: "org_test" },
role: "owner",
accessType: "full",
scope: { type: "full", permission: "write" },
})
}
it("validates an sm_ API key via the session endpoint", async () => {
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
vi.stubGlobal("fetch", fetchSpy)
const key = "sm_valid_key_0123456789abcdef"
await expect(validateApiKey(key, API_URL)).resolves.toEqual({
userId: "user_test",
organizationId: "org_test",
bearerToken: key,
scopes: [],
})
expect(fetchSpy).toHaveBeenCalledWith(
`${API_URL}/v3/session`,
expect.objectContaining({
headers: { Authorization: `Bearer ${key}` },
}),
)
})
it("caches a validated API key within the TTL", async () => {
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
vi.stubGlobal("fetch", fetchSpy)
const key = "sm_cached_key_0123456789abcdef"
await validateApiKey(key, API_URL)
await validateApiKey(key, API_URL)
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it("rejects an API key the session endpoint refuses", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
)
await expect(
validateApiKey("sm_revoked_key_0123456789abcdef", API_URL),
).resolves.toBeNull()
})
it("rejects malformed API keys without an API request", async () => {
const fetchSpy = vi.fn()
vi.stubGlobal("fetch", fetchSpy)
await expect(validateApiKey("sm_short", API_URL)).resolves.toBeNull()
await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull()
expect(fetchSpy).not.toHaveBeenCalled()
})
it("surfaces a 500 from the session endpoint as TransientAuthError, not invalid token", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
)
await expect(
validateApiKey("sm_outage_key_0123456789abcdef", API_URL),
).rejects.toThrow(TransientAuthError)
})
it("surfaces a session-endpoint timeout as TransientAuthError", async () => {
vi.spyOn(console, "error").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(
Object.assign(new Error("The operation was aborted"), {
name: "TimeoutError",
}),
),
)
await expect(
validateApiKey("sm_timeout_key_0123456789abcd", API_URL),
).rejects.toThrow(TransientAuthError)
})
})

View file

@ -52,6 +52,93 @@ export async function fetchSession(
return result.data
}
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
// instead of JWT verification. Successful lookups are cached per isolate so a
// busy MCP session doesn't re-validate on every JSON-RPC message.
const API_KEY_PATTERN = /^sm_\S{17,}$/
const API_KEY_CACHE_TTL_MS = 60_000
const API_KEY_CACHE_MAX_ENTRIES = 1000
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
export function isApiKey(token: string): boolean {
return API_KEY_PATTERN.test(token)
}
// Upstream was unreachable, not the token being bad: reporting these as invalid_token makes clients discard working credentials.
export class TransientAuthError extends Error {
readonly status?: number
constructor(message: string, status?: number) {
super(message)
this.name = "TransientAuthError"
this.status = status
}
}
const TRANSIENT_ERROR_NAMES = new Set([
"AbortError",
"TimeoutError",
"JWKSTimeout",
])
// ERR_JOSE_GENERIC is what jose throws when the JWKS endpoint answers non-200 or unparseable JSON.
const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"])
function transientAuthErrorFor(error: unknown): TransientAuthError | null {
const status = (error as { status?: unknown } | null)?.status
if (typeof status === "number" && status !== 401 && status !== 403) {
return new TransientAuthError(`Session endpoint returned ${status}`, status)
}
if (error instanceof TypeError) {
return new TransientAuthError(`Auth backend unreachable: ${error.message}`)
}
if (error instanceof Error && TRANSIENT_ERROR_NAMES.has(error.name)) {
return new TransientAuthError(error.message)
}
const code = (error as { code?: unknown } | null)?.code
if (typeof code === "string" && TRANSIENT_JOSE_CODES.has(code)) {
return new TransientAuthError(
`JWKS fetch failed: ${(error as Error).message}`,
)
}
return null
}
export async function validateApiKey(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
if (!isApiKey(token)) return null
const cached = apiKeyCache.get(token)
if (cached && cached.expiresAt > Date.now()) return cached.user
try {
const session = await fetchSession(token, apiUrl)
const organizationId = session.org?.id
if (!organizationId) return null
const user: AuthUser = {
userId: session.user.id,
organizationId,
bearerToken: token,
scopes: [],
}
if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear()
apiKeyCache.set(token, {
user,
expiresAt: Date.now() + API_KEY_CACHE_TTL_MS,
})
return user
} catch (error) {
console.error("API key validation error:", error)
const transient = transientAuthErrorFor(error)
if (transient) throw transient
return null
}
}
export async function validateOAuthToken(
token: string,
apiUrl: string,
@ -97,6 +184,8 @@ export async function validateOAuthToken(
}
} catch (error) {
console.error("OAuth token validation error:", error)
const transient = transientAuthErrorFor(error)
if (transient) throw transient
return null
}
}

View file

@ -7,11 +7,14 @@ import { z } from "zod"
import {
containerTagSchema,
documentsApiResponseSchema,
paginationSchema,
memoriesListSchema,
type ContainerTag,
type DocumentMemoryEntry,
type DocumentsApiResponse,
type DocumentWithMemories,
type MemoriesList,
type MemoryEntry,
type MemoryEntryHistory,
} from "../../shared/types"
const MAX_CHARS = 200000
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
pagination: SdkDocumentListResponse["pagination"]
}
const memoryEntryHistorySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
isLatest: z.boolean().optional(),
isForgotten: z.boolean().optional(),
})
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
const memoryEntrySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
isLatest: z.boolean(),
isForgotten: z.boolean(),
isStatic: z.boolean().optional(),
isInference: z.boolean().optional(),
createdAt: z.string(),
updatedAt: z.string(),
sourceCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
history: z.array(memoryEntryHistorySchema).optional(),
})
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
const memoryEntriesResponseSchema = z.object({
memoryEntries: z.array(memoryEntrySchema),
pagination: paginationSchema,
})
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
// Memory-entry shapes live in shared/types so the client parser and the
// listMemories output schema share one definition and can't drift.
export type { MemoryEntry, MemoryEntryHistory }
export type MemoryEntriesResponse = MemoriesList
export type Memory =
| {
@ -452,7 +422,7 @@ export class SupermemoryClient {
})
}
return memoryEntriesResponseSchema.parse(await response.json())
return memoriesListSchema.parse(await response.json())
} catch (error) {
this.handleError(error)
}

View file

@ -2,7 +2,13 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import { validateOAuthToken, type AuthUser } from "./auth"
import {
isApiKey,
TransientAuthError,
validateApiKey,
validateOAuthToken,
type AuthUser,
} from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types"
@ -42,7 +48,7 @@ app.use(
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
exposeHeaders: ["WWW-Authenticate"],
exposeHeaders: ["WWW-Authenticate", "Retry-After"],
}),
)
@ -123,6 +129,30 @@ function authInfoFor(
}
}
type AuthResolution =
| { ok: true; user: AuthUser }
| { ok: false; reason: "invalid" }
| { ok: false; reason: "transient" }
// Keeps a transient upstream failure distinct from an invalid token.
async function resolveAuthUser(
token: string,
apiUrl: string,
mcpResource: string,
): Promise<AuthResolution> {
try {
const user = isApiKey(token)
? await validateApiKey(token, apiUrl)
: await validateOAuthToken(token, apiUrl, mcpResource)
return user ? { ok: true, user } : { ok: false, reason: "invalid" }
} catch (error) {
if (error instanceof TransientAuthError) {
return { ok: false, reason: "transient" }
}
throw error
}
}
function unauthorizedResponse(
resourceMetadataUrl: string,
invalidToken = false,
@ -176,8 +206,23 @@ async function handleMcpRequest(
if (!token) return unauthorizedResponse(resourceMetadataUrl)
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
const resolved = await resolveAuthUser(token, apiUrl, mcpResource)
if (!resolved.ok && resolved.reason === "transient") {
return Response.json(
{
jsonrpc: "2.0",
error: {
code: -32001,
message:
"Authentication backend temporarily unavailable, please retry",
},
id: null,
},
{ status: 503, headers: { "Retry-After": "5" } },
)
}
if (!resolved.ok) return unauthorizedResponse(resourceMetadataUrl, true)
const authUser = resolved.user
const actor: ActorContext = {
userId: authUser.userId,

View file

@ -12,8 +12,8 @@ export function register(deps: ToolDeps) {
description: "Fetch documents with memories for graph display",
inputSchema: z.object({
containerTag: optionalContainerTagSchema,
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
page: z.number().int().min(1).max(10_000).optional().default(1),
limit: z.number().int().min(1).max(1_000).optional().default(200),
}),
outputSchema: documentsApiResponseSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,

View file

@ -7,6 +7,7 @@ import {
} from "./output-schemas"
import { textContent, type ToolDeps } from "./types"
// An out-of-space document reports "not found" on purpose, so the id is not an existence oracle.
export function register(deps: ToolDeps) {
const inputSchema = z.object({
documentId: z
@ -28,8 +29,17 @@ export function register(deps: ToolDeps) {
},
async (args) => {
try {
const effectiveTag = await deps.resolveContainerTag()
const client = deps.getClient()
const document = await client.getDocument(args.documentId)
const docTags = document.containerTags
if (
Array.isArray(docTags) &&
docTags.length > 0 &&
!docTags.includes(effectiveTag)
) {
throw new Error("Document not found")
}
const { content, truncated } = getDocumentContent(document)
const structuredContent: GetDocumentOutput = {
document: {

View file

@ -13,7 +13,11 @@ export function register(deps: ToolDeps) {
description:
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
inputSchema: z.object({
prefill: z.string().optional().describe("Optional content to prefill"),
prefill: z
.string()
.max(200000, "Prefill exceeds maximum length")
.optional()
.describe("Optional content to prefill"),
}),
outputSchema: saveViewSchema,
_meta: appToolMeta(),

View file

@ -1,6 +1,7 @@
import { z } from "zod"
import {
containerTagAccessSchema,
memoriesListSchema,
paginationSchema,
sessionScopeSchema,
} from "../../shared/types"
@ -42,33 +43,6 @@ const documentSummarySchema = z.object({
summary: z.string().nullable(),
})
const memoryHistorySchema = z.object({
id: z.string(),
memory: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
isLatest: z.boolean().optional(),
isForgotten: z.boolean().optional(),
})
const memoryEntryOutputSchema = z.object({
id: z.string(),
memory: z.string(),
version: z.number(),
isLatest: z.boolean(),
isForgotten: z.boolean(),
isStatic: z.boolean().optional(),
isInference: z.boolean().optional(),
createdAt: z.string(),
updatedAt: z.string(),
sourceCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
history: z.array(memoryHistorySchema).optional(),
})
export const addMemoryOutputSchema = z.object({
action: z.enum(["save", "forget"]),
success: z.boolean(),
@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
export const listMemoriesOutputSchema = z.object({
memoryEntries: z.array(memoryEntryOutputSchema),
pagination: paginationSchema,
})
// Reuse the shared schema so the tool's output contract stays identical to what
// the client parses — the two can't drift.
export const listMemoriesOutputSchema = memoriesListSchema
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
@ -149,7 +122,6 @@ export const whoAmIOutputSchema = z.object({
version: z.string().optional(),
})
.optional(),
sessionId: z.string().optional(),
})
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>

View file

@ -20,7 +20,6 @@ export function register(deps: ToolDeps) {
deps.getActiveContainerTag(),
])
const client = deps.getClientInfo(context)
const sessionId = context.sessionId
const structuredContent: WhoAmIOutput = {
userId: session.user.id,
...(session.user.email ? { email: session.user.email } : {}),
@ -34,7 +33,6 @@ export function register(deps: ToolDeps) {
: null,
...(session.scope ? { scope: session.scope } : {}),
...(client ? { client } : {}),
...(sessionId ? { sessionId } : {}),
}
return {
content: [textContent(JSON.stringify(structuredContent))],

View file

@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
email: z.string().optional(),
name: z.string().optional(),
}),
org: z.looseObject({ id: z.string().min(1) }).optional(),
role: z.string().optional(),
accessType: z.enum(["full", "restricted"]).optional(),
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
// Extracted memory entries from /v4/memories/list. Single source of truth for
// both the client parser and the listMemories tool output schema, so the two
// can't drift (a mismatch previously produced Ajv "must NOT have additional
// properties"). z.object strips unknown API fields on parse, keeping parsed data
// matched to the strict MCP output contract while tolerating new API fields.
export const memoryEntryHistorySchema = z.object({
id: z.string(),
memory: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
isLatest: z.boolean().optional(),
isForgotten: z.boolean().optional(),
})
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
export const memoryEntrySchema = z.object({
id: z.string(),
memory: z.string(),
version: z.number(),
isLatest: z.boolean(),
isForgotten: z.boolean(),
isStatic: z.boolean().optional(),
isInference: z.boolean().optional(),
createdAt: z.string(),
updatedAt: z.string(),
sourceCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
history: z.array(memoryEntryHistorySchema).optional(),
})
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
export const memoriesListSchema = z.object({
memoryEntries: z.array(memoryEntrySchema),
pagination: paginationSchema,
})
export type MemoriesList = z.infer<typeof memoriesListSchema>
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
// Adding a new view here is a compile error in App.tsx until the case is handled.

View file

@ -40,9 +40,18 @@ const extractContent = (memory: SearchResult) => {
return "No content available"
}
// metadata.url comes from ingested content, so only http(s) reaches the OS opener.
const extractUrl = (memory: SearchResult) => {
if (memory.metadata?.url && typeof memory.metadata.url === "string") {
return memory.metadata.url
const url = memory.metadata.url
try {
const parsed = new URL(url)
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return url
}
} catch {
return null
}
}
return null
}

View file

@ -1,4 +1,3 @@
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
NEXT_PUBLIC_POSTHOG_KEY=
EXA_API_KEY=
XAI_API_KEY=
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=

View file

@ -6,12 +6,22 @@ import {
export default async function ConfigureSectionPage({
params,
searchParams,
}: {
params: Promise<{ section: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { section } = await params
// Default section is canonical at /configure.
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
if (section === DEFAULT_CONFIGURE_SECTION) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(await searchParams)) {
if (typeof value === "string") query.set(key, value)
else if (Array.isArray(value)) for (const v of value) query.append(key, v)
}
const search = query.toString()
redirect(search ? `/configure?${search}` : "/configure")
}
if (!isConfigureSection(section)) notFound()
return null
}

View file

@ -3,10 +3,12 @@
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
import { SettingsModalProvider } from "@/components/settings/settings-modal"
import { PromoCodeHost } from "@/hooks/use-promo-code"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<SettingsModalProvider>
<PromoCodeHost />
<EnsureWorkspace>{children}</EnsureWorkspace>
<PWAInstallPrompt />
</SettingsModalProvider>

View file

@ -591,6 +591,79 @@ export default function LoginPage() {
/>
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "agentid"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>AgentID</title>
<rect
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="1.8"
width="14"
x="5"
y="8.21"
/>
<path
d="M12 8.21V4.71M12 4.71a1.5 1.5 0 1 0-.01-3 1.5 1.5 0 0 0 .01 3Z"
stroke="currentColor"
strokeWidth="1.8"
/>
<circle
cx="9.25"
cy="13.21"
fill="currentColor"
r="1.25"
/>
<circle
cx="14.75"
cy="13.21"
fill="currentColor"
r="1.25"
/>
<path
d="M9 16.21h6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
}
authProvider="AgentID"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "agentid",
})
setPendingLoginMethod("agentid")
signIn
.oauth2({
callbackURL: getCallbackURL(),
providerId: "agentid",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
</div>
<TextSeparator

View file

@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from "next/server"
import iconDomains from "@/lib/mcp-icon-domains.json"
const DOMAIN_RE =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
const MAX_ICON_BYTES = 256 * 1024
const ALLOWED_DOMAINS = new Set(iconDomains.domains)
export async function GET(request: NextRequest) {
const domain = request.nextUrl.searchParams
.get("domain")
?.trim()
.toLowerCase()
if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) {
return new NextResponse(null, { status: 400 })
}
const response = await fetch(
`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`,
{ next: { revalidate: 60 * 60 * 24 * 7 } },
)
const contentType = response.headers.get("content-type") ?? ""
if (!response.ok || !contentType.startsWith("image/")) {
return new NextResponse(null, { status: 404 })
}
const contentLength = Number(response.headers.get("content-length") ?? 0)
if (contentLength > MAX_ICON_BYTES) {
return new NextResponse(null, { status: 413 })
}
if (!response.body) return new NextResponse(null, { status: 404 })
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let bytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
bytes += value.byteLength
if (bytes > MAX_ICON_BYTES) {
await reader.cancel()
return new NextResponse(null, { status: 413 })
}
chunks.push(value)
}
const body = new Uint8Array(bytes)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new NextResponse(body, {
headers: {
"cache-control":
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000",
"content-type": contentType,
},
})
}

View file

@ -1,3 +1,5 @@
import { hasVerifiedSession } from "@/lib/verify-session"
interface OGResponse {
title: string
description: string
@ -13,6 +15,42 @@ function isValidUrl(urlString: string): boolean {
}
}
const MAX_HTML_BYTES = 2_000_000
// OG parsing only needs <head>, so cap the read rather than buffering the whole body.
async function readBoundedText(
response: Response,
maxBytes = MAX_HTML_BYTES,
): Promise<string | null> {
const contentLength = response.headers.get("content-length")
if (contentLength && Number(contentLength) > maxBytes) {
return null
}
if (!response.body) {
return null
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => {})
return null
}
chunks.push(value)
}
const merged = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(merged)
}
function isPrivateIPv4Octets(a: number, b: number): boolean {
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
@ -247,6 +285,10 @@ function resolveImageUrl(
export async function GET(request: Request) {
try {
if (!(await hasVerifiedSession(request))) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const url = searchParams.get("url")
@ -332,7 +374,13 @@ export async function GET(request: Request) {
if (contentType && !contentType.includes("text/html")) {
return Response.json({ title: "", description: "" })
}
const html = await secondResponse.text()
const html = await readBoundedText(secondResponse)
if (html === null) {
return Response.json(
{ error: "Response too large" },
{ status: 413 },
)
}
return processHtml(html, redirectUrl)
}
}
@ -349,7 +397,10 @@ export async function GET(request: Request) {
return Response.json({ title: "", description: "" })
}
const html = await response.text()
const html = await readBoundedText(response)
if (html === null) {
return Response.json({ error: "Response too large" }, { status: 413 })
}
return processHtml(html, trimmedUrl)
} finally {
clearTimeout(timeoutId)

View file

@ -1,236 +0,0 @@
type AccountSource = "x" | "linkedin"
type ParsedAccount = {
handle: string
url: string
}
function parseXAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
let handle = trimmed.replace(/^@/, "")
const lowerValue = handle.toLowerCase()
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
try {
const url = new URL(
handle.startsWith("http://") || handle.startsWith("https://")
? handle
: `https://${handle}`,
)
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
} catch {
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
}
}
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
return { handle, url: `https://x.com/${handle}` }
}
function parseLinkedInAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
const url = new URL(
trimmed.startsWith("http://") || trimmed.startsWith("https://")
? trimmed
: `https://${trimmed}`,
)
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
} catch {
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
}
}
function parseAccount(
source: AccountSource,
value: string,
): ParsedAccount | null {
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
}
function looksUnavailable(source: AccountSource, html: string) {
const lowerHtml = html.toLowerCase()
if (source === "x") {
return (
lowerHtml.includes("this account doesn") ||
lowerHtml.includes("account suspended") ||
lowerHtml.includes("profile not found")
)
}
return (
lowerHtml.includes("profile not found") ||
lowerHtml.includes("page not found") ||
lowerHtml.includes("this linkedin profile is unavailable")
)
}
function linkedinFallback(account: ParsedAccount, status?: number) {
return Response.json({
found: null,
verified: false,
reason: "unable_to_verify_linkedin",
handle: account.handle,
status,
url: account.url,
})
}
async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
const oembedUrl = new URL("https://publish.twitter.com/oembed")
oembedUrl.searchParams.set("url", account.url)
const response = await fetch(oembedUrl, {
signal,
headers: {
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (response.status === 404 || response.status === 410) {
return Response.json({
found: false,
handle: account.handle,
status: response.status,
url: account.url,
})
}
if (!response.ok) {
return Response.json(
{
error: "Unable to verify account",
handle: account.handle,
status: response.status,
url: account.url,
},
{ status: 502 },
)
}
return Response.json({
found: true,
handle: account.handle,
status: response.status,
url: account.url,
})
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const source = searchParams.get("source")
const value = searchParams.get("value")
if (source !== "x" && source !== "linkedin") {
return Response.json({ error: "Invalid account source" }, { status: 400 })
}
if (!value?.trim()) {
return Response.json({ error: "Missing account value" }, { status: 400 })
}
const account = parseAccount(source, value)
if (!account) {
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 7000)
try {
if (source === "x") {
return await verifyXAccount(account, controller.signal)
}
const response = await fetch(account.url, {
signal: controller.signal,
redirect: "follow",
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (response.status === 404 || response.status === 410) {
return Response.json({
found: false,
handle: account.handle,
status: response.status,
url: account.url,
})
}
if (!response.ok) {
if (source === "linkedin") {
return linkedinFallback(account, response.status)
}
return Response.json(
{
error: "Unable to verify account",
handle: account.handle,
status: response.status,
url: account.url,
},
{ status: 502 },
)
}
const html = await response.text()
const found = !looksUnavailable(source, html)
return Response.json({
found,
handle: account.handle,
status: response.status,
url: account.url,
})
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (source === "linkedin") {
return linkedinFallback(account)
}
return Response.json(
{ error: "Account lookup timed out", handle: account.handle },
{ status: 504 },
)
}
console.error("Account status lookup failed:", error)
if (source === "linkedin") {
return linkedinFallback(account)
}
return Response.json(
{ error: "Unable to verify account", handle: account.handle },
{ status: 502 },
)
} finally {
clearTimeout(timeoutId)
}
}

View file

@ -1,75 +0,0 @@
export interface ExaContentResult {
url: string
text: string
title: string
author?: string
}
interface ExaApiResponse {
results: ExaContentResult[]
}
const exaApiKey = process.env.EXA_API_KEY
if (!exaApiKey) {
console.error(
"EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503",
)
}
export async function POST(request: Request) {
try {
if (!exaApiKey) {
return Response.json(
{ error: "Content extraction is unavailable" },
{ status: 503 },
)
}
const { urls } = await request.json()
if (!Array.isArray(urls) || urls.length === 0) {
return Response.json(
{ error: "Invalid input: urls must be a non-empty array" },
{ status: 400 },
)
}
if (!urls.every((url) => typeof url === "string" && url.trim())) {
return Response.json(
{ error: "Invalid input: all urls must be non-empty strings" },
{ status: 400 },
)
}
const response = await fetch("https://api.exa.ai/contents", {
method: "POST",
headers: {
"x-api-key": exaApiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
urls,
text: true,
livecrawl: "fallback",
}),
})
if (!response.ok) {
console.error(
"Exa API request failed:",
response.status,
response.statusText,
)
return Response.json(
{ error: "Failed to fetch content from Exa API" },
{ status: 500 },
)
}
const data: ExaApiResponse = await response.json()
return Response.json({ results: data.results })
} catch (error) {
console.error("Exa API request error:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}
}

View file

@ -1,109 +0,0 @@
import { xai } from "@ai-sdk/xai"
import { generateText } from "ai"
interface ResearchRequest {
xUrl: string
name?: string
email?: string
}
const ALLOWED_X_HOSTS: ReadonlySet<string> = new Set([
"x.com",
"www.x.com",
"twitter.com",
"www.twitter.com",
"mobile.twitter.com",
])
const X_URL_FALLBACK_REGEX =
/^(?:https?:\/\/)?(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com|mobile\.twitter\.com)\/([^/\s?#]+)/i
function isXHost(hostname: string): boolean {
return ALLOWED_X_HOSTS.has(hostname.toLowerCase())
}
function extractHandle(input: string): string {
const trimmed = input.trim()
if (!trimmed) return ""
let handle = trimmed.replace(/^@+/, "")
const lower = handle.toLowerCase()
if (lower.includes("x.com") || lower.includes("twitter.com")) {
try {
const parsed = new URL(
handle.startsWith("http://") || handle.startsWith("https://")
? handle
: `https://${handle}`,
)
handle = isXHost(parsed.hostname)
? (parsed.pathname.split("/").filter(Boolean)[0] ?? "")
: ""
} catch {
handle = handle.match(X_URL_FALLBACK_REGEX)?.[1] ?? ""
}
}
return handle.replace(/^@+/, "").split(/[/?#]/)[0]?.toLowerCase() ?? ""
}
function finalPrompt(handle: string, userContext: string) {
return `You are researching a user based on their X/Twitter profile to help personalize their experience.
X Handle: @${handle}${userContext}
Please analyze this X/Twitter profile and provide a comprehensive but concise summary of the user. Include:
- Professional background and current role (if available)
- Key interests and topics they engage with
- Notable projects, achievements, or affiliations
- Their expertise areas
- Any other relevant information that helps understand who they are
Format the response as clear, readable paragraphs. Focus on factual information from their profile. If certain information is not available, skip that section rather than speculating.`
}
export async function POST(req: Request) {
try {
const { xUrl, name, email }: ResearchRequest = await req.json()
if (!xUrl?.trim()) {
return Response.json(
{ error: "X/Twitter URL or handle is required" },
{ status: 400 },
)
}
const handle = extractHandle(xUrl)
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) {
return Response.json(
{ error: "Could not parse a valid X/Twitter handle from the input" },
{ status: 400 },
)
}
const contextParts: string[] = []
if (name) contextParts.push(`Name: ${name}`)
if (email) contextParts.push(`Email: ${email}`)
const userContext =
contextParts.length > 0
? `\n\nAdditional context about the user:\n${contextParts.join("\n")}`
: ""
const { text } = await generateText({
model: xai.responses("grok-4-fast"),
prompt: finalPrompt(handle, userContext),
tools: {
web_search: xai.tools.webSearch(),
x_search: xai.tools.xSearch({
allowedXHandles: [handle],
}),
},
})
return Response.json({ text })
} catch (error) {
console.error("Research API error:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}
}

View file

@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context"
import { useSession } from "@lib/auth"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { useCustomer } from "autumn-js/react"
import { ArrowRight, Loader, XCircle } from "lucide-react"
import { ArrowRight, XCircle } from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect, useMemo, useState } from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
@ -88,7 +87,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
"Auto-capture of project decisions",
"Context-aware suggestions",
],
icon: "/images/plugins/cursor.svg",
icon: "/images/plugins/cursor.png",
},
codex: {
name: "OpenAI Codex",
@ -103,11 +102,77 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
},
}
const MULTI_PLUGIN_FEATURES = [
"Share one persistent memory layer across selected coding agents.",
"Recall project context, coding decisions, and prior sessions.",
"Connect every selected plugin with one approval.",
]
function isKnownPlugin(value: string): boolean {
return Object.hasOwn(PLUGIN_INFO, value)
}
function getPluginName(client: string): string {
return PLUGIN_INFO[client]?.name ?? "External Tool"
}
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
function formatPluginNames(clients: string[]): string {
const names = clients.map((id) => getPluginName(id))
if (names.length === 0) return "External Tool"
if (names.length === 1) return names[0] ?? "External Tool"
if (names.length === 2) {
return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}`
}
return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}`
}
function encodeBase64UrlJson(value: Record<string, string>): string {
return btoa(JSON.stringify(value))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
function PluginLogoStack({ clients }: { clients: string[] }) {
if (clients.length === 0) {
return (
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<ArrowRight className="size-5 text-[#4BA0FA]" />
</div>
)
}
return (
<div className="flex items-center justify-center">
{clients.map((id, index) => {
const plugin = PLUGIN_INFO[id]
return (
<div
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
key={`${id}-${index}`}
style={{ zIndex: clients.length - index }}
title={plugin?.name ?? id}
>
{plugin ? (
<Image
alt={plugin.name}
className="size-6 object-contain"
height={24}
src={plugin.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
)
})}
</div>
)
}
type Status = "loading" | "creating" | "success" | "error"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
@ -121,16 +186,34 @@ function AuthConnectContent() {
const router = useRouter()
const { data: session, isPending } = useSession()
const { org, organizations, isRestoring } = useAuth()
const autumn = useCustomer()
const [status, setStatus] = useState<Status>("loading")
const [error, setError] = useState<string | null>(null)
const [isUpgrading, setIsUpgrading] = useState(false)
const callback = params.get("callback")
const client = params.get("client")
const validClient = client && client in PLUGIN_INFO ? client : null
const displayName = validClient ? getPluginName(validClient) : "External Tool"
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
const clientsParam = params.get("clients")
const hasClientList = params.has("clients")
const rawRequestedClients = useMemo(
() =>
(clientsParam !== null ? clientsParam.split(",") : client ? [client] : [])
.map((value) => value.trim())
.filter(Boolean),
[client, clientsParam],
)
const requestedClients = useMemo(
() => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))),
[rawRequestedClients],
)
const invalidClients = useMemo(
() => rawRequestedClients.filter((value) => !isKnownPlugin(value)),
[rawRequestedClients],
)
const validClient = requestedClients[0] ?? null
const displayName = formatPluginNames(requestedClients)
const pluginInfo =
requestedClients.length === 1 && validClient
? PLUGIN_INFO[validClient]
: null
// Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here.
@ -166,6 +249,16 @@ function AuthConnectContent() {
setError("Invalid callback URL.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
return
}
if (requestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (!session || !org) {
setStatus("error")
setError(
@ -177,17 +270,13 @@ function AuthConnectContent() {
try {
setStatus("creating")
const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient)
fetchParams.set("client", requestedClients[0] ?? "")
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
setStatus("upgrade")
return
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -198,7 +287,21 @@ function AuthConnectContent() {
setStatus("success")
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
if (hasClientList) {
redirectUrl.searchParams.set(
"keys",
encodeBase64UrlJson(
Object.fromEntries(
requestedClients.map((requestedClient) => [
requestedClient,
data.key,
]),
),
),
)
} else {
redirectUrl.searchParams.set("apikey", data.key)
}
redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString()
} catch (err) {
@ -208,23 +311,23 @@ function AuthConnectContent() {
}
}
async function handleUpgrade() {
try {
setIsUpgrading(true)
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
await autumn.attach({
planId: "api_pro",
successUrl: safeSuccessUrl,
})
} catch (err) {
console.error("Upgrade failed:", err)
setIsUpgrading(false)
}
}
// Show a spinner while session/org data is loading or while we're about
// to redirect to onboarding (prevents a brief flash of the connect card).
const isAuthLoading = isPending || isRestoring || organizations === null
useEffect(() => {
if (status !== "loading") return
if (rawRequestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
}
}, [invalidClients, rawRequestedClients.length, status])
if (isAuthLoading || shouldRedirectToOnboarding) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
@ -238,19 +341,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<PluginLogoStack clients={requestedClients} />
<div className="text-center">
<h2
className={dmSans125ClassName(
@ -265,13 +356,15 @@ function AuthConnectContent() {
)}
>
{pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`}
(requestedClients.length > 1
? "Use one Supermemory account across these plugins."
: `Use your Supermemory account with ${displayName}.`)}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<ul className="w-full space-y-2.5">
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
(feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
@ -282,16 +375,16 @@ function AuthConnectContent() {
{feature}
</span>
</li>
))}
</ul>
)}
),
)}
</ul>
<button
type="button"
onClick={handleConnect}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"text-[#FAFAFA] font-medium text-[14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
@ -311,104 +404,6 @@ function AuthConnectContent() {
</div>
)
}
if (status === "upgrade") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
{pluginInfo?.name ?? displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`A paid plan is required to use ${displayName} with Supermemory.`}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading || autumn.isLoading ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Upgrading
</>
) : (
"Upgrade to Pro \u2014 $19/month"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<a
href="https://app.supermemory.ai/settings#billing"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
View all plans
</a>
</div>
</div>
</div>
)
}
if (status === "error") {
return (
<div className={pageWrapperClass}>
@ -435,7 +430,7 @@ function AuthConnectContent() {
<div className="flex flex-col gap-2 w-full">
<button
type="button"
onClick={() => window.location.reload()}
onClick={() => void handleConnect()}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",

View file

@ -11,6 +11,7 @@ import { Suspense } from "react"
import { Toaster } from "@ui/components/sonner"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ThemeProvider } from "@/lib/theme-provider"
import { PromoCodeCapture } from "@/hooks/use-promo-code"
const font = Space_Grotesk({
subsets: ["latin"],
@ -95,6 +96,7 @@ export default function RootLayout({
includeCredentials={true}
headers={{ "X-App-Source": "nova" }}
>
<PromoCodeCapture />
<QueryProvider>
<AuthProvider>
<PostHogProvider>

View file

@ -19,6 +19,8 @@ import {
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { connectorPause } from "@/lib/connector-availability"
import { useConnectorNotify } from "@/lib/connector-notify"
import type { z } from "zod"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
@ -42,6 +44,7 @@ import {
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type GDriveSyncScope = "scoped" | "full"
@ -309,6 +312,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const promoCode = usePromoCode()
const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
@ -330,8 +334,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -501,7 +507,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
},
})
const notify = useConnectorNotify()
// Every connect path funnels here; a `disabled` button would swallow the click.
const handleConnect = (provider: ConnectorProvider) => {
if (connectorPause(provider)) {
notify.request(provider)
return
}
setConnectingProvider(provider)
addConnectionMutation.mutate({
provider,
@ -544,14 +557,32 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex items-center gap-3 flex-1">
<Icon className="size-6 text-[#737373]" />
<div className="space-y-[6px] flex-1">
<p className="text-[16px] font-medium">{config.title}</p>
<div className="flex items-center gap-2">
<p className="text-[16px] font-medium">{config.title}</p>
{connectorPause(provider) && (
<span className="shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]">
Paused
</span>
)}
</div>
<p className="text-[16px] text-[#737373]">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{provider === "google-drive" ? (
{connectorPause(provider) ? (
<button
type="button"
onClick={() => notify.request(provider)}
title={connectorPause(provider)?.message}
className="bg-[#14161A] text-[#FAFAFA] text-[14px] font-medium px-3 h-8 rounded-md border border-[rgba(82,89,102,0.3)] hover:bg-[#1B1F24] transition-colors"
>
{notify.isRequested(provider)
? "We'll email you"
: "Notify me"}
</button>
) : provider === "google-drive" ? (
<div className="flex items-center rounded-md overflow-hidden">
<button
type="button"
@ -717,6 +748,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex flex-col">
<DropdownMenuItem
onClick={() => {
if (connectorPause("google-drive")) {
notify.request("google-drive")
return
}
setConnectingProvider("google-drive")
addConnectionMutation.mutate({
provider: "google-drive",
@ -737,6 +772,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (connectorPause("google-drive")) {
notify.request("google-drive")
return
}
setConnectingProvider("google-drive")
addConnectionMutation.mutate({
provider: "google-drive",

View file

@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
import { usePromoCode } from "@/hooks/use-promo-code"
type TabType = "note" | "link" | "file" | "connect"
@ -153,6 +154,7 @@ export function AddDocument({
})
const autumn = useCustomer()
const promoCode = usePromoCode()
const {
tokensUsed,
searchesUsed,
@ -342,8 +344,10 @@ export function AddDocument({
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -442,8 +446,10 @@ export function AddDocument({
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -43,6 +43,7 @@ import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { isConnectorPaused } from "@/lib/connector-availability"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
useQuickNoteDraftReset,
@ -603,6 +604,10 @@ export function AppExperience() {
const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => {
if (integration && isConnectorPaused(integration)) {
void setViewMode("integrations")
return
}
if (integration === "notion" || integration === "google-drive") {
void setAddDoc("connect")
return

View file

@ -1,5 +1,5 @@
import { cn } from "@lib/utils"
import { Gmail, Granola, Notion } from "@ui/assets/icons"
import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons"
import { dmSans125ClassName } from "@/lib/fonts"
export function SlackMark({ className }: { className?: string }) {
@ -99,6 +99,8 @@ export function brainConnectorIcon(
className = "size-[18px]",
): React.ReactNode {
switch (slug) {
case "google-drive":
return <GoogleDrive className={className} />
case "gmail":
return <Gmail className={className} />
case "github":

View file

@ -9,6 +9,7 @@ import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useBrainTrial } from "@/hooks/use-brain-trial"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { BrainSetupModal } from "@/components/brain-setup-modal"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
@ -172,7 +173,8 @@ export function BrainHomeView() {
const o = useBrainOverview()
const trial = useBrainTrial()
const board = useConnectionsBoard()
const { needsSetup } = useTrialStatus()
const { data: trialStatus } = useTrialStatus()
const { org: activeOrg } = useAuth()
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
const milestones = [
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
@ -184,6 +186,10 @@ export function BrainHomeView() {
]
const milestonesDone = milestones.filter(Boolean).length
const milestonesTotal = milestones.length
// Positive gate: a slow trial request would otherwise prompt an expired org.
const showSetupPrompt = Boolean(
board.slack && !board.slack.connected && trialStatus?.active,
)
const showTimeline =
!o.loading && (trial.state !== "none" || milestonesDone < milestonesTotal)
@ -199,7 +205,7 @@ export function BrainHomeView() {
setupTotal={milestonesTotal}
lastUpdatedAt={o.lastUpdatedAt}
/>
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
<BrainSetupModal enabled={showSetupPrompt} orgId={activeOrg?.id} />
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 space-y-6">
{board.showBoard && <ConnectToolsCard board={board} />}
@ -221,6 +227,7 @@ export function BrainHomeView() {
<AskInSlackCard board={board} />
</div>
</div>
{showSetupPrompt && <SlackBanner />}
</div>
)
}

View file

@ -18,11 +18,11 @@ const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
const cardStyle = {
export const cardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const tileStyle = {
export const tileStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}

View file

@ -0,0 +1,178 @@
"use client"
import { Check } from "lucide-react"
import { useEffect, useState } from "react"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { cn } from "@lib/utils"
import { SlackMark } from "@/components/brain-connector-icons"
import { cardStyle, tileStyle } from "@/components/brain-home/connections-board"
import { analytics } from "@/lib/analytics"
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const dismissKey = (orgId: string) => `sm_brain_setup_modal_dismissed:${orgId}`
const CALL_FEATURES = [
"We install Slack with you, live",
"Connect Gmail, Notion, Linear and the rest",
"Wire up plugins and answer your questions",
]
export function BrainSetupModal({
enabled,
orgId,
}: {
enabled: boolean
orgId: string | undefined
}) {
const [open, setOpen] = useState(false)
useEffect(() => {
// Also closes if the org stops being eligible while the dialog is open.
if (!enabled || !orgId) {
setOpen(false)
return
}
let dismissed = false
try {
dismissed = localStorage.getItem(dismissKey(orgId)) === "1"
} catch {}
if (dismissed) return
setOpen(true)
analytics.brainSetupModalSeen()
}, [enabled, orgId])
const close = () => {
setOpen(false)
if (!orgId) return
try {
localStorage.setItem(dismissKey(orgId), "1")
} catch {}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent
showCloseButton={false}
className={cn(
"w-[94%]! max-w-[520px]! rounded-[22px] border border-white/[0.08] bg-[#1B1F24] p-5",
dmSansClassName(),
)}
style={cardStyle}
>
<DialogTitle className="sr-only">Get set up</DialogTitle>
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Get set up
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
Your brain is ready. Let's get it working where your team is.
</p>
</div>
<div className="relative mt-4 overflow-hidden rounded-[14px] border border-[#2261CA66] bg-[#00173C] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<span className="absolute right-5 top-5 inline-flex h-[18px] items-center rounded-[3px] bg-[#4BA0FA] px-1.5 text-[10px] font-bold uppercase tracking-[0.36px] text-[#00171A]">
Fastest
</span>
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#7E9BC4]">
Setup call
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[24px] font-bold leading-none tracking-[-0.34px] text-[#FAFAFA]",
)}
>
Do it with us
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[13px] leading-snug text-[#C8D0DA]",
)}
>
30 minutes, live with our team. You leave with a working brain.
</p>
<ul className="mt-5 flex flex-col gap-3">
{CALL_FEATURES.map((feature) => (
<li
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
key={feature}
>
<Check className="mt-0.5 size-3.5 shrink-0 text-[#fafafa]" />
<span>{feature}</span>
</li>
))}
</ul>
<a
href={COMPANY_BRAIN_CAL_HREF}
target="_blank"
rel="noreferrer"
onClick={() => {
analytics.brainSetupCallClicked({ surface: "setup_modal" })
analytics.brainSetupModalPicked({ choice: "call" })
close()
}}
className={cn(
dmSans125ClassName(),
"mt-5 flex h-11 items-center justify-center rounded-[10px] bg-white text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.01]",
)}
>
Book a setup call
</a>
</div>
<div className="mt-3 overflow-hidden rounded-[12px] bg-[#14161A]">
<div className="flex min-h-[52px] items-center gap-3 px-3 py-2.5">
<div
className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
<SlackMark className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
Add to Slack
</p>
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
Rather set it up yourself? Takes a minute.
</p>
</div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
onClick={() => {
analytics.brainSetupModalPicked({ choice: "slack" })
close()
}}
className={cn(
dmSans125ClassName(),
"flex w-[88px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] px-3 py-1.5 text-[12px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
)}
>
Install
</a>
</div>
</div>
<button
type="button"
onClick={close}
className="mx-auto mt-4 block text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A1A1AA]"
>
I'll do it later
</button>
</DialogContent>
</Dialog>
)
}

View file

@ -37,6 +37,7 @@ import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons"
import { BrainTrialPill } from "@/components/brain-trial-pill"
import { SetupCallLink } from "@/components/setup-call-link"
import { GraphIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu"
@ -538,29 +539,7 @@ export function CompanyBrainHeader({
</TooltipContent>
</Tooltip>
)}
{canInvite && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={handleInvite}
aria-label="Invite teammates"
>
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Invite</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Invite teammates
</TooltipContent>
</Tooltip>
)}
<SetupCallLink />
<Tooltip>
<TooltipTrigger asChild>
<Button

View file

@ -45,42 +45,44 @@ export function CompanyBrainPromo() {
return (
<div
className={cn(
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
"relative flex items-start gap-3 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md sm:items-center sm:gap-4",
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
<div className="mt-1 flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef] sm:mt-0">
<Logo className="h-4 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-[#fafafa]">
Give your team a Company Brain
</p>
<p className="text-[13px] text-[#a1a1a1]">
Lives in your Slack. Answers from your team's tools, and brings things
up before you ask.
</p>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:contents">
<div className="min-w-0 pt-1 pr-7 sm:flex-1 sm:pt-0 sm:pr-0">
<p className="text-[15px] font-semibold text-[#fafafa]">
Give your team a Company Brain
</p>
<p className="text-[11px] leading-snug text-[#a1a1a1] sm:text-[13px] sm:leading-normal">
Lives in your Slack. Answers from your team's tools, and brings
things up before you ask.
</p>
</div>
<Button
className={cn(
"h-9! min-h-9 w-fit shrink-0 self-end gap-1.5 rounded-full! px-3 font-medium sm:self-auto",
dmSansClassName(),
)}
onClick={() => {
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
router.push("/onboarding?new=1&mode=team")
}}
variant="headers"
>
Set it up
<ArrowRight className="size-4 shrink-0" />
</Button>
</div>
<Button
className={cn(
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
dmSansClassName(),
)}
onClick={() => {
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
router.push("/onboarding?new=1&mode=team")
}}
variant="headers"
>
Set it up
<ArrowRight className="size-4 shrink-0" />
</Button>
<button
aria-label="Dismiss"
type="button"
onClick={dismiss}
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
className="absolute right-2.5 top-2.5 shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa] sm:static"
>
<XIcon className="size-4" />
</button>

View file

@ -0,0 +1,82 @@
"use client"
import { cn } from "@lib/utils"
import type { ReactNode } from "react"
import { dmSans125ClassName } from "@/lib/fonts"
// Shared connector/integration card shell: icon, name, subtitle, optional
// top-right slot, and a footer split into a status side and an action side.
export function ConnectorCard({
icon,
name,
subtitle,
topRight,
footerLeft,
footerRight,
}: {
icon: ReactNode
name: string
subtitle: string
topRight?: ReactNode
footerLeft: ReactNode
footerRight?: ReactNode
}) {
return (
<div className="flex h-full min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{icon}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<p
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{name}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
)}
>
{subtitle}
</p>
</div>
{topRight}
</div>
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<div className="flex min-w-0 items-center gap-3">{footerLeft}</div>
{footerRight}
</div>
</div>
)
}
export function ScopeChip({
label,
connected,
}: {
label: string
connected: boolean
}) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
connected ? "text-[#FAFAFA]" : "text-[#737373]",
)}
>
<span
className={cn(
"size-[7px] shrink-0 rounded-full",
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
)}
/>
{label}
</span>
)
}

View file

@ -0,0 +1,117 @@
"use client"
import { cn } from "@lib/utils"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
import { dmSans125ClassName } from "@/lib/fonts"
export const sectionLabelClass = cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
)
// Horizontally scrollable card rail with a section heading — shared by the
// main integrations directory and the Company Brain connections directory.
// Arrows appear only when the content actually overflows.
export function SectionRail({
label,
children,
headerSlot,
labelSlot,
scrollbar = "hidden",
}: {
label: string
children: ReactNode
headerSlot?: ReactNode
labelSlot?: ReactNode
scrollbar?: "hidden" | "visible"
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const [hasOverflow, setHasOverflow] = useState(false)
const update = useCallback(() => {
const el = scrollRef.current
if (!el) return
setHasOverflow(el.scrollWidth > el.clientWidth + 4)
setCanScrollLeft(el.scrollLeft > 4)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
useEffect(() => {
update()
const el = scrollRef.current
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("scrollend", update)
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
el.removeEventListener("scrollend", update)
ro.disconnect()
}
}, [update])
const scrollBy = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
setTimeout(update, 450)
}
const arrowClass = cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
)
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className={sectionLabelClass}>{label}</h3>
{labelSlot}
</div>
<div className="hidden items-center gap-1.5 sm:flex">
{headerSlot}
{hasOverflow ? (
<>
<button
type="button"
aria-label="Show previous"
disabled={!canScrollLeft}
onClick={() => scrollBy(-1)}
className={arrowClass}
>
<ArrowLeft className="size-3.5" />
</button>
<button
type="button"
aria-label="Show more"
disabled={!canScrollRight}
onClick={() => scrollBy(1)}
className={arrowClass}
>
<ArrowRight className="size-3.5" />
</button>
</>
) : null}
</div>
</div>
<div
ref={scrollRef}
className={cn(
"flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1",
scrollbar === "visible" ? "scrollbar-thin sm:pb-2" : "scrollbar-none",
)}
>
{children}
</div>
</section>
)
}
// Standard card width inside a rail: full-width stacked on mobile, 2-up on
// small screens, 3-up on large.
export const railItemClass =
"w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"

View file

@ -61,7 +61,6 @@ export function FullscreenNoteModal({
const handleContentChange = useCallback(
(newContent: string) => {
console.log("handleContentChange", newContent)
setContent(newContent)
setDraft(newContent)
},

View file

@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { SectionRail } from "@/components/directory/section-rail"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
@ -36,13 +37,17 @@ import {
FileText,
Globe,
Info,
Bell,
Loader,
Pause,
Plus,
Search,
X,
Zap,
} from "lucide-react"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import { connectorPause } from "@/lib/connector-availability"
import { useConnectorNotify } from "@/lib/connector-notify"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useContainerTags } from "@/hooks/use-container-tags"
@ -71,8 +76,14 @@ import {
isFreeTierPlugin,
normalizePluginClientId,
type InstallStep,
type PluginInfo,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
import {
CopyButton,
INSET,
InstallSteps,
PillButton,
} from "./integrations/install-steps"
import {
ShortcutsConnectButtons,
useShortcutsConnect,
@ -80,6 +91,7 @@ import {
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
import { GranolaConnectModal } from "./granola-connect-modal"
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
import { usePromoCode } from "@/hooks/use-promo-code"
type Connection = z.infer<typeof ConnectionResponseSchema>
@ -536,13 +548,13 @@ const SECTIONS: Array<{
action: { type: "external", href: POKE_RECIPE_URL },
},
{
kind: "client",
id: "shortcuts",
name: "Apple Shortcuts",
tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Save anything from your phone or Mac",
icon: <AppleShortcutsIcon />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
kind: "import",
id: "x-bookmarks",
name: "Import X bookmarks",
tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Turn your X bookmarks into memory",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
viewMode: "import" as ViewParamValue,
},
{
kind: "client",
@ -555,13 +567,13 @@ const SECTIONS: Array<{
dev: true,
},
{
kind: "import",
id: "x-bookmarks",
name: "Import X bookmarks",
tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Turn your X bookmarks into memory",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
viewMode: "import" as ViewParamValue,
kind: "client",
id: "shortcuts",
name: "Apple Shortcuts",
tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Save anything from your phone or Mac",
icon: <AppleShortcutsIcon />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
},
],
},
@ -591,6 +603,52 @@ export function DetailWrapper({
)
}
function NotifyMeButton({
provider,
title,
onClick,
}: {
provider: string
title?: string
onClick?: () => void
}) {
const { isRequested, request } = useConnectorNotify()
const requested = isRequested(provider)
return (
<PillButton
className={requested ? "cursor-default opacity-60" : undefined}
onClick={() => {
onClick?.()
if (!requested) request(provider)
}}
title={title}
>
{requested ? (
<>
<Check className="size-3.5" /> We&apos;ll email you
</>
) : (
<>
<Bell className="size-3.5" /> Notify me
</>
)}
</PillButton>
)
}
function PausedChip() {
return (
<span
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]",
)}
>
Paused
</span>
)
}
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
return (
<span
@ -637,6 +695,206 @@ function IconBox({
)
}
const PLUGIN_COMMANDS: InstallStep[] = [
{
code: "npx supermemory plugin",
copyLabel: "Install plugins",
title: "Install plugins",
description:
"Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.",
},
{
code: "npx supermemory plugin login",
copyLabel: "Reconnect plugins",
title: "Reconnect plugins",
description:
"Run browser OAuth again for plugins that are already installed, without reinstalling them.",
},
{
code: "npx supermemory plugin uninstall",
copyLabel: "Uninstall plugins",
title: "Uninstall plugins",
description:
"Remove selected plugin integrations while keeping your credentials and memories.",
},
]
const PLUGIN_COMMAND_CLIENTS = [
"claude_code",
"cursor",
"codex",
"opencode",
] as const
type PluginSetupTab = "agent" | "manual"
const PLUGIN_CLI_TARGETS: Partial<Record<string, string>> = {
claude_code: "claude",
codex: "codex",
cursor: "cursor",
opencode: "opencode",
}
function pluginAgentPrompt(plugin: PluginInfo): string {
const cliTarget = PLUGIN_CLI_TARGETS[plugin.id]
if (cliTarget) {
return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.`
}
const docsInstruction = plugin.docsUrl
? ` Follow the official setup instructions at ${plugin.docsUrl}.`
: " Follow its official setup instructions."
return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.`
}
function PluginSetupMethodTabs({
value,
onChange,
}: {
value: PluginSetupTab
onChange: (value: PluginSetupTab) => void
}) {
return (
<div
className={cn(
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
)}
role="tablist"
aria-label="Setup method"
>
{(["agent", "manual"] as const).map((tab) => (
<button
key={tab}
className={cn(
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
value === tab
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
onClick={() => onChange(tab)}
role="tab"
type="button"
aria-selected={value === tab}
>
{tab === "agent" ? "Agent instructions" : "Manual instructions"}
</button>
))}
</div>
)
}
function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) {
const prompt = pluginAgentPrompt(plugin)
return (
<div className="flex min-w-0 items-start gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] text-[#E4E4E7]">
{prompt}
</p>
<CopyButton text={prompt} label="Agent instructions" />
</div>
)
}
function PluginCommandsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
}}
className={cn(
dmSans125ClassName(),
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[620px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Supermemory plugin commands
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
<div
role="img"
aria-label="Claude Code, Cursor, Codex, and OpenCode"
className="flex shrink-0 -space-x-2"
>
{PLUGIN_COMMAND_CLIENTS.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
return (
<span
key={pluginId}
className="flex size-8 items-center justify-center rounded-[9px] border border-white/[0.12] bg-[#0D121A] p-1.5 shadow-sm"
>
<Image
src={plugin.icon}
alt=""
width={20}
height={20}
className="size-5 object-contain"
/>
</span>
)
})}
</div>
<div className="min-w-0 flex-1">
<p className="text-[16px] font-semibold leading-tight text-[#FAFAFA]">
Plugin commands
</p>
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">
Install, reconnect, or remove integrations from one CLI.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-3 sm:p-4",
INSET,
)}
>
<InstallSteps steps={PLUGIN_COMMANDS} />
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 pt-1">
<p className="text-[11px] text-[#737373]">
Run these commands from your terminal.
</p>
<DialogPrimitive.Close asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</DialogPrimitive.Close>
</div>
</DialogContent>
</Dialog>
)
}
type InfoUseCase = {
title: string
description: string
@ -2067,6 +2325,8 @@ function ItemCard({
docsUrl,
leftIndicator,
statusSlot,
layoutClassName,
paused,
}: {
actionSlot: ReactNode
infoActionSlot?: ReactNode
@ -2081,6 +2341,8 @@ function ItemCard({
docsUrl?: string
leftIndicator?: ReactNode
statusSlot?: ReactNode
layoutClassName?: string
paused?: boolean
}) {
const [infoOpen, setInfoOpen] = useState(false)
return (
@ -2098,6 +2360,9 @@ function ItemCard({
className={cn(
"group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
id === "shortcuts" &&
"max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center",
layoutClassName,
)}
>
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
@ -2115,7 +2380,12 @@ function ItemCard({
<div className="flex shrink-0 items-start justify-between gap-2">
<IconBox>{icon}</IconBox>
</div>
<div className="flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3">
<div
className={cn(
"flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3",
id === "shortcuts" && "max-sm:contents",
)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1">
{leftIndicator}
@ -2127,7 +2397,8 @@ function ItemCard({
>
{name}
</span>
{isNew && <NewChip />}
{paused && <PausedChip />}
{isNew && !paused && <NewChip />}
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
</div>
<p
@ -2139,7 +2410,13 @@ function ItemCard({
{tagline}
</p>
</div>
<div className="flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between">
<div
className={cn(
"flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between",
id === "shortcuts" &&
"max-sm:col-span-2 max-sm:row-start-2 max-sm:w-full",
)}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
<div
className="hidden min-w-0 flex-1 sm:flex"
@ -2150,7 +2427,11 @@ function ItemCard({
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
<div
className="flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]"
className={cn(
"flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]",
id === "shortcuts" &&
"max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full",
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
@ -2453,95 +2734,6 @@ function CategoryFilterToggle({
)
}
function SectionRail({
label,
children,
headerSlot,
}: {
label: string
children: ReactNode
headerSlot?: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const update = useCallback(() => {
const el = scrollRef.current
if (!el) return
setCanScrollLeft(el.scrollLeft > 4)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
useEffect(() => {
update()
const el = scrollRef.current
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("scrollend", update)
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
el.removeEventListener("scrollend", update)
ro.disconnect()
}
}, [update])
const scrollBy = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
setTimeout(update, 450)
}
const arrowClass = cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
)
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3
className={cn(
dmSans125ClassName(),
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
)}
>
{label}
</h3>
<div className="hidden items-center gap-1.5 sm:flex">
{headerSlot}
<button
type="button"
aria-label="Show previous"
disabled={!canScrollLeft}
onClick={() => scrollBy(-1)}
className={arrowClass}
>
<ArrowLeft className="size-3.5" />
</button>
<button
type="button"
aria-label="Show more"
disabled={!canScrollRight}
onClick={() => scrollBy(1)}
className={arrowClass}
>
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
<div
ref={scrollRef}
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
>
{children}
</div>
</section>
)
}
export function IntegrationsView({
publicMode = false,
onOpenDocument,
@ -2555,6 +2747,7 @@ export function IntegrationsView({
const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const promoCode = usePromoCode()
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
// stay on hasProProduct. See useConnectorAccess.
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
@ -2566,18 +2759,21 @@ export function IntegrationsView({
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
const [newKey, setNewKey] = useState<{
open: boolean
key: string
pluginId: string | null
loading: boolean
}>({ open: false, key: "", pluginId: null, loading: false })
const [pluginSetupTab, setPluginSetupTab] = useState<PluginSetupTab>("agent")
const openPluginSetup = useCallback((pluginId: string) => {
setPluginSetupTab("agent")
setNewKey({ open: true, key: "", pluginId, loading: false })
}, [])
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
null,
)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
)
const { data: pluginsData } = useQuery({
queryFn: async () => {
@ -2747,11 +2943,6 @@ export function IntegrationsView({
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -2761,12 +2952,7 @@ export function IntegrationsView({
},
onMutate: (pluginId) => setConnectingPlugin(pluginId),
onError: (err) => {
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
setNewKey((s) =>
s.loading
? { open: false, key: "", pluginId: null, loading: false }
: s,
)
setNewKey((s) => ({ ...s, loading: false }))
toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error",
})
@ -2776,10 +2962,32 @@ export function IntegrationsView({
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
},
onSuccess: (data, pluginId) => {
setNewKey({ open: true, key: data.key, pluginId, loading: false })
setNewKey((s) =>
s.open && s.pluginId === pluginId
? { ...s, key: data.key, loading: false }
: s,
)
},
})
const generatePluginKey = () => {
const pluginId = newKey.pluginId
if (
!pluginId ||
newKey.key ||
newKey.loading ||
createPluginKeyMutation.isPending
)
return
setNewKey((s) => ({ ...s, loading: true }))
createPluginKeyMutation.mutate(pluginId)
}
const selectPluginSetupTab = (tab: PluginSetupTab) => {
setPluginSetupTab(tab)
if (tab === "manual") generatePluginKey()
}
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
const response = await $fetch("@post/connections/:provider", {
@ -2824,14 +3032,22 @@ export function IntegrationsView({
}
}
const handlePausedConnector = useCallback((provider: string) => {
const pause = connectorPause(provider)
if (!pause) return
toast.info(pause.message)
}, [])
const handleUpgrade = useCallback(
async (planId?: unknown) => {
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
try {
const result = await autumn.attach({
planId: checkoutPlanId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -2842,7 +3058,7 @@ export function IntegrationsView({
toast.error("Failed to start checkout. Please try again.")
}
},
[autumn],
[autumn, promoCode],
)
const redirectToLogin = useCallback(() => {
@ -2909,10 +3125,7 @@ export function IntegrationsView({
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
// Open instantly; the key fills in on mint. The ?connect param stays the source
// of truth until the modal closes.
setNewKey({ open: true, key: "", pluginId: target, loading: true })
createPluginKeyMutation.mutate(target)
openPluginSetup(target)
}
return
}
@ -2930,6 +3143,10 @@ export function IntegrationsView({
if (["notion", "google-drive", "onedrive"].includes(target)) {
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
void setConnectTarget(null)
if (connectorPause(target)) {
handlePausedConnector(target)
return
}
void setAddDoc("connect")
}
}, 0)
@ -2947,8 +3164,9 @@ export function IntegrationsView({
redirectToLogin,
setConnectTarget,
setAddDoc,
createPluginKeyMutation,
handleUpgrade,
openPluginSetup,
handlePausedConnector,
])
const closeMcpModal = () => {
@ -3263,7 +3481,7 @@ export function IntegrationsView({
handleUpgrade("api_pro")
return
}
createPluginKeyMutation.mutate("claude_code")
openPluginSetup("claude_code")
},
},
{
@ -3341,7 +3559,7 @@ export function IntegrationsView({
return
}
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
openPluginSetup(item.pluginId)
}}
disabled={!!connectingPlugin}
className={cn(
@ -3362,12 +3580,7 @@ export function IntegrationsView({
<FinishSetupButton
onClick={() => {
trackCard(item)
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
if (connectingPlugin) return
createPluginKeyMutation.mutate(item.pluginId)
return
}
setFinishSetupPluginId(item.pluginId)
openPluginSetup(item.pluginId)
}}
/>
)
@ -3384,7 +3597,7 @@ export function IntegrationsView({
<PillButton
onClick={() => {
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
openPluginSetup(item.pluginId)
}}
disabled={!!connectingPlugin}
>
@ -3402,15 +3615,20 @@ export function IntegrationsView({
const count = connectionsByProvider[item.provider].length
const isGranola = item.provider === "granola"
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
const pause = connectorPause(item.provider)
if (count > 0) {
return (
<div className="flex w-full items-center justify-between gap-2">
<button
type="button"
aria-label="Add another knowledge source"
title="Add another knowledge source"
aria-label={pause ? "Paused" : "Add another knowledge source"}
title={pause ? pause.message : "Add another knowledge source"}
onClick={() => {
trackCard(item)
if (pause) {
handlePausedConnector(item.provider)
return
}
if (isGranola) {
if (!connectorAccess) {
handleUpgrade("api_pro")
@ -3424,13 +3642,27 @@ export function IntegrationsView({
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
pause && "cursor-not-allowed opacity-50",
)}
>
<Plus className="size-4" />
{pause ? (
<Pause className="size-4" />
) : (
<Plus className="size-4" />
)}
</button>
</div>
)
}
if (pause) {
return (
<NotifyMeButton
onClick={() => trackCard(item)}
provider={item.provider}
title={pause.message}
/>
)
}
if (needsPlanUpgrade) {
return (
<PillButton onClick={() => handleUpgrade("api_pro")}>
@ -3554,7 +3786,7 @@ export function IntegrationsView({
return
}
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
openPluginSetup(item.pluginId)
}}
disabled={!!connectingPlugin}
>
@ -3629,7 +3861,7 @@ export function IntegrationsView({
}
}
const renderItemCard = (item: Item) => (
const renderItemCard = (item: Item, layoutClassName?: string) => (
<ItemCard
key={item.id}
actionSlot={renderRight(item)}
@ -3645,6 +3877,8 @@ export function IntegrationsView({
docsUrl={item.docsUrl}
leftIndicator={renderLeftIndicator(item)}
statusSlot={renderStatus(item)}
layoutClassName={layoutClassName}
paused={item.kind === "connector" && !!connectorPause(item.provider)}
/>
)
@ -3666,10 +3900,6 @@ export function IntegrationsView({
!isAutumnLoading &&
!hasProProduct &&
!isFreeTierPlugin(connectedPluginId)
const finishSetupPlugin = finishSetupPluginId
? PLUGIN_CATALOG[finishSetupPluginId]
: undefined
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
const pluginSteps = dialogPlugin?.installSteps ?? []
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
@ -3754,7 +3984,14 @@ export function IntegrationsView({
</p>
) : q || category !== "all" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleItems.map((item) => renderItemCard(item))}
{visibleItems.map((item) =>
renderItemCard(
item,
item.id === "shortcuts"
? "sm:w-max sm:min-w-full"
: undefined,
),
)}
</div>
) : (
<div className="flex flex-col gap-6">
@ -3767,6 +4004,23 @@ export function IntegrationsView({
<SectionRail
key={cat}
label={CATEGORY_LABEL[cat]}
labelSlot={
cat === "plugins" ? (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={pluginCommandsOpen}
onClick={() => setPluginCommandsOpen(true)}
className={cn(
dmSans125ClassName(),
"inline-flex items-center gap-1.5 rounded-full text-[10px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4BA0FA]/60 sm:text-[11px]",
)}
>
<span>Install plugins with one command</span>
<NewChip />
</button>
) : null
}
headerSlot={
cat === "ai-clients" && activeMcpKey ? (
<McpConnectedPill
@ -3812,6 +4066,11 @@ export function IntegrationsView({
</div>
</div>
<PluginCommandsDialog
open={pluginCommandsOpen}
onOpenChange={setPluginCommandsOpen}
/>
<Dialog
open={newKey.open}
onOpenChange={(open) => {
@ -3821,7 +4080,10 @@ export function IntegrationsView({
pluginId: open ? s.pluginId : null,
loading: open ? s.loading : false,
}))
if (!open) void setConnectTarget(null)
if (!open) {
setPluginSetupTab("agent")
void setConnectTarget(null)
}
}}
>
<DialogContent
@ -3854,9 +4116,11 @@ export function IntegrationsView({
Set up {dialogPlugin?.name ?? "your plugin"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
{newKey.loading
? "Generating your key…"
: "Copy your key and run these steps to finish."}
{pluginSetupTab === "agent"
? "Copy this prompt into your coding agent."
: newKey.loading
? "Generating your key…"
: "Follow these steps to finish manually."}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -3889,17 +4153,30 @@ export function IntegrationsView({
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
"min-w-0 space-y-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
{newKey.loading ? (
<PluginSetupMethodTabs
value={pluginSetupTab}
onChange={selectPluginSetupTab}
/>
{pluginSetupTab === "agent" && dialogPlugin ? (
<PluginAgentInstructions plugin={dialogPlugin} />
) : newKey.loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
<Loader className="size-4 animate-spin" />
Generating your key
</div>
) : (
) : newKey.key ? (
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
) : (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-[13px] text-[#A1A1AA]">
We couldn&apos;t generate the key for the manual setup.
</p>
<PillButton onClick={generatePluginKey}>Try again</PillButton>
</div>
)}
</div>
</div>
@ -3913,6 +4190,7 @@ export function IntegrationsView({
pluginId: null,
loading: false,
})
setPluginSetupTab("agent")
void setConnectTarget(null)
}}
className={cn(
@ -4051,7 +4329,7 @@ export function IntegrationsView({
if (!connectedPluginId) return
const pluginId = connectedPluginId
setConnectedPluginId(null)
createPluginKeyMutation.mutate(pluginId)
openPluginSetup(pluginId)
}}
disabled={!!connectingPlugin}
>
@ -4082,91 +4360,6 @@ export function IntegrationsView({
</DialogContent>
</Dialog>
<Dialog
open={!!finishSetupPluginId}
onOpenChange={(open) => {
if (!open) setFinishSetupPluginId(null)
}}
>
<DialogContent
showCloseButton={false}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
}}
className={cn(
dmSans125ClassName(),
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
{finishSetupPlugin && (
<IconBox>
<Image
src={finishSetupPlugin.icon}
alt={finishSetupPlugin.name}
width={24}
height={24}
/>
</IconBox>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
Complete install in the tool this card turns active after the
first API call.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
{finishSetupSteps.length > 0 ? (
<InstallSteps steps={finishSetupSteps} />
) : (
<p className="text-[13px] text-[#A1A1AA]">
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
authentication, then send a test memory.
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<DialogPrimitive.Close asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</DialogPrimitive.Close>
</div>
</DialogContent>
</Dialog>
<Dialog
open={mcpModalOpen}
onOpenChange={(open) => {

View file

@ -16,17 +16,22 @@ export function PillButton({
onClick,
disabled,
type = "button",
className,
title,
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
type?: "button" | "submit"
className?: string
title?: string
}) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
dmSans125ClassName(),
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
@ -34,6 +39,7 @@ export function PillButton({
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
{children}

View file

@ -30,6 +30,7 @@ import {
type PluginInfo,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./install-steps"
import { usePromoCode } from "@/hooks/use-promo-code"
interface ConnectedPlugin {
id: string
@ -415,48 +416,11 @@ function PluginRow({
)
}
type TierFilter = "all" | "pro" | "free"
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "pro", label: "Pro" },
{ value: "free", label: "Free" },
]
function TierFilterToggle({
value,
onChange,
}: {
value: TierFilter
onChange: (value: TierFilter) => void
}) {
return (
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{TIER_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
dmSans125ClassName(),
"rounded-full px-3 h-7 text-[12px] font-medium transition-colors",
value === filter.value
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
{filter.label}
</button>
))}
</div>
)
}
export function PluginsDetail() {
const { org } = useAuth()
const autumn = useCustomer()
const promoCode = usePromoCode()
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
@ -572,11 +536,6 @@ export function PluginsDetail() {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -613,8 +572,10 @@ export function PluginsDetail() {
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -635,17 +596,12 @@ export function PluginsDetail() {
)
const visibleRows = useMemo(() => {
const filtered = catalogRows.filter((id) => {
if (tierFilter === "free") return isFreeTierPlugin(id)
if (tierFilter === "pro") return !isFreeTierPlugin(id)
return true
})
// Connected plugins float to the top (stable within each group).
return [...filtered].sort(
return [...catalogRows].sort(
(a, b) =>
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
)
}, [catalogRows, tierFilter, connectedPluginIds])
}, [catalogRows, connectedPluginIds])
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
@ -684,12 +640,7 @@ export function PluginsDetail() {
)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<SectionHeader>Plugins</SectionHeader>
{catalogRows.length > 0 && (
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
)}
</div>
<SectionHeader>Plugins</SectionHeader>
<div className="flex flex-col">
{visibleRows.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId]

View file

@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
}) {
const { connect, isPending, pendingType } = controller
return (
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<PillButton
className="h-9 flex-none"
onClick={(e) => {

View file

@ -3,6 +3,7 @@
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import {
ArrowRight,
@ -15,6 +16,7 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
type ResearchEvent,
@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({
setPhase("trial")
analytics.brainTrialCardViewed()
}, [needsSetup, phase])
const { org } = useAuth()
const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null
>(null)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
// Returning from checkout remounts and reseeds local state from the email domain,
// so past the confirm step the org's stored domain is the one to trust.
const confirmedDomain = getBrainWorkspaceDomain(org?.metadata)
const clean = normalizeDomain(
phase === "confirm" ? domain : confirmedDomain || domain,
)
const queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done"

View file

@ -16,6 +16,7 @@ import {
import { useRouter } from "next/navigation"
import { useResearchStatus } from "@/hooks/use-research-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { SetupCallButton } from "./setup-call-button"
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
const BACKEND =
@ -480,6 +481,14 @@ export function ResearchActionRail({
})}
</ol>
)}
<div className="mt-6 border-t border-white/[0.06] pt-5">
<p className="mb-3 text-[12px] font-medium leading-[1.5] text-[#525D6E]">
Want us to wire it up live? Slack, connectors, plugins, and a
working walkthrough.
</p>
<SetupCallButton className="w-full" surface="research_rail" />
</div>
</div>
</div>
)

View file

@ -0,0 +1,32 @@
"use client"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { COMPANY_BRAIN_CAL_HREF, type SetupCallSurface } from "@/lib/cal"
import { dmSans125ClassName } from "@/lib/fonts"
export function SetupCallButton({
className,
surface,
children,
}: {
className?: string
surface: SetupCallSurface
children?: React.ReactNode
}) {
return (
<a
href={COMPANY_BRAIN_CAL_HREF}
target="_blank"
rel="noreferrer"
onClick={() => analytics.brainSetupCallClicked({ surface })}
className={cn(
dmSans125ClassName(),
"inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-transparent px-4 py-2.5 text-[13px] font-medium text-[#E4E4E7] transition-colors hover:bg-white/[0.06] hover:text-[#FAFAFA]",
className,
)}
>
{children ?? "Set up Company Brain with us"}
</a>
)
}

View file

@ -80,8 +80,11 @@ import {
} from "@lib/constants"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import { connectorPause } from "@/lib/connector-availability"
import { useConnectorNotify } from "@/lib/connector-notify"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
import { usePromoCode } from "@/hooks/use-promo-code"
type SourceId =
| "drive"
@ -149,11 +152,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
credits: "$20",
productId: "api_pro",
description: "For people building with AI memory",
features: [
"Auto top-up when balance runs low",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
features: ["Auto top-up when balance runs low", "Priority support"],
},
{
id: "max",
@ -377,6 +376,8 @@ export function StepSources({
return !connectorAccess
}
const notify = useConnectorNotify()
const setState = (id: SourceId, state: SourceState) => {
onChange({ ...values, connected: { ...values.connected, [id]: state } })
}
@ -386,6 +387,11 @@ export function StepSources({
id: SourceId,
) => {
analytics.onboardingIntegrationClicked({ integration: provider })
if (connectorPause(provider)) {
notify.request(provider)
setState(id, "waitlist")
return
}
setState(id, "connecting")
try {
const metadata: Record<string, string> = {}
@ -425,12 +431,18 @@ export function StepSources({
setState(id, "waitlist")
}
// Paused beats locked: upgrading cannot unlock a connector nobody can connect.
const guard = (
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
provider?: string,
) => {
return () => {
if (provider && connectorPause(provider)) {
fn()
return
}
if (isLocked(plan) && plan) {
setRequestedPlan(plan)
setRequestedConnector(title)
@ -618,6 +630,7 @@ function OnboardingPlansModal({
requestedPlan: RequiredPlan
}) {
const autumn = useCustomer()
const promoCode = usePromoCode()
const { currentPlan, isLoading } = useTokenUsage(autumn)
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
null,
@ -636,8 +649,10 @@ function OnboardingPlansModal({
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
@ -948,19 +963,22 @@ function GoogleDriveSourceCard({
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
provider?: string,
) => () => void
connectRealProvider: (
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => void
}) {
const pause = connectorPause("google-drive")
return (
<SourceCard
title="Google Drive"
blurb="Docs, sheets, slides — the working memory of your team."
icon={<GoogleDrive className="size-7" />}
state={values.connected.drive ?? "idle"}
ctaLabel="Connect"
ctaLabel={pause ? "Notify me" : "Connect"}
locked={isLocked("pro")}
requiredPlan="pro"
perks={[
@ -968,11 +986,19 @@ function GoogleDriveSourceCard({
"Stays in sync as files change",
"You pick what to share at sign-in",
]}
onConnect={guard("pro", "Google Drive", () =>
connectRealProvider("google-drive", "drive"),
onConnect={guard(
"pro",
"Google Drive",
() => connectRealProvider("google-drive", "drive"),
"google-drive",
)}
headerNote={
values.driveScope === "full" ? (
pause ? (
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#F5A524] font-medium">
<AlertTriangle className="size-3 shrink-0" />
{pause.message}
</p>
) : values.driveScope === "full" ? (
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
<AlertTriangle className="size-3 shrink-0" />
Full Drive can exhaust your monthly usage.
@ -1006,6 +1032,7 @@ function NotionSourceCard({
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
provider?: string,
) => () => void
connectRealProvider: (
provider: "google-drive" | "notion" | "onedrive",
@ -1048,6 +1075,7 @@ function GranolaSourceCard({
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
provider?: string,
) => () => void
onOpen: () => void
}) {
@ -1092,6 +1120,7 @@ function MoreSourcesGrid({
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
provider?: string,
) => () => void
openExternal: (id: SourceId, url: string) => void
requestWaitlist: (id: SourceId) => void
@ -1283,7 +1312,9 @@ function SourceCard({
{isDone ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
<Check className="size-3.5" />
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
{state === "waitlist"
? "We'll email you"
: (doneLabel ?? "Connected")}
</span>
) : (
<Button

View file

@ -394,11 +394,6 @@ export function SelectSpacesModal({
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}

View file

@ -36,6 +36,7 @@ import {
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { usePromoCode } from "@/hooks/use-promo-code"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Pay-as-you-go after $5 runs out",
"Full search and memory access",
"All plugins (Claude Code, Cursor, Hermes...)",
"Email support",
],
},
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Auto top-up when balance runs low",
"Google Drive, Notion, OneDrive & Granola connectors",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
},
@ -531,6 +532,7 @@ export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer()
const promoCode = usePromoCode()
const posthog = usePostHog()
const isCompanyBrain = useHasCompanyBrain()
const brainTrial = useMemo(
@ -698,8 +700,10 @@ export default function Billing() {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#billing`,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return

File diff suppressed because it is too large Load diff

View file

@ -90,13 +90,6 @@ const ROWS: {
effortHelp:
"Deeper thinking routes messages more carefully but takes longer.",
},
{
role: "research",
effortKey: "researchEffort",
title: "Web research",
help: "Looks things up on the web when researching your company.",
effortHelp: "Deeper research per web search, at the cost of speed.",
},
]
type FullConfig = Required<BrainModelConfig>
@ -132,10 +125,8 @@ const PRESETS: PresetDef[] = [
? "grok-4-fast"
: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
}),
},
{
@ -145,35 +136,24 @@ const PRESETS: PresetDef[] = [
build: (defaults) => ({
main: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: defaults.mainEffort ?? "high",
triageEffort: defaults.triageEffort ?? "low",
researchEffort: defaults.researchEffort ?? "high",
}),
},
{
id: "thorough",
label: "Most thorough",
description: "Deepest answers and research. Slower, uses more credits.",
description: "Deepest answers. Slower, uses more credits.",
build: (defaults, choices) => ({
main: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
}),
},
]
const CONFIG_KEYS = [
"main",
"triage",
"research",
"mainEffort",
"triageEffort",
"researchEffort",
] as const
const CONFIG_KEYS = ["main", "triage", "mainEffort", "triageEffort"] as const
const extraHighIsBounded = (model: string): boolean =>
model.startsWith("grok-") || model.startsWith("gpt-")

View file

@ -38,6 +38,7 @@ import {
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type Connection = z.infer<typeof ConnectionResponseSchema>
@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) {
export default function ConnectionsMCP() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const promoCode = usePromoCode()
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const router = useRouter()
const [removeDialog, setRemoveDialog] = useState<{
@ -552,8 +554,10 @@ export default function ConnectionsMCP() {
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#connections`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -0,0 +1,329 @@
"use client"
import { Loader2 } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
import { brainConnectorIcon } from "../brain-connector-icons"
import { ConnectorCard, ScopeChip } from "../directory/connector-card"
import { PillButton } from "../integrations/install-steps"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
let directoryCache: McpDirectoryEntry[] | null = null
function isDirectoryEntry(value: unknown): value is McpDirectoryEntry {
if (!value || typeof value !== "object") return false
const entry = value as Partial<McpDirectoryEntry>
return (
typeof entry.id === "string" &&
typeof entry.name === "string" &&
(entry.type === "remote" || entry.type === "local") &&
(entry.url === null || typeof entry.url === "string") &&
typeof entry.auth === "string" &&
(entry.note === null || typeof entry.note === "string") &&
Array.isArray(entry.categories) &&
entry.categories.every((category) => typeof category === "string") &&
typeof entry.popularity === "number" &&
(entry.iconDomain === null || typeof entry.iconDomain === "string") &&
["custom", "unsupported"].includes(entry.setup ?? "") &&
(entry.oauthCapability === null ||
["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) &&
Array.isArray(entry.authMethods) &&
entry.authMethods.every((method) =>
["oauth", "api-key"].includes(method),
) &&
["fixed", "tenant", "unavailable", "local"].includes(
entry.availability ?? "",
)
)
}
function parseDirectory(value: unknown) {
if (!value || typeof value !== "object") throw new Error("invalid catalog")
const entries = (value as { entries?: unknown }).entries
if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) {
throw new Error("invalid catalog")
}
return entries
}
async function loadDirectory(signal: AbortSignal) {
if (directoryCache) return directoryCache
const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, {
signal,
cache: "default",
credentials: "include",
})
if (!response.ok) throw new Error("catalog request failed")
directoryCache = parseDirectory(await response.json())
return directoryCache
}
export function useMcpDirectory() {
const [entries, setEntries] = useState<McpDirectoryEntry[]>(
() => directoryCache ?? [],
)
const [error, setError] = useState(false)
useEffect(() => {
const controller = new AbortController()
void loadDirectory(controller.signal)
.then((data) => {
setEntries(data)
setError(false)
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") return
setError(true)
})
return () => controller.abort()
}, [])
return { entries, error }
}
export function categoryLabel(value: string) {
return value
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
export function entrySlug(entry: McpDirectoryEntry) {
return entry.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63)
}
// Mirrors the backend's URL normalization so connection rows match entries.
export function normalizeServerUrl(value: string) {
try {
const url = new URL(value)
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase()
} catch {
return value.toLowerCase()
}
}
// An entry we can actually take the user through connecting.
export function isEntrySetUppable(entry: McpDirectoryEntry) {
return (
entry.setup !== "unsupported" &&
entry.authMethods.length > 0 &&
(entry.availability === "fixed" || entry.availability === "tenant")
)
}
// Entries worth listing at all — servers with no reachable URL are dropped.
export function listableDirectoryEntries(entries: McpDirectoryEntry[]) {
return entries.filter((entry) => entry.availability !== "unavailable")
}
export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) {
return [entry.name, entry.url, entry.note, ...entry.categories]
.filter(Boolean)
.some((value) => value?.toLowerCase().includes(needle))
}
function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) {
const [failed, setFailed] = useState(false)
if (!entry.iconDomain || failed) {
return brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
}
return (
<img
src={`/api/mcp-icon?domain=${encodeURIComponent(entry.iconDomain)}`}
alt=""
className="size-5 object-contain"
loading="lazy"
onError={() => setFailed(true)}
/>
)
}
export function DirectoryEntryCard({
entry,
connected,
onSetUp,
}: {
entry: McpDirectoryEntry
connected: boolean
onSetUp: (entry: McpDirectoryEntry) => void
}) {
const canSetUp = !connected && isEntrySetUppable(entry)
const status = connected
? "Connected"
: canSetUp
? "Not connected"
: entry.availability === "local"
? "Desktop only"
: "Coming soon"
return (
<ConnectorCard
icon={<DirectoryIcon entry={entry} />}
name={entry.name}
subtitle={entrySubtitle(entry)}
footerLeft={<ScopeChip label={status} connected={connected} />}
footerRight={
canSetUp ? (
<PillButton onClick={() => onSetUp(entry)}>Set up</PillButton>
) : null
}
/>
)
}
function entrySubtitle(entry: McpDirectoryEntry) {
if (entry.categories.length > 0) {
return entry.categories.slice(0, 2).map(categoryLabel).join(" · ")
}
return entry.type === "local" ? "Desktop extension" : "MCP server"
}
// One directory listing: a dense single-line row. The default state carries no
// status text — in a marketplace, "not connected" is implied. Only connection,
// or the reason there's no button, earns words.
export function DirectoryEntryRow({
entry,
connected,
onSetUp,
}: {
entry: McpDirectoryEntry
connected: boolean
onSetUp: (entry: McpDirectoryEntry) => void
}) {
const canSetUp = !connected && isEntrySetUppable(entry)
return (
<div className="group flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[#14161A]">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
<DirectoryIcon entry={entry} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
{entry.name}
</p>
<p className="mt-px truncate text-[11px] font-medium text-[#616875]">
{entrySubtitle(entry)}
</p>
</div>
{connected ? (
<span className="flex shrink-0 items-center gap-1.5 pr-1 text-[11px] font-medium text-[#FAFAFA]">
<span className="size-[6px] rounded-full bg-[#00AC3F]" />
Connected
</span>
) : canSetUp ? (
<button
type="button"
onClick={() => onSetUp(entry)}
className="h-7 shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 text-[12px] font-medium text-[#FAFAFA]/70 transition-colors group-hover:bg-[#252C37] group-hover:text-[#FAFAFA] hover:bg-[#2B3340]"
>
Set up
</button>
) : (
<span className="shrink-0 pr-1 text-[11px] font-medium text-[#4E5560]">
{entry.availability === "local" ? "Desktop only" : "Coming soon"}
</span>
)}
</div>
)
}
const GRID_PAGE_SIZE = 24
// Paged card grid over the MCP directory. With a query it renders matching
// servers; without one it renders the whole marketplace.
export function McpDirectoryGrid({
query = "",
entries,
loadError,
excludeSlugs,
isEntryConnected,
onSetUp,
suppressEmpty,
}: {
query?: string
entries: McpDirectoryEntry[]
loadError: boolean
// entries already rendered elsewhere (e.g. the built-in app catalog)
excludeSlugs?: Set<string>
isEntryConnected: (entry: McpDirectoryEntry) => boolean
onSetUp: (entry: McpDirectoryEntry) => void
// the caller rendered its own matches, so an empty grid isn't "no results"
suppressEmpty?: boolean
}) {
const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE)
const needle = query.trim().toLowerCase()
// biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query
useEffect(() => {
setVisibleCount(GRID_PAGE_SIZE)
}, [needle])
// Connected first, then connectable, then "coming soon"/desktop-only.
const matches = useMemo(() => {
const found = entries.filter(
(entry) =>
!excludeSlugs?.has(entrySlug(entry)) &&
(!needle || entryMatchesQuery(entry, needle)),
)
return found.sort(
(a, b) =>
Number(isEntryConnected(b)) - Number(isEntryConnected(a)) ||
Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)),
)
}, [entries, excludeSlugs, isEntryConnected, needle])
if (loadError) {
if (suppressEmpty) return null
return (
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
The MCP directory couldn't be loaded. Refresh to try again.
</div>
)
}
if (entries.length === 0) {
if (suppressEmpty) return null
return (
<div className="flex items-center justify-center gap-2 rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin" />
Loading MCP directory
</div>
)
}
if (matches.length === 0) {
if (suppressEmpty) return null
return (
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
No integrations match {query.trim()}.
</div>
)
}
return (
<div className="space-y-4">
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2 lg:grid-cols-3">
{matches.slice(0, visibleCount).map((entry) => (
<DirectoryEntryRow
key={entry.id}
entry={entry}
connected={isEntryConnected(entry)}
onSetUp={onSetUp}
/>
))}
</div>
{visibleCount < matches.length ? (
<button
type="button"
onClick={() => setVisibleCount((count) => count + GRID_PAGE_SIZE)}
className="mx-auto flex h-9 cursor-pointer items-center rounded-full border border-[#2A313C] px-5 text-[12px] font-semibold text-[#D4D4D8] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]"
>
Show {Math.min(GRID_PAGE_SIZE, matches.length - visibleCount)} more ·{" "}
{visibleCount} of {matches.length.toLocaleString()}
</button>
) : null}
</div>
)
}

View file

@ -0,0 +1,45 @@
"use client"
import { CalendarClock } from "lucide-react"
import { Button } from "@ui/components/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSansClassName } from "@/lib/fonts"
export function SetupCallLink() {
const { data } = useTrialStatus()
if (!data?.active) return null
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
aria-label="Book a setup call"
>
<a
href={COMPANY_BRAIN_CAL_HREF}
target="_blank"
rel="noreferrer"
onClick={() =>
analytics.brainSetupCallClicked({ surface: "header" })
}
>
<CalendarClock className="size-4 shrink-0" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Book a setup call
</TooltipContent>
</Tooltip>
)
}

View file

@ -14,6 +14,7 @@ import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
import {
Brain,
CalendarClock,
LogOut,
Settings,
Settings2,
@ -29,6 +30,7 @@ import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
import { useViewMode } from "@/lib/view-mode-context"
export function UserProfileMenu({
@ -213,6 +215,24 @@ export function UserProfileMenu({
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
{isCompanyBrain ? (
<DropdownMenuItem
asChild
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<a
href={COMPANY_BRAIN_CAL_HREF}
target="_blank"
rel="noreferrer"
onClick={() =>
analytics.brainSetupCallClicked({ surface: "user_menu" })
}
>
<CalendarClock className="size-4 text-[#737373]" />
Book a setup call
</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
asChild
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"

View file

@ -6,9 +6,9 @@ const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BASE = `${BACKEND}/brain/models`
export type BrainModelRole = "main" | "triage" | "research"
export type BrainModelRole = "main" | "triage"
export type BrainReasoningEffort = "auto" | "low" | "medium" | "high" | "xhigh"
export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort"
export type BrainReasoningKey = "mainEffort" | "triageEffort"
export type BrainModelConfig = Record<BrainModelRole, string> &
Partial<Record<BrainReasoningKey, BrainReasoningEffort>>

View file

@ -1,16 +1,8 @@
import { useAuth } from "@lib/auth-context"
import {
getBrainMode,
getCompanyBrainOverride,
hasCompanyBrain,
} from "@/lib/billing-utils"
import { isCompanyBrainOrg } from "@/lib/billing-utils"
export function useHasCompanyBrain(): boolean {
const { org } = useAuth()
const metadata = org?.metadata as Record<string, unknown> | string | undefined
// An explicit concierge override wins over the team-onboarding fallback.
const override = getCompanyBrainOverride(metadata)
if (override !== undefined) return override
// Team-brain orgs use brain spaces even before the add-on webhook lands.
return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
return isCompanyBrainOrg(metadata)
}

View file

@ -0,0 +1,82 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo } from "react"
import { toast } from "sonner"
const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
const PROMO_TOAST_ID = "promo-code"
function promoCodeKey(orgId: string): string {
return `sm.promoCode.org_${orgId}`
}
function readOrgPromoCode(orgId?: string): string | null {
if (!orgId || typeof window === "undefined") return null
return window.localStorage.getItem(promoCodeKey(orgId))
}
export function usePromoCode() {
const { org } = useAuth()
const orgId = org?.id
const getDiscounts = useCallback(() => {
const promotionCode = readOrgPromoCode(orgId)
return promotionCode ? [{ promotionCode }] : undefined
}, [orgId])
const clear = useCallback(() => {
if (!orgId) return
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
}, [orgId])
return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
}
export function PromoCodeCapture() {
useEffect(() => {
const url = new URL(window.location.href)
const code = url.searchParams.get("discountCode")
if (!code) return
window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code)
url.searchParams.delete("discountCode")
window.history.replaceState({}, "", url.toString())
}, [])
return null
}
export function PromoCodeHost() {
const { org } = useAuth()
const router = useRouter()
useEffect(() => {
if (!org?.id) return
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
if (pending) {
window.localStorage.setItem(promoCodeKey(org.id), pending)
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
}
const code = readOrgPromoCode(org.id)
if (!code) {
toast.dismiss(PROMO_TOAST_ID)
return
}
toast.success("Discount code active", {
id: PROMO_TOAST_ID,
description: `Code ${code} will apply at checkout.`,
duration: Number.POSITIVE_INFINITY,
action: {
label: "Upgrade",
onClick: () => router.push("/settings#billing"),
},
})
}, [org?.id, router])
return null
}

View file

@ -1,5 +1,6 @@
import posthog from "posthog-js"
import type { BrainStep } from "@/components/onboarding-brain/types"
import type { SetupCallSurface } from "@/lib/cal"
const pendingEvents: Array<{
eventName: string
@ -83,6 +84,8 @@ export const analytics = {
connectionDeleted: () => safeCapture("connection_deleted"),
connectionAuthStarted: (props: { provider: string }) =>
safeCapture("connection_auth_started", props),
connectorPausedClicked: (props: { provider: string; reason: string }) =>
safeCapture("connector_paused_clicked", props),
// integrations surface (main Nova page)
integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
@ -276,4 +279,9 @@ export const analytics = {
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
brainTrialCheckoutAbandoned: () =>
safeCapture("brain_trial_checkout_abandoned"),
brainSetupCallClicked: (props: { surface: SetupCallSurface }) =>
safeCapture("brain_setup_call_clicked", props),
brainSetupModalSeen: () => safeCapture("brain_setup_modal_seen"),
brainSetupModalPicked: (props: { choice: "slack" | "call" }) =>
safeCapture("brain_setup_modal_picked", props),
}

View file

@ -112,6 +112,15 @@ export function getBrainMode(
: null
}
export function isCompanyBrainOrg(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean {
const override = getCompanyBrainOverride(metadataRaw)
if (override !== undefined) return override
if (hasCompanyBrain(metadataRaw)) return true
return getBrainMode(metadataRaw) === "team"
}
export type BrainTrialStatus =
| "active"
| "exhausted"

8
apps/web/lib/cal.ts Normal file
View file

@ -0,0 +1,8 @@
export const COMPANY_BRAIN_CAL_HREF =
"https://cal.com/team/supermemory/company-brain"
export type SetupCallSurface =
| "research_rail"
| "setup_modal"
| "header"
| "user_menu"

View file

@ -1,9 +1,4 @@
import {
getBrainMode,
getBrainWorkspaceDomain,
getCompanyBrainOverride,
hasCompanyBrain,
} from "./billing-utils"
import { getBrainWorkspaceDomain, isCompanyBrainOrg } from "./billing-utils"
export type BrainEntryOrganization = {
id: string
@ -18,21 +13,12 @@ export type CompanyBrainEntryDecision =
| { action: "choose"; organizations: BrainEntryOrganization[] }
| { action: "create" }
export function isCompanyBrainOrganization(
organization: BrainEntryOrganization,
): boolean {
const override = getCompanyBrainOverride(organization.metadata)
if (override !== undefined) return override
return (
hasCompanyBrain(organization.metadata) ||
getBrainMode(organization.metadata) === "team"
)
}
export function getCompanyBrainOrganizations(
organizations: BrainEntryOrganization[],
): BrainEntryOrganization[] {
return organizations.filter(isCompanyBrainOrganization)
return organizations.filter((organization) =>
isCompanyBrainOrg(organization.metadata),
)
}
function normalizeDomain(domain: string): string {

View file

@ -0,0 +1,30 @@
export type ConnectorPauseReason = "google_verification"
export interface ConnectorPause {
reason: ConnectorPauseReason
label: string
message: string
}
const PAUSED_CONNECTORS: Record<string, ConnectorPause> = {
"google-drive": {
reason: "google_verification",
label: "Google Drive",
message:
"New Google Drive connections are paused while Google reviews our app. Already connected? Your files keep syncing.",
},
gmail: {
reason: "google_verification",
label: "Gmail",
message:
"New Gmail connections are paused while Google reviews our app. Already connected? Your email keeps syncing.",
},
}
export function connectorPause(provider: string): ConnectorPause | undefined {
return PAUSED_CONNECTORS[provider]
}
export function isConnectorPaused(provider: string): boolean {
return provider in PAUSED_CONNECTORS
}

View file

@ -0,0 +1,44 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import { connectorPause } from "@/lib/connector-availability"
const key = (provider: string) => `connector_notify:${provider}`
// Per-browser only. The PostHog event is the record of truth for who to email.
export function useConnectorNotify() {
const [requested, setRequested] = useState<Record<string, boolean>>({})
useEffect(() => {
try {
const seen: Record<string, boolean> = {}
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith("connector_notify:")) {
seen[k.slice("connector_notify:".length)] = true
}
}
setRequested(seen)
} catch {}
}, [])
const isRequested = useCallback(
(provider: string) => requested[provider] === true,
[requested],
)
const request = useCallback((provider: string) => {
const pause = connectorPause(provider)
if (!pause) return
analytics.connectorPausedClicked({ provider, reason: pause.reason })
try {
localStorage.setItem(key(provider), "1")
} catch {}
setRequested((prev) => ({ ...prev, [provider]: true }))
toast.success(`We'll email you when ${pause.label} is back.`)
}, [])
return { isRequested, request }
}

View file

@ -0,0 +1,21 @@
export type McpDirectoryAvailability =
| "fixed"
| "tenant"
| "unavailable"
| "local"
export type McpDirectoryEntry = {
id: string
name: string
type: "remote" | "local"
url: string | null
auth: string
note: string | null
categories: string[]
popularity: number
availability: McpDirectoryAvailability
iconDomain: string | null
setup: "custom" | "unsupported"
oauthCapability: "dcr" | "preregistered" | null
authMethods: Array<"oauth" | "api-key">
}

View file

@ -0,0 +1,518 @@
{
"domains": [
"10xgenomics.com",
"activecampaign.com",
"actively.ai",
"adisinsight-mcp.springer.com",
"adobe-creativity.adobe.io",
"adobeaemcloud.com",
"aep-ai-ama.adobe.io",
"affinity.co",
"aftership.com",
"agent.thoughtspot.app",
"agentmail.to",
"agents.riskanalytics.dnb.com",
"agenttools.wolfram.com",
"ahrefs.com",
"ai-connect.norton.com",
"ai-inc.mailchimp.com",
"ai-inc.quickbooks.intuit.com",
"ai-inc.turbotax.intuit.com",
"ai-tools.tillermoney.com",
"ai.chronograph.pe",
"ai.consilio.com",
"ai.thirdbridge.com",
"ai.todoist.net",
"ai.veltra.com",
"airbnb.com",
"airtable.com",
"ajo-mcp.adobe.io",
"alltrails.com",
"alma.food",
"alphavantage.co",
"alphaxiv.org",
"alpic.ai",
"amplitude.com",
"analytics.credit.morningstar.com",
"analytics.lseg.com",
"android.com",
"angellist.com",
"anthropic.mcp.creditkarma.com",
"api-ssl.bitly.com",
"apify.com",
"apigw.americanexpress.com",
"apollo.io",
"apollographql.com",
"app.airops.com",
"app.base44.com",
"app.brighthire.ai",
"app.carta.com",
"app.definely.com",
"app.eraser.io",
"app.files.com",
"app.flourish.studio",
"app.fyxer.com",
"app.grasp-ai.com",
"app.hanoverpark.com",
"app.ketryx.com",
"app.magicschool.ai",
"app.midpage.ai",
"app.synthesize.bio",
"app.tropicapp.io",
"app.unthread.io",
"appfolio.com",
"asana.com",
"ashbyhq.com",
"asset-management.mcp.cloudinary.com",
"atlassian.com",
"attention.tech",
"attio.com",
"audible.com",
"auraintelligence.com",
"autodesk.com",
"autorfp.ai",
"benchling.com",
"benevity.org",
"bigdata.com",
"bigquery.googleapis.com",
"bindings.mcp.cloudflare.com",
"blockscout.com",
"blueconic.com",
"boltz.bio",
"box.com",
"brandfetch.io",
"brave.com",
"braze.com",
"brevo.com",
"brex.com",
"briskteaching.com",
"calendar.google.com",
"calendly.com",
"callbacks.omniapp.co",
"canary-data.com",
"candid.org",
"canva.com",
"cargoai.co",
"cbinsights.com",
"chargebee.com",
"chartmogul.com",
"chatgpt.mermaid.ai",
"checkatrade.com",
"circleback.ai",
"civitatis-claude-app.civitatis.com",
"cja-mcp.adobe.io",
"clapi.guidepoint.io",
"clarify.ai",
"clarity-sfdr20-mcp.pro.clarity.ai",
"claude-mcp-api.ml.goodnotes.com",
"claude.mcp.kpler.com",
"claude.slidesgpt.com",
"claudecompanion.gateway.api.mcafee.com",
"clay.com",
"clerk.com",
"clickhouse.cloud",
"clickup.com",
"close.com",
"cloud.cdata.com",
"cloudimanage.com",
"cloze.com",
"cognitoforms.com",
"coindesk.com",
"columnapi.com",
"cometchat.com",
"commonroom.io",
"compute.googleapis.com",
"connect.squareup.com",
"connector.scholargateway.ai",
"consensus.app",
"contentsquare.com",
"context.era.app",
"context7.com",
"coralogix.com",
"coteach.ai",
"coupler.io",
"coursera.com",
"courtlistener.com",
"courtroom5.com",
"craft.do",
"crossbeam.com",
"crypto.com",
"customer.io",
"daloopa.com",
"dashboard.plaid.com",
"data-search.apigw.feverup.com",
"databricks.com",
"datacamp.com",
"datadoghq.com",
"datagrail.io",
"datahub.com",
"day.ai",
"deepl.com",
"demandapi-mcp.booking.com",
"descript.com",
"descrybe.com",
"developer.api.autodesk.com",
"developer.mcp.mastercard.com",
"devrev.ai",
"dhsprogram.com",
"dice.com",
"diffit.me",
"digits.com",
"directbooker.ai",
"docs.superhuman.com",
"docuseal.com",
"docusign.com",
"dovetail.com",
"dremio.com",
"drive.google.com",
"dropbox.com",
"dynatrace.com",
"econ-index.mcp.claude.com",
"elevenlabs.io",
"elicit.com",
"entendre.finance",
"eulerapp.com",
"everlaw.com",
"exa.ai",
"example-server.modelcontextprotocol.io",
"excalidraw.com",
"exp-app-mcp.prod.ep.viator.com",
"expedia.com",
"expo.dev",
"factset.com",
"fathom.ai",
"fellow.app",
"felt.com",
"fids-mcp.ice.com",
"fig-mcp.instacart.com",
"figma.com",
"financeanalytics.dnb.com",
"financialmodelingprep.com",
"fireflies.ai",
"firefox.com",
"fiscal.ai",
"fitch.group",
"floot.com",
"frontify-integrations.com",
"fullstory.com",
"funnel.io",
"g.runorion.com",
"g2.com",
"gainsight.com",
"gamma.app",
"gatewaymcp.verisk.com",
"genai-prod-ext.dominos.co.in",
"getaugust.ai",
"getguru.com",
"getmontecarlo.com",
"getunblocked.com",
"glean.com",
"global.datasite.com",
"glovoapp.com",
"gmail.com",
"gocardless.com",
"godaddy.com",
"gopigment.com",
"govcon.dev",
"govtribe.com",
"grain.com",
"granola.ai",
"grantedai.com",
"grasshopper-mcp.prd.narmitech.com",
"grounding.kensho.com",
"gusto.com",
"harmonic.ai",
"harness.io",
"harvey.ai",
"haveibeenpwned.com",
"hcls.mcp.claude.com",
"healthex.io",
"helium10.com",
"heygen.com",
"highspot.com",
"honeycomb.io",
"hrn-production.helix.com",
"hubspot.com",
"huggingface.co",
"ibisworld.com",
"ibkr.com",
"idiolect.app",
"ifttt.com",
"imedidata.com",
"incident.io",
"indeed.com",
"inductive.bio",
"inkbox.ai",
"insiderone.com",
"instrumentl.com",
"intapp.com",
"integrators.prod.api.tabsplatform.com",
"intercom.com",
"ipone.clarivate.com",
"ironcladapp.com",
"isometric.com",
"item.app",
"jam.dev",
"jentic.com",
"jotform.com",
"jupiterone.com",
"jusmundi.com",
"k.owkin.com",
"kfinance.kensho.com",
"kg.mcp.learningcommons.org",
"kindora-mcp.azurewebsites.net",
"kiwi.com",
"klaviyo.com",
"krisp.ai",
"kubernetes.io",
"lastminute.com",
"latch.bio",
"latticehq.com",
"lawve.ai",
"learn.microsoft.com",
"leaveadot.com",
"legal-mcp.thomsonreuters.com",
"legaldatahunter.com",
"legalzoom.com",
"letsbot.net",
"letsdeel.com",
"light.inc",
"lightfield.app",
"lilt.com",
"linear.app",
"listenlabs.ai",
"litmus.com",
"livestorm.co",
"localfalcon.com",
"lorikeetcx.ai",
"lovable.dev",
"lucid.app",
"luminpdf.com",
"lumonic.com",
"lunarcrush.ai",
"lusha.com",
"macaly.com",
"magicpatterns.com",
"mail.superhuman.com",
"mailerlite.com",
"make.com",
"manufact.com",
"marketplace-mcp.us-east-1.api.aws",
"matrixmcp.virtuoso.ai",
"mcp-app.turkishtechlab.com",
"mcp-demo.airwallex.com",
"mcp-gateway-external-pilot.spotify.net",
"mcp-pub.aiera.com",
"mcp-public.basecamp-research.com",
"mcp-server.egnyte.com",
"mcp-server.signnow.com",
"mcp-server.zomato.com",
"mcp-v1.tixel.com",
"mcp2.readwise.io",
"meetcampfire.com",
"melon.com",
"meltwater.com",
"mem.ai",
"mem0.ai",
"mercadolibre.com",
"mercury.com",
"metabase.com",
"metal.ai",
"metaview.ai",
"microsoft.com",
"mintlify.com",
"miro.com",
"mixpanel.com",
"monday.com",
"mongodb.com",
"moodys.com",
"morningstar.com",
"mospi.gov.in",
"motherduck.com",
"msci.com",
"mtnewswires.com",
"myisolved.com",
"n8n.io",
"netlify-mcp.netlify.app",
"netsuite.com",
"nimbleway.com",
"nlp.api.production.unwrap.ai",
"nooks.in",
"notion.com",
"omni.mulesoft.com",
"onesignal.com",
"ontra.ai",
"open-ai-app.stubhub.net",
"oreilly.com",
"otter.ai",
"ottotheagent.com",
"outreach.io",
"pagerduty.com",
"pandadoc.com",
"partner-mcp.ticketmaster.com",
"patlytics.ai",
"paypal.com",
"paytmpayments.com",
"peec.ai",
"pga.com",
"phished.io",
"phoenix.hginsights.com",
"pi.security",
"pinegap.ai",
"platform.opentargets.org",
"plaud.ai",
"playmcp.kakao.com",
"polaranalytics.com",
"pophive.org",
"posthog.com",
"postman.com",
"premium.mcp.pitchbook.com",
"privacy.com",
"process.st",
"prod.originhq.com",
"production.ai-mcp-extensibility-prd.tamg.cloud",
"projects.motionapp.com",
"pscale.dev",
"public-api.wordpress.com",
"pubmed.mcp.claude.com",
"qbo-connector.meridian.pilot.com",
"qonto.com",
"quartr.com",
"quicknode.com",
"quo.com",
"railway.com",
"rallyuxr.com",
"ramp-mcp-remote.ramp.com",
"ramp.com",
"rapid7.com",
"razorpay.com",
"react.dev",
"read.ai",
"reclaim.ai",
"reddit.com",
"relativity.com",
"remote.com",
"render.com",
"replit-mcp.com",
"resend.com",
"retool.com",
"revolut.com",
"rillet.com",
"roamresearch.com",
"roboflow.com",
"salesflare.com",
"salesloft.com",
"sanity.io",
"sap.com",
"scamguard.malwarebytes.com",
"scite.ai",
"seismic.com",
"semrush.com",
"send.co",
"sentry.dev",
"servicenow.com",
"services.biorender.com",
"services.functionhealth.com",
"services.oxfordeconomics.com",
"setup.shopify.com",
"shapes.co",
"shipbob.com",
"shippo.com",
"shutterstock.com",
"sigmacomputing.com",
"signeasy.com",
"similarweb.com",
"sketch.com",
"sketchup.com",
"slack.com",
"smartbear.com",
"smartling.com",
"smartsheet.com",
"snowflake.com",
"snowstorm-mcp.snomedtools.org",
"snyk.io",
"solveintelligence.com",
"sourcegraph.com",
"spinach.ai",
"splice.com",
"sprouts-mcp-server.kartikay-dhar.workers.dev",
"squareup.com",
"stackoverflow.com",
"staircase.ai",
"starburst.io",
"strava.com",
"stripe.com",
"stytch.dev",
"sumble.com",
"sumsub.com",
"supabase.com",
"super.com",
"supermetrics.com",
"surveymonkey.com",
"swagger.mcp.smartbear.com",
"sybill.ai",
"synapse.org",
"tableau.com",
"taskrabbit.com",
"tavily.com",
"taxact.com",
"teacher-tools.eedi.ai",
"teamtailor.com",
"techgc.co",
"tellme.embat.io",
"thumbtack.com",
"tickettailor.ai",
"ticktick.com",
"tigerdata.com",
"tines.com",
"tldraw-mcp-app.tldraw.workers.dev",
"tldv.io",
"tomtom.com",
"tray.io",
"trellis.law",
"trello.com",
"trivago.com",
"tryprofound.com",
"turquoise.health",
"twilio.com",
"uakozrqrztgrgwoywxkx.supabase.co",
"uber.com",
"ubereats.com",
"udemy.com",
"unsplash.com",
"use.kick.co",
"usepylon.com",
"v0.app",
"vast.blueskyapi.com",
"vendr.com",
"vercel.com",
"vibe.com",
"virtuoso.ai",
"voluum.com",
"webexapis.com",
"webflow.com",
"webull.com",
"whimsical.com",
"windsor.ai",
"wisdom-api.enterpret.com",
"wisprflow.ai",
"within.ai",
"wix.com",
"workable.com",
"workato.com",
"workfront.adobe.com",
"workos.com",
"wrike.com",
"wyndhamhotels.com",
"xactrestore-xactremodelserver-usw2-prod.propsol.io",
"xero.com",
"xweather.com",
"zapier.com",
"ziprecruiter.com",
"zocks.io",
"zoho.com",
"zoom.us",
"zoominfo.com",
"zscaler.com"
]
}

View file

@ -0,0 +1,47 @@
import { getBackendUrl } from "./url-helpers"
const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
// `bun run dev:local` serves localhost while auth lives on api.supermemory.ai, so its cookie never arrives.
function isLocalDevRequest(request: Request): boolean {
if (process.env.NODE_ENV !== "development") {
return false
}
try {
return LOCAL_DEV_HOSTS.has(new URL(request.url).hostname)
} catch {
return false
}
}
// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side.
export async function hasVerifiedSession(request: Request): Promise<boolean> {
if (isLocalDevRequest(request)) {
return true
}
const cookie = request.headers.get("cookie")
if (!cookie) {
return false
}
try {
const response = await fetch(`${getBackendUrl()}/api/auth/get-session`, {
headers: { cookie },
redirect: "error",
cache: "no-store",
})
if (!response.ok) {
return false
}
const session: unknown = await response.json()
return Boolean(
session &&
typeof session === "object" &&
"user" in session &&
session.user,
)
} catch {
return false
}
}

View file

@ -41,13 +41,14 @@ export default async function proxy(request: Request) {
return NextResponse.next()
}
// MCP setup page is public — no auth required
if (url.searchParams.get("view") === "mcp") {
return NextResponse.next()
}
// Integrations index is public in guest mode; actions still require login.
if (url.pathname === "/" && url.searchParams.get("view") === "integrations") {
// Integrations index and MCP setup are public in guest mode; actions still
// require login. The ?view param is only meaningful at "/" (see
// lib/view-mode-context, which ignores it elsewhere), so scope it there —
// unscoped, ?view=mcp would let any path skip the /api/ gate below.
if (
url.pathname === "/" &&
["integrations", "mcp"].includes(url.searchParams.get("view") ?? "")
) {
return NextResponse.next()
}

View file

@ -23,7 +23,6 @@
"dependencies": {
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/react": "^3.0.170",
"@ai-sdk/xai": "^3.0.83",
"@better-fetch/fetch": "^1.1.18",
"@cloudflare/ai-chat": "^0.0.7",
"@dnd-kit/core": "^6.3.1",

View file

@ -148,7 +148,6 @@
"dependencies": {
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/react": "^3.0.170",
"@ai-sdk/xai": "^3.0.83",
"@better-fetch/fetch": "^1.1.18",
"@cloudflare/ai-chat": "^0.0.7",
"@dnd-kit/core": "^6.3.1",
@ -336,7 +335,7 @@
},
"packages/tools": {
"name": "@supermemory/tools",
"version": "2.1.1",
"version": "2.2.0",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.25",
"@ai-sdk/openai": "^2.0.23",
@ -470,7 +469,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.83", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SuQz68BZGeuZjrSUJAzku97IlhdiNJJBsvG/Tvm3K2tuxkBS7TJq0fH4/AzAM7w2H2jxVUgboP7kRR6IfpRxcg=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.67", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw=="],
"@aihubmix/ai-sdk-provider": ["@aihubmix/ai-sdk-provider@1.0.3", "", { "dependencies": { "@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/google": "^3.0.0", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "zod": "3.25.76" } }, "sha512-6YSu/3rLkPlY7fqSHTwq6LMiK+0BLVZsbsi/28w+og2MrpUYPNaBhkj7F80K79NE28+HFOqIJROFgAUFmaoh6w=="],
@ -5118,11 +5117,11 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@aihubmix/ai-sdk-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q=="],
@ -5578,8 +5577,6 @@
"@voltagent/core/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@voltagent/core/@ai-sdk/xai": ["@ai-sdk/xai@3.0.67", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw=="],
"@voltagent/core/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@voltagent/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.204.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw=="],
@ -6632,10 +6629,6 @@
"@voltagent/core/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@voltagent/core/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@voltagent/core/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@ -7288,8 +7281,6 @@
"@voltagent/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@voltagent/core/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@voltagent/core/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],

View file

@ -21,6 +21,13 @@ type AddMemoryInput = {
memory: string
}
// The schema constrains well-behaved models; a prompt-injected one can still send anything.
function clampSearchLimit(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 10
return Math.min(50, Math.max(1, Math.floor(parsed)))
}
/**
* Create Supermemory tools for AI SDK
*/
@ -30,6 +37,8 @@ export function supermemoryTools(
) {
const client = new Supermemory({
apiKey,
timeout: 30_000,
maxRetries: 2,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
@ -54,8 +63,10 @@ export function supermemoryTools(
default: true,
},
limit: {
type: "number",
description: "Maximum number of results to return",
type: "integer",
minimum: 1,
maximum: 50,
description: "Maximum number of results to return (1-50)",
default: 10,
},
},
@ -67,10 +78,11 @@ export function supermemoryTools(
limit = 10,
}) => {
try {
const safeLimit = clampSearchLimit(limit)
const response = await client.search.execute({
q: informationToGet,
containerTags,
limit,
limit: safeLimit,
chunkThreshold: 0.6,
includeFullDocs,
})

View file

@ -3,6 +3,7 @@ import {
anonymousClient,
apiKeyClient,
emailOTPClient,
genericOAuthClient,
magicLinkClient,
organizationClient,
usernameClient,
@ -19,6 +20,7 @@ export const authClient = createAuthClient({
usernameClient(),
magicLinkClient(),
emailOTPClient(),
genericOAuthClient(),
apiKeyClient(),
adminClient(),
organizationClient(),

View file

@ -1,7 +1,7 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.1.1",
"version": "2.2.0",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",

View file

@ -372,4 +372,10 @@ export function supermemoryTools(
}
}
export { withSupermemory } from "./vercel"
// `./vercel` is not a published subpath, so this is the only way consumers reach the middleware types.
export {
withSupermemory,
type WithSupermemoryOptions,
type PromptTemplate,
type MemoryPromptData,
} from "./vercel"

View file

@ -1,4 +1,5 @@
import type OpenAI from "openai"
import { validateApiKey } from "../shared"
import {
createOpenAIMiddleware,
type OpenAIMiddlewareOptions,
@ -21,6 +22,7 @@ import {
* @param options.verbose - Optional flag to enable detailed logging of memory search and injection process (default: false)
* @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full"
* @param options.addMemory - Optional mode for memory addition: "always" (default), "never"
* @param options.apiKey - Optional Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
*
* @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs
*
@ -56,16 +58,14 @@ import {
* })
* ```
*
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
* @throws {Error} When supermemory API request fails
*/
export function withSupermemory(
openaiClient: OpenAI,
options: OpenAIMiddlewareOptions,
) {
if (!process.env.SUPERMEMORY_API_KEY) {
throw new Error("SUPERMEMORY_API_KEY is not set")
}
validateApiKey(options.apiKey)
if (!options.containerTag) {
throw new Error(

View file

@ -1,6 +1,7 @@
import type OpenAI from "openai"
import Supermemory from "supermemory"
import { addConversation } from "../conversations-client"
import { validateApiKey } from "../shared"
import { deduplicateMemoriesForMode } from "../tools-shared"
import { createLogger, type Logger } from "../vercel/logger"
import { convertProfileToMarkdown } from "../vercel/util"
@ -20,6 +21,7 @@ export interface OpenAIMiddlewareOptions {
mode?: "profile" | "query" | "full"
addMemory?: "always" | "never"
baseUrl?: string
apiKey?: string
}
interface SupermemoryProfileSearch {
@ -75,22 +77,25 @@ const getLastUserMessage = (
*
* @param containerTag - The container tag/identifier for memory search (e.g., user ID, project ID)
* @param queryText - Optional query text to search for specific memories. If empty, returns all profile memories
* @param baseUrl - The Supermemory API base URL
* @param apiKey - The Supermemory API key used to authenticate the request
* @returns Promise that resolves to the SuperMemory profile search response
* @throws {Error} When the API request fails or returns an error status
*
* @example
* ```typescript
* // Search with query
* const results = await supermemoryProfileSearch("user-123", "favorite programming language")
* const results = await supermemoryProfileSearch("user-123", "favorite programming language", baseUrl, apiKey)
*
* // Get all profile memories
* const profile = await supermemoryProfileSearch("user-123", "")
* const profile = await supermemoryProfileSearch("user-123", "", baseUrl, apiKey)
* ```
*/
const supermemoryProfileSearch = async (
containerTag: string,
queryText: string,
baseUrl: string,
apiKey: string,
): Promise<SupermemoryProfileSearch> => {
const payload = queryText
? JSON.stringify({
@ -106,7 +111,7 @@ const supermemoryProfileSearch = async (
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
Authorization: `Bearer ${apiKey}`,
},
body: payload,
})
@ -138,6 +143,8 @@ const supermemoryProfileSearch = async (
* @param containerTag - The container tag/identifier for memory search
* @param logger - Logger instance for debugging and info output
* @param mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both)
* @param baseUrl - The Supermemory API base URL
* @param apiKey - The Supermemory API key used to authenticate the request
* @returns Promise that resolves to enhanced messages with memory-injected system prompt
*
* @example
@ -150,7 +157,9 @@ const supermemoryProfileSearch = async (
* messages,
* "user-123",
* logger,
* "full"
* "full",
* baseUrl,
* apiKey
* )
* // Returns messages with system prompt containing relevant memories
* ```
@ -161,6 +170,7 @@ const addSystemPrompt = async (
logger: Logger,
mode: "profile" | "query" | "full",
baseUrl: string,
apiKey: string,
) => {
const systemPromptExists = messages.some((msg) => msg.role === "system")
@ -170,6 +180,7 @@ const addSystemPrompt = async (
containerTag,
queryText,
baseUrl,
apiKey,
)
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
@ -400,8 +411,9 @@ const addMemoryTool = async (
* @param options.verbose - Enable detailed logging of memory operations (default: false)
* @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile")
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always")
* @param options.apiKey - Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
* @returns Object with `wrapClient` and `createClient` methods
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
*
* @example
* ```typescript
@ -421,8 +433,9 @@ export function createOpenAIMiddleware(
) {
const logger = createLogger(options?.verbose ?? false)
const baseUrl = normalizeBaseUrl(options?.baseUrl)
const apiKey = validateApiKey(options?.apiKey)
const client = new Supermemory({
apiKey: process.env.SUPERMEMORY_API_KEY,
apiKey,
...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}),
})
@ -457,6 +470,7 @@ export function createOpenAIMiddleware(
containerTag,
queryText,
baseUrl,
apiKey,
)
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
@ -615,7 +629,7 @@ export function createOpenAIMiddleware(
memoryCustomId,
logger,
messages,
process.env.SUPERMEMORY_API_KEY,
apiKey,
baseUrl,
),
)
@ -623,7 +637,7 @@ export function createOpenAIMiddleware(
}
operations.push(
addSystemPrompt(messages, containerTag, logger, mode, baseUrl),
addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey),
)
const results = await Promise.all(operations)

View file

@ -552,6 +552,14 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool
]
}
function parseToolArguments(argumentsJson: string) {
try {
return { success: true as const, value: JSON.parse(argumentsJson) }
} catch {
return { success: false as const }
}
}
/**
* Execute a tool call based on the function name and arguments
*/
@ -565,7 +573,14 @@ export function createToolCallExecutor(
toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
): Promise<string> {
const functionName = toolCall.function.name
const args = JSON.parse(toolCall.function.arguments)
const parsed = parseToolArguments(toolCall.function.arguments)
if (!parsed.success) {
return JSON.stringify({
success: false,
error: `Invalid JSON arguments for ${functionName}`,
})
}
const args = parsed.value
switch (functionName) {
case "searchMemories":

View file

@ -129,11 +129,7 @@ export function createSupermemoryHooks(
return
}
saveConversation(messages, ctx).catch((error) => {
ctx.logger.error("Background conversation save failed", {
error: error instanceof Error ? error.message : "Unknown error",
})
})
await saveConversation(messages, ctx)
} catch (error) {
ctx.logger.error("Error in onEnd", {
error: error instanceof Error ? error.message : "Unknown error",

View file

@ -448,7 +448,7 @@ const convertToConversationMessages = (
}
/**
* Saves conversation to Supermemory (fire-and-forget).
* Saves conversation to Supermemory.
*/
export const saveConversation = async (
messages: VoltAgentMessage[],

View file

@ -1,150 +0,0 @@
"use client"
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export const AnonymousAuth = ({
dashboardPath = "/dashboard",
loginPath = "/login",
}) => {
const router = useRouter()
useEffect(() => {
const createAnonymousSession = async () => {
const session = await authClient.getSession()
if (!session?.session) {
console.debug(
"[ANONYMOUS_AUTH] No session found, creating anonymous session...",
)
try {
// Create anonymous session
console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
const res = await authClient.signIn.anonymous()
if (!res.token) {
throw new Error("Failed to get anonymous token")
}
// Get the new session
console.debug(
"[ANONYMOUS_AUTH] Getting new session with anonymous token...",
)
const newSession = await authClient.getSession()
console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
if (!newSession?.session || !newSession?.user) {
console.error(
"[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
)
throw new Error("Failed to create anonymous session")
}
// Get the user's organization
console.debug(
"[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
)
const orgs = await authClient.organization.list()
console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
count: orgs?.length || 0,
orgs: orgs?.map((o) => ({
id: o.id,
name: o.name,
slug: o.slug,
})),
})
const org = orgs?.[0]
if (!org) {
console.error(
"[ANONYMOUS_AUTH] No organization found for anonymous user",
)
throw new Error("Failed to get organization for anonymous user")
}
// Redirect to the organization dashboard
console.debug(
`[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
)
router.push(dashboardPath)
} catch (error) {
console.error(
"[ANONYMOUS_AUTH] Anonymous session creation error:",
error,
)
console.error("[ANONYMOUS_AUTH] Error details:", {
message: error instanceof Error ? error.message : "Unknown error",
stack: error instanceof Error ? error.stack : undefined,
})
router.push(loginPath)
}
} else if (session.session) {
// Session exists, handle organization routing
console.debug(
"[ANONYMOUS_AUTH] Session exists, checking organization...",
)
if (!session.session.activeOrganizationId) {
console.debug(
"[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
)
const orgs = await authClient.organization.list()
console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
count: orgs?.length || 0,
orgs: orgs?.map((o) => ({
id: o.id,
name: o.name,
slug: o.slug,
})),
})
if (orgs?.[0]) {
console.debug(
`[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
)
await authClient.organization.setActive({
organizationId: orgs[0].id,
})
console.debug(
`[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
)
router.push(dashboardPath)
}
} else {
console.debug(
`[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
)
console.debug(
"[ANONYMOUS_AUTH] Fetching full organization details...",
)
const org = await authClient.organization.getFullOrganization({
query: {
organizationId: session.session.activeOrganizationId,
},
})
console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
id: org.id,
name: org.name,
slug: org.slug,
})
console.debug(
`[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
)
router.push(dashboardPath)
}
}
}
createAnonymousSession()
}, [router.push])
// Return null as this component only handles the redirect logic
return null
}

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import {
BulkDeleteMemoriesSchema,
DocumentsWithMemoriesQuerySchema,
ListMemoriesQuerySchema,
SearchRequestSchema,
@ -151,4 +152,26 @@ describe("pagination query schemas", () => {
expect(parsed.page).toBe(2)
expect(parsed.limit).toBe(50)
})
it("DocumentsWithMemoriesQuerySchema caps limit at 1000", () => {
expect(
DocumentsWithMemoriesQuerySchema.safeParse({ limit: 1001 }).success,
).toBe(false)
expect(
DocumentsWithMemoriesQuerySchema.safeParse({ limit: 200 }).success,
).toBe(true)
})
it("BulkDeleteMemoriesSchema caps containerTags at 100 entries of bounded length", () => {
const tooMany = {
containerTags: Array.from({ length: 101 }, (_, i) => `tag_${i}`),
}
expect(BulkDeleteMemoriesSchema.safeParse(tooMany).success).toBe(false)
const tagTooLong = { containerTags: ["x".repeat(257)] }
expect(BulkDeleteMemoriesSchema.safeParse(tagTooLong).success).toBe(false)
const ok = { containerTags: ["tag_a", "tag_b"] }
expect(BulkDeleteMemoriesSchema.safeParse(ok).success).toBe(true)
})
})

View file

@ -1102,8 +1102,8 @@ export const DocumentsWithMemoriesQuerySchema = z
description: "Page number to fetch",
example: 1,
}),
limit: z.number().int().min(1).default(10).openapi({
description: "Number of items per page",
limit: z.number().int().min(1).max(1000).default(10).openapi({
description: "Number of items per page (max 1000)",
example: 10,
}),
sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({
@ -1409,12 +1409,13 @@ export const BulkDeleteMemoriesSchema = z
example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
}),
containerTags: z
.array(z.string())
.array(z.string().max(256))
.min(1)
.max(100)
.optional()
.openapi({
description:
"Array of container tags - all memories in these containers will be deleted",
"Array of container tags - all memories in these containers will be deleted (max 100 at once)",
example: ["user_123", "project_123"],
}),
})