Compare commits

...

41 commits

Author SHA1 Message Date
MaheshtheDev
4d8a4ebfdd fix(mcp): let getDocument read any accessible document (#1641)
Some checks failed
Publish Pipecat SDK Python / publish (push) Has been cancelled
Publish Agent Framework Python / publish (push) Has been cancelled
Publish AI SDK / publish (push) Has been cancelled
Publish Cartesia SDK Python / publish (push) Has been cancelled
Publish OpenAI SDK Python / publish (push) Has been cancelled
Publish Tools / publish (push) Has been cancelled
getDocument filtered on the caller's active space, so an ID from listDocuments in any other space returned "Document not found". With activeSpace unset the fallback is sm_project_default, which broke most cross-space reads.

The API already scopes document reads to the caller's org, so the extra filter added no protection. Verified locally against the mono API: own-space and cross-space IDs now resolve, foreign-org IDs still 404.
2026-09-02 21:49:57 +00:00
Luv
9a0c5a5ad6
docs: fixed typo in graph-memory.mdx (#1640) 2026-09-02 10:07:03 -07:00
MaheshtheDev
17eab43cd4 docs: add Cursor and Grok Bot plugin pages (#1635)
Ship dedicated integration docs and point the plugin catalog at them. Grok Bot stays to install, auth, and skills — no Cursor-only config or repo tags.
2026-09-01 20:51:54 +00:00
Dhravya
4ad5f0beb1
feat(sdk-playground): reflect SDK-owned memory block in debug view (#1533)
## Stack Context

Part 3 (top) of a 3-PR stack moving memory deduplication into the SDKs. See `sdk-dedup/tools-ts` for full context.

## What?

Update the SDK playground so its debug view reflects the SDK-owned memory block.

- Displays the current deduplicated `<supermemory>` replacement block produced by the SDK middleware, instead of the old browser-side "seen facts" delta.
- Adds a `memory-dedupe` helper and ignores local `*.tsbuildinfo`.

## Why?

The previous debug cards were misleading — they showed an incremental browser-filtered delta while the middleware actually re-injected the full profile. Now the visualization matches what the SDK really sends.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Playground-only visualization and chat gating changes; no production SDK or API behavior.
>
> **Overview**
> The playground **debug trace** now shows the **deduplicated memory block** the SDK middleware would inject (static → dynamic → search, mode-aware), instead of a misleading browser-side “new facts” delta. A new **`memory-dedupe`** helper mirrors `@supermemory/tools` middleware behavior and is applied when fetching container context and building middleware memory debug entries; the context preview card is relabeled to reflect that each turn **replaces** the prior `<supermemory>` block.
>
> **Chat UX:** messaging is enabled when API keys are configured on the **server** (`hasSupermemoryKey` / `hasOpenAiKey` from `/api/chat`), not only when keys are typed in the panel. The message input stays editable while waiting for text; Send still requires non-empty input.
>
> Also ignores `*.tsbuildinfo` in `.gitignore`.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ed15364eb3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-09-01 06:10:37 +00:00
Dhravya
03773c4f2e
feat(python-sdks): SDK-level cross-source memory deduplication (#1532)
## Stack Context

Part 2 of a 3-PR stack moving memory deduplication into the SDKs. See `sdk-dedup/tools-ts` (parent) for the full context and the TypeScript implementation this mirrors.

## What?

Port the normalized, priority-ordered (`static > dynamic > search`) profile deduplication into the Python SDKs.

- Each request injects one **owned memory block that replaces** the prior block rather than accumulating.
- Dedup is **request-local** (no shared state), so it stays correct under concurrency.

Covers OpenAI, Agent Framework (middleware + context provider), Cartesia, and Pipecat.

## Why?

Keeps the Python SDKs at behavioral parity with the TypeScript SDK so all integrations deduplicate memory the same way.

## Testing

- OpenAI: 31 passed, 11 skipped (live)
- Agent Framework: 59 passed
- Cartesia: 8 passed
- Pipecat: 8 passed

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes memory formatting and system-prompt injection across multiple SDK integrations; incorrect dedup or replacement could alter LLM context, but there is no auth or data-store risk.
>
> **Overview**
> Ports **normalized cross-source memory deduplication** and **replace-not-append injection** into the Python OpenAI, Agent Framework, Cartesia, and Pipecat packages so they match the TypeScript SDK behavior.
>
> **Deduplication** uses request-local keys: strip optional `[YYYY-MM-DD]` prefixes, normalize whitespace, and compare with `casefold`, with priority **static → dynamic → search**. In **`query` mode**, profile static/dynamic are excluded from dedup input so facts that only appear in search (or overlap profile) are not dropped before formatting.
>
> **Injection** no longer appends memory text every turn. OpenAI and Agent Framework middleware **strip prior owned `<supermemory context="user-memories" readonly>` blocks** and **replace** them once per request while keeping the caller’s system instructions; extra system messages lose stale blocks only. New helpers (`strip`/`replace`/`wrap`) live in each package’s utils.
>
> Tests cover normalized fact variants, query-mode search retention, and stale block replacement.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 42f308b224. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-09-01 06:10:36 +00:00
Dhravya
d0f53b0d64
feat(tools): SDK-level cross-source memory deduplication (#1531)
## Stack Context

This stack moves memory deduplication **out of the playground UI and into the SDKs themselves**, so every integration injects a single, deduplicated, self-replacing memory block. Three PRs:

1. **`sdk-dedup/tools-ts`** (this PR) — TypeScript SDK core + integrations
2. `sdk-dedup/python` — Python SDKs
3. `sdk-dedup/playground` — playground debug view reflects the SDK-owned block

## What?

Move profile deduplication into the SDK middleware for the TypeScript tools package.

- Facts are normalized (strip leading `[YYYY-MM-DD]`, trim, collapse whitespace, casefold) and deduplicated in **`static > dynamic > search`** priority within a single request.
- The result is injected as one **owned `<supermemory>` block** that *replaces* the previous block instead of accumulating a new one each turn.
- Dedup is **mode-aware**: in query mode, search results are not dropped against a profile that isn't being injected.
- Deduplication is **request-local** — no global/browser `Set`. Safe for multiple users, concurrent requests, and Cloudflare Worker isolates.

Covers AI SDK, OpenAI (Chat + Responses), Mastra, and VoltAgent. New `shared/memory-context.ts` owns the block-replacement logic.

## Why?

The earlier "conversation-scoped deduplication" was only a playground browser `Set` — a UI debug affordance that did not change what the SDK sent to the model, and would have been unsafe as server-side global state. Real cross-source dedup belongs in the SDK, applied fresh per stateless model request.

## Testing

- `bun run test` in `packages/tools`: 145 passed (the one failing suite, `claude-memory.test.ts`, is a pre-existing broken import unrelated to this change).

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how system prompts and instructions are built across all TypeScript integrations; behavior is well-covered by unit tests but incorrect strip/replace logic could drop or duplicate context in production prompts.
>
> **Overview**
> Moves **cross-source memory deduplication** and **owned prompt injection** into `@supermemory/tools` so every integration sends one deduplicated memory block per request instead of growing context each turn.
>
> **Deduplication:** Facts are normalized via `normalizeMemoryFact` (strip `[YYYY-MM-DD]`, trim, collapse whitespace, lowercase) and deduplicated with **static → dynamic → search** priority. `deduplicateMemoriesForMode` keeps search hits in **query** mode when the profile is not injected.
>
> **Owned `<supermemory>` block:** New `shared/memory-context.ts` wraps memories in `<supermemory context="user-memories" readonly>`, strips stale blocks, and **replaces** prior SDK context while preserving caller system instructions. Applied in AI SDK (`injectMemoriesIntoParams`), OpenAI Chat/Responses middleware, Mastra input processor (`wrapMemoryContext`), and VoltAgent hooks.
>
> **Tests:** Unit coverage for block replacement (with-supermemory, OpenAI, VoltAgent), Mastra wrapper tag assertion, normalized dedup variants, and concurrent `containerTag` isolation.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fa2e0d85c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-09-01 06:10:36 +00:00
Dhravya
b01d2b69a3
feat(sdk-playground): interactive SDK chat playground (#1437)
## Summary
- Add `apps/sdk-playground` — chat UI to test TS/Python SDK integrations
- Context panel with document memories, API keys in dashboard, tools reference tab
- Python FastAPI server on port 8792; portless entry in `portless.json`

Stacked on #1436

## Test plan
- [ ] `cd apps/sdk-playground && bun run check-types`
- [ ] `bun run dev` with Supermemory + OpenAI keys in UI
- [ ] Switch SDKs and verify chat + context panel

Made with [Cursor](https://cursor.com)
2026-09-01 06:10:36 +00:00
Dhravya
7974498062
chore(ci): wire JS tools unit tests into CI (#1436)
## Summary
- Add tools/ai-sdk unit test jobs to `ci.yml`
- Update `turbo.json` and root `package.json` scripts
- Refresh `bun.lock`

Stacked on #1435

## Test plan
- [ ] CI passes on this branch

Made with [Cursor](https://cursor.com)
2026-09-01 06:10:36 +00:00
Dhravya
4173edb9e6
docs(skills): refresh Supermemory skill for 7-tool parity (#1435)
## Summary
- Update `SKILL.md` with proactive search and full 7-tool surface
- Refresh `sdk-guide.md` with v4 API examples and tool descriptions

Stacked on #1434

## Test plan
- [ ] Review skill content for accuracy

Made with [Cursor](https://cursor.com)
2026-09-01 06:10:36 +00:00
Dhravya
c262cc9953
fix(python-sdks): v4 API migration for integration packages (#1434)
## Summary
- **agent-framework**: proactive search tool descriptions
- **cartesia / pipecat**: v4 `client.add` + hybrid search, dedupe fixes, tests

Stacked on #1433

## Test plan
- [ ] pytest in agent-framework, cartesia, pipecat packages

Made with [Cursor](https://cursor.com)
2026-09-01 06:10:36 +00:00
Dhravya Shah
879ddd5c95
docs: clarify customer content is never used to train models on any plan (#1613)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-31 23:09:30 -07:00
Dhravya
46d1b53230
feat(ai-sdk): re-export 7-tool surface (#1433)
## Summary
- Re-export full tool set from `@supermemory/tools/ai-sdk`
- Add unit tests for tool re-exports

Stacked on #1432

## Test plan
- [ ] `bun run test:unit` in `packages/ai-sdk`

Made with [Cursor](https://cursor.com)
2026-09-01 05:59:58 +00:00
Dhravya
de3bbb3ce9
feat(tools): 7-tool parity and description refresh (#1432)
## Summary
- Refresh canonical tool descriptions in `tools-shared.ts`
- Align OpenAI and AI SDK tool bindings with 7-tool surface
- Export `TOOL_DESCRIPTIONS` / `PARAMETER_DESCRIPTIONS` from package index

Stacked on #1431

## Test plan
- [ ] `bun run test:unit` in `packages/tools`

Made with [Cursor](https://cursor.com)
2026-09-01 05:59:57 +00:00
Dhravya Shah
5fee2f2872
docs(mcp): fix grammar in ChatGPT web plugin description (#1630)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-31 21:46:32 -07:00
Dhravya Shah
ece20ff53e
chore(ci): Python SDK pytest workflow (#1431)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 21:39:36 -07:00
Dhravya
348483d5e8
feat(openai-sdk-python): 7-tool parity (#1430)
## Summary
- Expand `SupermemoryTools` from 2 tools to 7 (matches `@supermemory/tools`)
- Add `memory_forget` via shared HTTP helper
- Add document list/add/delete and get_profile tool surfaces
- Expand tests for new tools and execution paths

Stacked on #1429

## Test plan
- [x] `uv run pytest tests/test_tools.py::TestMemoryOperationsUnit`

Made with [Cursor](https://cursor.com)
2026-09-01 04:34:18 +00:00
Dhravya
c5b7e7d4fc
fix(openai-sdk-python): migrate to v4 Supermemory APIs (#1429)
## Summary
- Replace deprecated `search.execute` with `search.memories` (hybrid mode) in `search_memories`
- Replace `memories.add` with `client.add` in tools and middleware
- Fix middleware `container_tag` param (was incorrectly `container_tags`)
- Fix profile memory deduplication for string and Pydantic API items
- Bump `supermemory>=3.50` and `requires-python>=3.9`

## Test plan
- [x] `uv run pytest tests/test_tools.py::TestMemoryOperationsUnit`

Made with [Cursor](https://cursor.com)
2026-09-01 04:34:18 +00:00
MaheshtheDev
143024fa34 chore(web): forward legacy auth paths (#1631)
Forwards /auth/connect and /auth/agent-connect with the query string intact, and drops the pages they replaced.
2026-09-01 00:55:16 +00:00
MaheshtheDev
9ccd1b64c3 chore(web): clean up onboarding and dashboard surfaces (#1614)
Removes stale promo cards, banners, and the setup modal. Simplifies the onboarding entry so all new workspaces go through one flow.
2026-08-29 04:57:09 +00:00
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
196 changed files with 15035 additions and 5528 deletions

257
.github/workflows/ci-python.yml vendored Normal file
View file

@ -0,0 +1,257 @@
name: CI - Python SDKs
on:
pull_request:
paths:
- "packages/agent-framework-python/**"
- "packages/cartesia-sdk-python/**"
- "packages/openai-sdk-python/**"
- "packages/pipecat-sdk-python/**"
- ".github/workflows/ci-python.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
jobs:
agent-framework-python:
name: agent-framework-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-supermemory
supermemory-version: "3.16.0"
- python-version: "3.13"
dependency-lane: current-supermemory
supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/agent-framework-python
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/agent-framework-python/pyproject.toml
- name: Install build and test tools
run: python -m pip install build pytest pytest-asyncio
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel and tested Supermemory SDK
run: >-
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
"supermemory==${{ matrix.supermemory-version }}"
- name: Check dependency compatibility
run: python -m pip check
- name: Verify installed wheel and SDK version
run: >-
python -c "from importlib.metadata import version; from pathlib import Path;
import supermemory_agent_framework;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
- name: Run tests
run: python -m pytest
openai-sdk-python:
name: openai-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.9"
dependency-lane: minimum-supermemory
supermemory-version: "3.50.0"
expected-supermemory-version: "3.50.0"
- python-version: "3.12"
dependency-lane: locked
supermemory-version: ""
expected-supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/openai-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Setup uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.12.5"
enable-cache: true
working-directory: packages/openai-sdk-python
cache-dependency-glob: uv.lock
- name: Install locked dependencies
run: uv sync --locked --python "${{ matrix.python-version }}"
- name: Build wheel
run: uv build --wheel --out-dir "$RUNNER_TEMP/wheels"
- name: Install built wheel
run: >-
uv pip install --python .venv/bin/python --reinstall --no-deps
"$RUNNER_TEMP"/wheels/*.whl
- name: Install minimum Supermemory SDK
if: matrix.supermemory-version != ''
run: >-
uv pip install --python .venv/bin/python
"supermemory==${{ matrix.supermemory-version }}"
- name: Check dependency compatibility
run: uv pip check --python .venv/bin/python
- name: Verify installed wheel and SDK version
run: >-
.venv/bin/python -c "from importlib.metadata import version;
from pathlib import Path; import supermemory_openai;
assert version('supermemory') == '${{ matrix.expected-supermemory-version }}';
assert 'site-packages' in Path(supermemory_openai.__file__).parts"
- name: Run tests without changing the verified environment
run: .venv/bin/python -m pytest
cartesia-sdk-python:
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-dependencies
supermemory-version: "3.16.0"
cartesia-line-version: "0.2.0"
- python-version: "3.12"
dependency-lane: current-dependencies
supermemory-version: "3.59.0"
cartesia-line-version: "0.2.17"
defaults:
run:
working-directory: packages/cartesia-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
- name: Install build and test tools
run: python -m pip install build pytest
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel and tested runtime dependencies
run: >-
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
"supermemory==${{ matrix.supermemory-version }}"
"cartesia-line==${{ matrix.cartesia-line-version }}"
- name: Check dependency compatibility
run: python -m pip check
- name: Verify real Cartesia Line integration and run tests
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import line, pytest, supermemory_cartesia;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
assert version('cartesia-line') == '${{ matrix.cartesia-line-version }}';
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
raise SystemExit(result)"
pipecat-sdk-python:
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-dependencies
supermemory-version: "3.16.0"
pipecat-version: "0.0.98"
- python-version: "3.12"
dependency-lane: current-dependencies
supermemory-version: "3.59.0"
pipecat-version: "1.7.0"
defaults:
run:
working-directory: packages/pipecat-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
- name: Install build and test tools
run: python -m pip install build pytest
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel and tested runtime dependencies
run: >-
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
"supermemory==${{ matrix.supermemory-version }}"
"pipecat-ai==${{ matrix.pipecat-version }}"
- name: Check dependency compatibility
run: python -m pip check
- name: Verify real Pipecat integration and run tests
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import pipecat, pytest, supermemory_pipecat;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
assert version('pipecat-ai') == '${{ matrix.pipecat-version }}';
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
raise SystemExit(result)"

View file

@ -26,8 +26,83 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Run TypeScript type checking - name: Detect SDK and playground changes
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' id: sdk-changes
run: |
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
echo "tools=false" >> "$GITHUB_OUTPUT"
else
echo "tools=true" >> "$GITHUB_OUTPUT"
fi
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/ai-sdk; then
echo "ai_sdk=false" >> "$GITHUB_OUTPUT"
else
echo "ai_sdk=true" >> "$GITHUB_OUTPUT"
fi
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- apps/sdk-playground; then
echo "sdk_playground=false" >> "$GITHUB_OUTPUT"
else
echo "sdk_playground=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Python for SDK Playground
if: steps.sdk-changes.outputs.sdk_playground == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Setup uv for SDK Playground
if: steps.sdk-changes.outputs.sdk_playground == 'true'
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.12.5"
enable-cache: true
working-directory: apps/sdk-playground/python
cache-dependency-glob: uv.lock
- name: Validate SDK Playground Python server
if: steps.sdk-changes.outputs.sdk_playground == 'true'
working-directory: apps/sdk-playground/python
run: |
uv sync --locked --python 3.12
.venv/bin/python -m py_compile server.py
.venv/bin/python -c "import server"
- name: Run Tools unit tests
if: steps.sdk-changes.outputs.tools == 'true'
run: bun run --cwd packages/tools test:unit
- name: Build Tools package
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
run: bun run --cwd packages/tools build
- name: Run AI SDK type checking
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
run: bun run --cwd packages/ai-sdk check-types
- name: Run AI SDK unit tests
if: steps.sdk-changes.outputs.ai_sdk == 'true'
run: bun run --cwd packages/ai-sdk test:unit
- name: Build AI SDK package
if: steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
run: bun run --cwd packages/ai-sdk build
- name: Run SDK Playground type checking
if: steps.sdk-changes.outputs.sdk_playground == 'true'
run: bun run --cwd apps/sdk-playground check-types:app
- name: Build SDK Playground
if: steps.sdk-changes.outputs.sdk_playground == 'true'
run: bun run --cwd apps/sdk-playground build:app
- name: Run Memory Graph type checking
run: bun run --cwd packages/memory-graph check-types
- name: Run Memory Graph unit tests
run: bun run --cwd packages/memory-graph test
- name: Run Biome CI (format & lint on changed files) - name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -23,20 +23,22 @@ jobs:
working-directory: ./packages/agent-framework-python working-directory: ./packages/agent-framework-python
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install build dependencies - name: Install build dependencies
run: pip install hatchling build run: python -m pip install hatchling build
- name: Build package - name: Build package
run: python -m build run: python -m build
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with: with:
packages-dir: packages/agent-framework-python/dist/ packages-dir: packages/agent-framework-python/dist/

View file

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
paths: paths:
- "packages/ai-sdk/package.json" - "packages/ai-sdk/package.json"
concurrency: concurrency:
@ -15,7 +15,7 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
defaults: defaults:
@ -38,26 +38,65 @@ jobs:
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
run: bun install working-directory: .
run: bun install --frozen-lockfile
- name: Check if version changed - name: Check if version changed
id: version-check id: version-check
run: | run: |
PACKAGE_NAME=$(jq -r '.name' package.json) PACKAGE_NAME=$(jq -r '.name' package.json)
LOCAL_VERSION=$(jq -r '.version' package.json) LOCAL_VERSION=$(jq -r '.version' package.json)
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") if npm view "$PACKAGE_NAME@$LOCAL_VERSION" version >/dev/null 2>&1; then
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
echo "Version $LOCAL_VERSION already published, skipping." echo "Version $LOCAL_VERSION already published, skipping."
echo "changed=false" >> "$GITHUB_OUTPUT" echo "changed=false" >> "$GITHUB_OUTPUT"
else else
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" echo "Publishing $LOCAL_VERSION."
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
fi fi
- name: Build - name: Wait for the Tools dependency
if: steps.version-check.outputs.changed == 'true'
run: |
TOOLS_SPEC=$(jq -r '.dependencies["@supermemory/tools"]' package.json)
TOOLS_VERSION=${TOOLS_SPEC#^}
TOOLS_VERSION=${TOOLS_VERSION#~}
if [[ ! "$TOOLS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
echo "Unsupported @supermemory/tools dependency spec: $TOOLS_SPEC" >&2
exit 1
fi
for attempt in {1..20}; do
PUBLISHED_VERSION=$(npm view "@supermemory/tools@$TOOLS_VERSION" version 2>/dev/null || true)
if [ "$PUBLISHED_VERSION" = "$TOOLS_VERSION" ]; then
echo "@supermemory/tools@$TOOLS_VERSION is available on npm."
exit 0
fi
echo "Waiting for @supermemory/tools@$TOOLS_VERSION (attempt $attempt/20)."
sleep 15
done
echo "@supermemory/tools@$TOOLS_VERSION was not published within five minutes." >&2
exit 1
- name: Build Tools dependency
if: steps.version-check.outputs.changed == 'true'
run: bun run --cwd ../tools build
- name: Build AI SDK package
if: steps.version-check.outputs.changed == 'true' if: steps.version-check.outputs.changed == 'true'
run: bun run build run: bun run build
- name: Verify packed artifact
if: steps.version-check.outputs.changed == 'true'
run: |
npm pack --dry-run --json > "$RUNNER_TEMP/ai-sdk-pack.json"
jq -e '
(.[0].files | any(.path == "dist/index.js")) and
(.[0].files | any(.path == "dist/index.d.ts"))
' "$RUNNER_TEMP/ai-sdk-pack.json" >/dev/null
- name: Publish - name: Publish
if: steps.version-check.outputs.changed == 'true' if: steps.version-check.outputs.changed == 'true'
run: npm publish --access public --provenance run: npm publish --access public --provenance

View file

@ -23,20 +23,22 @@ jobs:
working-directory: ./packages/cartesia-sdk-python working-directory: ./packages/cartesia-sdk-python
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install build dependencies - name: Install build dependencies
run: pip install hatchling build run: python -m pip install hatchling build
- name: Build package - name: Build package
run: python -m build run: python -m build
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with: with:
packages-dir: packages/cartesia-sdk-python/dist/ packages-dir: packages/cartesia-sdk-python/dist/

View file

@ -23,20 +23,22 @@ jobs:
working-directory: ./packages/pipecat-sdk-python working-directory: ./packages/pipecat-sdk-python
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install build dependencies - name: Install build dependencies
run: pip install hatchling build run: python -m pip install hatchling build
- name: Build package - name: Build package
run: python -m build run: python -m build
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with: with:
packages-dir: packages/pipecat-sdk-python/dist/ packages-dir: packages/pipecat-sdk-python/dist/

View file

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

View file

@ -56,7 +56,7 @@ Uploading a long PDF does more than store bytes: Supermemory derives many memori
## Properties and rules of memories ## Properties and rules of memories
1. Memories are atomic - Each memory has enough information and context about one particular topic 1. Memories are atomic - Each memory has enough information and context about one particular topic
2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledg.e 2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledge.
## Memory relationships ## Memory relationships

View file

@ -388,6 +388,8 @@
"pages": [ "pages": [
"integrations/openclaw", "integrations/openclaw",
"integrations/claude-code", "integrations/claude-code",
"integrations/cursor",
"integrations/grok-bot",
"integrations/opencode", "integrations/opencode",
"integrations/codex", "integrations/codex",
"integrations/hermes" "integrations/hermes"

View file

@ -0,0 +1 @@
<svg fill="none" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m96 73h320.735v365.65h-320.735z"/></clipPath><path d="m512.001 325.499c0 7.124 0 14.24-.041 21.364-.035 5.999-.103 11.998-.268 17.99-.356 13.068-1.124 26.245-3.448 39.169-2.359 13.109-6.205 25.306-12.266 37.222-5.958 11.703-13.746 22.419-23.029 31.709-9.29 9.291-20 17.072-31.71 23.03-11.909 6.061-24.113 9.907-37.222 12.266-12.923 2.324-26.101 3.092-39.169 3.448-5.999.165-11.991.233-17.99.268-7.123.048-14.24.041-21.364.041h-139c-7.124 0-14.24 0-21.364-.041-5.999-.035-11.998-.103-17.99-.268-13.068-.356-26.245-1.124-39.169-3.448-13.109-2.359-25.306-6.205-37.2219-12.266-11.7034-5.958-22.4195-13.746-31.7095-23.03-9.29-9.29-17.0717-19.999-23.0296-31.709-6.06082-11.909-9.90709-24.113-12.26559-37.222-2.32422-12.924-3.092104-26.101-3.448621-39.169-.164547-5.999-.2331075-11.991-.267388-17.99-.02742444-7.124-.02742444-14.24-.02742444-21.364v-139c0-7.124 0-14.24.04113664-21.364.0342805-5.999.1028418-11.998.2673878-17.99.356517-13.068 1.124399-26.245 3.448619-39.169 2.3585-13.1091 6.20477-25.3061 12.26558-37.222 5.9579-11.7034 13.7465-22.4195 23.0296-31.7095 9.2901-9.29 19.9993-17.0717 31.7095-23.0296 11.9091-6.06082 24.1129-9.90709 37.2222-12.26559 12.923-2.32422 26.101-3.092101 39.168-3.448618 6-.164547 11.992-.2331075 17.991-.2673881 7.117-.03428046 14.233-.03428046 21.357-.03428046h139c7.124 0 14.24 0 21.364.04113656 5.999.0342806 11.998.102842 17.99.267388 13.068.356517 26.245 1.124402 39.169 3.448622 13.109 2.3585 25.306 6.20477 37.222 12.26553 11.703 5.958 22.419 13.7465 31.709 23.0297 9.29 9.29 17.072 19.9992 23.03 31.7094 6.061 11.9091 9.907 24.113 12.266 37.2222 2.324 12.923 3.092 26.101 3.448 39.169.165 5.999.233 11.991.268 17.99.048 7.123.041 14.24.041 21.364v139z" fill="#14120b"/><path d="m186.501 3.99902h139c7.125 0 14.231-.00004 21.341.04102 5.985.0342 11.952.10221 17.903.26562h.001c13.003.35475 25.943 1.11709 38.569 3.3877 12.775 2.29828 24.592 6.03144 36.118 11.89354v-.001c10.973 5.5867 21.052 12.8384 29.848 21.4571l.847.8379c8.993 8.993 16.525 19.3593 22.292 30.6933l3.565-1.8135-3.565 1.8145c5.678 11.1575 9.36 22.6024 11.674 34.9208l.219 1.195c2.129 11.837 2.932 23.95 3.316 36.132l.072 2.438c.164 5.958.232 11.919.266 17.904v.004c.048 7.107.041 14.207.041 21.337v129.344l-.007-.007v9.656c0 7.125 0 14.231-.041 21.341-.034 5.985-.102 11.952-.266 17.903v.001c-.354 13.003-1.117 25.944-3.387 38.569-2.227 12.376-5.8 23.853-11.351 35.037l-.543 1.08c-5.767 11.327-13.306 21.701-22.293 30.695-8.993 8.993-19.361 16.526-30.695 22.293-11.518 5.861-23.342 9.595-36.116 11.894-11.837 2.128-23.95 2.931-36.132 3.315l-2.438.072c-5.958.164-11.919.232-17.904.266h-.004c-7.107.048-14.207.041-21.337.041h-139c-7.125 0-14.231 0-21.341-.041-5.985-.034-11.952-.102-17.903-.266h-.001c-13.003-.355-25.944-1.117-38.57-3.387-12.7742-2.299-24.5914-6.032-36.1165-11.894h.001c-10.974-5.587-21.0534-12.837-29.8496-21.456l-.8467-.838c-8.7118-8.712-16.0531-18.713-21.7461-29.634l-.5459-1.06-.544-1.081c-5.3709-10.816-8.8904-21.919-11.12983-33.84l-.21973-1.196c-2.27061-12.625-3.03294-25.566-3.38769-38.569v-.001c-.16342-5.958-.23143-11.918-.26563-17.903-.02736-7.112-.02734-14.219-.02734-21.341v-139c0-7.125-.00005-14.231.04101-21.341.0342-5.985.10222-11.952.26563-17.903v-.001c.35475-13.003 1.11699-25.944 3.38769-38.57 2.29829-12.7743 6.03159-24.5916 11.89359-36.1166l-.001-.001c5.7665-11.3269 13.3073-21.6999 22.2939-30.6934 8.9933-8.9932 19.36-16.5261 30.6944-22.2929h.0009c11.5174-5.8615 23.3407-9.59617 36.1149-11.89455 11.837-2.12878 23.951-2.93017 36.133-3.31446l2.438-.07226c5.958-.16342 11.919-.23142 17.904-.26563l-.001-.00097c7.104-.03422 14.211-.03321 21.335-.03321z" stroke="#edecec" stroke-opacity=".2" stroke-width="8"/><g clip-path="url(#a)"><path d="m410.344 159.545-146.38-84.5111c-4.7-2.7145-10.5-2.7145-15.2 0l-146.373 84.5111c-3.9515 2.282-6.391 6.501-6.391 11.071v170.418c0 4.569 2.4395 8.789 6.391 11.07l146.379 84.512c4.701 2.714 10.501 2.714 15.201 0l146.38-84.512c3.951-2.281 6.391-6.501 6.391-11.07v-170.418c0-4.57-2.44-8.789-6.391-11.071zm-9.195 17.902-141.308 244.751c-.955 1.65-3.477.976-3.477-.934v-160.261c0-3.203-1.711-6.164-4.487-7.772l-138.786-80.127c-1.65-.956-.976-3.478.934-3.478h282.616c4.013 0 6.522 4.35 4.515 7.828h-.007z" fill="#edecec"/></g></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -145,7 +145,7 @@ Per-repo overrides in `.claude/.supermemory-claude/config.json`. Run `/supermemo
Source code, issues, and detailed README. Source code, issues, and detailed README.
</Card> </Card>
<Card title="OpenClaw Plugin" icon="messages-square" href="/integrations/openclaw"> <Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
Multi-platform memory for Telegram, WhatsApp, Discord, and more. Memory for your Cursor chats.
</Card> </Card>
</CardGroup> </CardGroup>

View file

@ -170,7 +170,7 @@ tail -f ~/.codex-supermemory.log
Source code, issues, and detailed README. Source code, issues, and detailed README.
</Card> </Card>
<Card title="Claude Code Plugin" icon="code" href="/integrations/claude-code"> <Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
Memory plugin for Claude Code. Memory for your Cursor chats.
</Card> </Card>
</CardGroup> </CardGroup>

View file

@ -0,0 +1,208 @@
---
title: "Cursor"
sidebarTitle: "Cursor"
description: "cursor-supermemory: persistent memory across your Cursor chats"
icon: "/images/cursor-logo.svg"
---
Your agent remembers the decisions, bugs, and conventions from earlier chats instead of starting cold every time.
## Install
<Note>
Requires [Node.js](https://nodejs.org) on your `PATH`. Installing Cursor does not put one there.
</Note>
Run this in Cursor:
```
/add-plugin cursor-supermemory
```
Or install it from the [Cursor Marketplace](https://cursor.com/marketplace/supermemory): open **Customize**, find **Supermemory**, select **Install**, and choose **project** or **user** scope.
Restart Cursor or run **Developer: Reload Window** afterwards.
## Authenticate
Open a new chat in Cursor and run:
```
/supermemory-setup
```
A browser window opens. Sign in to Supermemory and you are done.
As a fallback, set an API key from [API Keys](https://console.supermemory.ai/keys):
<Tabs>
<Tab title="macOS / Linux (zsh)">
```bash
echo 'export SUPERMEMORY_API_KEY="sm_..."' >> ~/.zshrc
source ~/.zshrc
```
</Tab>
<Tab title="macOS / Linux (bash)">
```bash
echo 'export SUPERMEMORY_API_KEY="sm_..."' >> ~/.bashrc
source ~/.bashrc
```
</Tab>
<Tab title="Windows (PowerShell)">
```powershell
[System.Environment]::SetEnvironmentVariable("SUPERMEMORY_API_KEY", "sm_...", "User")
```
Restart your terminal after running this.
</Tab>
</Tabs>
Restart Cursor after installing the plugin or changing credentials.
Check the connection any time with `/supermemory-status`.
<Accordion title="Prefer the terminal?" icon="terminal">
The slash commands just run the plugin's CLI for you. To drive it yourself:
```bash
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" login
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" status
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" logout
```
`CURSOR_PLUGIN_ROOT` is set for plugin hooks. If it is empty in your shell, run `node dist/cli.js <command>` from the installed plugin directory.
Credentials are stored in `~/.supermemory-cursor/credentials.json`.
</Accordion>
## How It Works
| Layer | What it does |
|-------|--------------|
| Session profile | Loads your persistent profile when a Cursor conversation starts |
| Automatic recall | Searches on substantive prompts, deduplicates results, and injects them after the first supported tool result |
| Incremental capture | Saves each completed turn, and retries unsaved transcript deltas at session end |
| MCP tools | Explicit memory control from any Cursor AI session |
| Context gatherer | Fans out targeted searches before substantial work |
| Always-on rule | Makes the agent recall relevant history proactively |
### Skills and Commands
| Name | Type | Description |
|------|------|-------------|
| `memory-init` | Skill | Explore the codebase and initialize project memory |
| `memory-save` | Skill | Save an insight, decision, or solution worth keeping |
| `memory-search` | Skill | Search memory for past work, bugs, and decisions |
| `supermemory-context-gatherer` | Agent | Gather deep background before substantial work |
| `supermemory-setup` | Command | Connect Supermemory to Cursor |
| `supermemory-status` | Command | Check authentication and live connectivity |
| `supermemory-config` | Command | Create or edit the project config file |
| `supermemory-logout` | Command | Disconnect Supermemory from Cursor |
## MCP Tools
| Tool | Description |
|------|-------------|
| `supermemory_get_config` | Show current config, resolved container tags, and config file paths |
| `supermemory_set_config` | Update config at project or global scope |
| `supermemory_containers` | Show what `user` and `project` container tags resolve to |
| `supermemory_search` | Search memories by query |
| `supermemory_add` | Save new information to memory |
| `supermemory_list` | List stored memories |
| `supermemory_forget` | Delete a memory by id or content |
| `supermemory_profile` | Get your user profile summary |
Every tool that takes a `container` argument accepts:
- `"user"` (default): personal memories for the current repository
- `"project"`: project knowledge for the current repository
- `"both"`: both scopes plus compatible legacy memories
- any custom string: used as a raw container tag
`user` and `project` write to the same repository container. The `sm_scope` metadata field is what keeps personal and session memories separate from explicit project knowledge when an agent asks for one scope.
## Container Tags
Cursor shares one repository tag with the [Claude Code](/integrations/claude-code), [OpenAI Codex](/integrations/codex), and [OpenCode](/integrations/opencode) plugins, so agents working on the same repo read and write the same memory:
```text
repo_<project_name>__<project_id>
```
The project ID is a stable hash of the normalized Git remote. Repositories without a remote fall back to their resolved local path. Two repos with the same directory name never collide, and different agents on the same repository share memory.
The plugin still reads the former `cursor_user_*` and `cursor_project_*` tags, along with legacy tags from the other agents. New writes only use the unified repository tag. Set `repoContainerTag` only when you need an explicit shared override.
## Configuration
<Tip>
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview): run `npx supermemory local`, then set `SUPERMEMORY_API_URL="http://localhost:6767"` (or `baseUrl` in your config file) and use the API key printed on first boot.
</Tip>
### Environment variables
| Variable | Description |
|----------|-------------|
| `SUPERMEMORY_API_KEY` | API key (overrides all other sources) |
| `SUPERMEMORY_API_URL` | Override the Supermemory API base URL |
| `SUPERMEMORY_REPO_TAG` | Override the unified repository container tag |
| `SUPERMEMORY_USER_TAG` | Legacy Cursor personal container to continue reading |
| `SUPERMEMORY_PROJECT_TAG` | Legacy Cursor project container to continue reading |
| `CURSOR_USER_EMAIL` | Used only to find legacy Cursor personal memories |
### Global config
`~/.config/cursor/supermemory.json` holds user-wide defaults and applies to all projects.
```json
{
"repoContainerTag": "repo_my_project__0123456789abcdef",
"similarityThreshold": 0.55,
"maxMemories": 10,
"injectProfile": true,
"signalExtraction": false,
"signalKeywords": ["remember", "architecture", "decision", "bug", "fix"],
"signalTurnsBefore": 3
}
```
### Project config
`.cursor/.supermemory/config.json` holds per-workspace overrides and wins over global config. Add it to `.gitignore` if it contains an API key.
```json
{
"apiKey": "sm_...",
"repoContainerTag": "repo_my_project__0123456789abcdef",
"similarityThreshold": 0.55,
"maxMemories": 10,
"injectProfile": true
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `apiKey` | — | Project-specific API key |
| `baseUrl` | Supermemory API | Override the Supermemory API base URL |
| `repoContainerTag` | derived from normalized Git remote or project path | Override the unified repository container |
| `userContainerTag` | — | Legacy Cursor personal container to continue reading |
| `projectContainerTag` | — | Legacy Cursor project container to continue reading |
| `similarityThreshold` | `0.55` | Minimum similarity for prompt recall. Values below `0.55` are floored. |
| `maxMemories` | `10` | Max profile facts injected at session start |
| `injectProfile` | `true` | Whether to inject the user profile at session start |
| `signalExtraction` | `false` | Capture only turns containing durable-signal keywords |
| `signalKeywords` | `remember`, `architecture`, `decision`, `bug`, `fix` | Keywords that trigger signal-based capture |
| `signalTurnsBefore` | `3` | Number of nearby turns retained around a signal |
You can also set these from the agent with `supermemory_set_config`, or edit the file by hand.
## Log Out
Run `/supermemory-logout` in Cursor.
This removes the stored credentials. Your memories in Supermemory are preserved.
## Next Steps
<Card title="GitHub Repository" icon="/images/github-icon.svg" href="https://github.com/supermemoryai/cursor-supermemory">
Source code, issues, and detailed README.
</Card>

View file

@ -0,0 +1,64 @@
---
title: "Grok Bot"
sidebarTitle: "Grok Bot"
description: "Supermemory for Grok Bot: persistent memory for your Grok Bots"
icon: "/images/grok-logo.png"
---
Grok Bots are cloud agents. A new task can mean a new machine and a blank context. Supermemory is the memory they keep between those tasks — findings, decisions, and preferences — so the next Bot does not start cold.
## How it helps
Without memory, a Bot redoes investigation you already paid for. With Supermemory:
- Later Bots know what earlier ones already figured out
- You can save a decision or finding once and have it stick
- You can ask what it already remembers instead of starting over
Install once, sign in, and it is available to your Grok Bots.
## Install
Open the [Supermemory for Grok Bot plugin page](https://x.ai/bot/plugin/58578698) and select **Add to Grok Bot**.
Restart Grok Bot afterwards so the plugin loads.
## Authenticate
Just ask Grok Bot:
```
Sign me in to Supermemory
```
It shows a connect card. Follow it to link your Supermemory account.
To check later, ask `Is Supermemory connected?`
## Skills
Grok Bot picks these up from what you ask. There are no slash commands.
| Skill | Ask for it like this |
|-------|----------------------|
| `memory-init` | "Learn this codebase and remember it" |
| `memory-save` | "Remember that we use Vitest for unit tests" |
| `memory-search` | "What do you remember about our database schema?" |
## Log Out
Ask Grok Bot to `Disconnect Supermemory`.
This removes the stored credentials. Your memories in Supermemory are preserved.
## Next Steps
<CardGroup cols={2}>
<Card title="GitHub Repository" icon="/images/github-icon.svg" href="https://github.com/supermemoryai/cursor-supermemory">
Source code, issues, and detailed README.
</Card>
<Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
The same plugin, installed through Cursor.
</Card>
</CardGroup>

View file

@ -18,7 +18,7 @@ Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent),
## Installation ## Installation
```bash ```bash
npm install @supermemory/tools @voltagent/core npm install @supermemory/tools @voltagent/core ai@^6 @ai-sdk/openai@^3
``` ```
Set up your API key as an environment variable: Set up your API key as an environment variable:
@ -52,9 +52,7 @@ const configWithMemory = withSupermemory({
const agent = new Agent(configWithMemory) const agent = new Agent(configWithMemory)
// Memories are automatically injected and saved // Memories are automatically injected and saved
const result = await agent.generateText({ const result = await agent.generateText("What's my name?")
messages: [{ role: "user", content: "What's my name?" }],
})
``` ```
<Note> <Note>
@ -131,14 +129,13 @@ const configWithMemory = withSupermemory({
// Search tuning // Search tuning
searchMode: "hybrid", // "memories" | "documents" | "hybrid" searchMode: "hybrid", // "memories" | "documents" | "hybrid"
threshold: 0.1, // 0.0-1.0 (higher = more accurate) threshold: 0.6, // 0.0-1.0 (higher = more accurate)
limit: 10, // Max results to return limit: 10, // Integer from 1 to 100
rerank: true, // Rerank for best relevance rerank: true, // Rerank for best relevance
rewriteQuery: false, // AI-rewrite query (+400ms latency) rewriteQuery: false, // AI-rewrite query (+400ms latency)
// Context // Context
entityContext: "This is John, a software engineer", // Guides memory extraction (max 1500 chars) metadata: { source: "voltagent" }, // Attached to saved conversations
metadata: { source: "voltagent" }, // Attached to saved conversations
// API // API
apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var
@ -154,14 +151,16 @@ const configWithMemory = withSupermemory({
| `addMemory` | string | `"always"` | Whether to save conversations after each response | | `addMemory` | string | `"always"` | Whether to save conversations after each response |
| `customId` | string | **required** | Custom ID to group messages into a conversation | | `customId` | string | **required** | Custom ID to group messages into a conversation |
| `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` | | `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` |
| `threshold` | number | `0.1` | Similarity threshold (0 = more results, 1 = more accurate) | | `threshold` | number | | Similarity threshold (0 = more results, 1 = more accurate) |
| `limit` | number | `10` | Maximum number of memory results | | `limit` | number | — | Maximum number of memory results (integer from 1 to 100) |
| `rerank` | boolean | `false` | Rerank results for relevance | | `rerank` | boolean | `false` | Rerank results for relevance |
| `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) | | `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) |
| `entityContext` | string | — | Context for memory extraction (max 1500 chars) | | `entityContext` | string | — | Deprecated and ignored. [Configure it on the container tag instead](/concepts/customization#entity-context). |
| `metadata` | object | — | Custom metadata attached to saved conversations | | `metadata` | object | — | Custom metadata attached to saved conversations |
| `promptTemplate` | function | — | Custom function to format memory data into prompt | | `promptTemplate` | function | — | Custom function to format memory data into prompt |
When `threshold` or `limit` is omitted, the selected Supermemory backend route applies its own default. Set them explicitly when you need consistent search tuning across modes.
## Search Modes ## Search Modes
The `searchMode` option controls what type of results are searched: The `searchMode` option controls what type of results are searched:
@ -171,4 +170,3 @@ The `searchMode` option controls what type of results are searched:
| `"memories"` | Search only memory entries (atomic facts about the user) | | `"memories"` | Search only memory entries (atomic facts about the user) |
| `"documents"` | Search only document chunks | | `"documents"` | Search only document chunks |
| `"hybrid"` | Search both memories AND document chunks (recommended) | | `"hybrid"` | Search both memories AND document chunks (recommended) |

View file

@ -36,7 +36,7 @@ A malicious or buggy client with a correctly scoped key cannot read another cont
### Data use ### Data use
Supermemory is infrastructure for *your* agents. Paid production usage is not treated as free training corpus for unrelated public models. For contractual wording (DPA, subprocessors, training policies), request the latest legal pack from support. Supermemory is infrastructure for *your* agents. Your customer content is never used to train models — this applies to every plan, free or paid, with no difference between them. For contractual wording (DPA, subprocessors, training policies), request the latest legal pack from support.
### Data residency and deployment options ### Data residency and deployment options

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. **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 ## Related
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits - [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits

View file

@ -113,7 +113,7 @@ On the Plugins page, select the **plus icon** in the upper-right corner.
Complete the **New Plugin** form: Complete the **New Plugin** form:
- In **Name**, enter `Supermemory MCP`. - In **Name**, enter `Supermemory MCP`.
- In **Description**, enter `Memory/context for you ai agents`. - In **Description**, enter `Memory/context for your AI agents`.
- Under **Connection**, select **Server URL** instead of **Tunnel**. - Under **Connection**, select **Server URL** instead of **Tunnel**.
- In the URL field, paste `https://mcp.supermemory.ai/mcp`. - In the URL field, paste `https://mcp.supermemory.ai/mcp`.

View file

@ -1,6 +1,11 @@
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose" import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest" 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 API_URL = "https://api.example.com"
const ISSUER = `${API_URL}/api/auth` const ISSUER = `${API_URL}/api/auth`
@ -120,4 +125,92 @@ describe("MCP authentication", () => {
status: 403, 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 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( export async function validateOAuthToken(
token: string, token: string,
apiUrl: string, apiUrl: string,
@ -97,6 +184,8 @@ export async function validateOAuthToken(
} }
} catch (error) { } catch (error) {
console.error("OAuth token validation error:", error) console.error("OAuth token validation error:", error)
const transient = transientAuthErrorFor(error)
if (transient) throw transient
return null return null
} }
} }

View file

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

View file

@ -2,7 +2,13 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server" import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono" import { Hono, type Context } from "hono"
import { cors } from "hono/cors" 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 { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server" import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types" import type { ActorContext, ServerEnv } from "./types"
@ -42,7 +48,7 @@ app.use(
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps // When omitted, Hono echoes Access-Control-Request-Headers. This keeps
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible. // 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( function unauthorizedResponse(
resourceMetadataUrl: string, resourceMetadataUrl: string,
invalidToken = false, invalidToken = false,
@ -176,8 +206,23 @@ async function handleMcpRequest(
if (!token) return unauthorizedResponse(resourceMetadataUrl) if (!token) return unauthorizedResponse(resourceMetadataUrl)
const authUser = await validateOAuthToken(token, apiUrl, mcpResource) const resolved = await resolveAuthUser(token, apiUrl, mcpResource)
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true) 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 = { const actor: ActorContext = {
userId: authUser.userId, userId: authUser.userId,

View file

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

View file

@ -21,7 +21,7 @@ export function register(deps: ToolDeps) {
{ {
title: "Get Document", title: "Get Document",
description: description:
"Read one stored document by ID, including its summary and available content. Use listDocuments in the intended space to discover document IDs.", "Read one stored document by ID from any space you can access, including its summary and available content. Use listDocuments to discover document IDs.",
inputSchema, inputSchema,
outputSchema: getDocumentOutputSchema, outputSchema: getDocumentOutputSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS, annotations: READ_ONLY_TOOL_ANNOTATIONS,

View file

@ -13,7 +13,11 @@ export function register(deps: ToolDeps) {
description: 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.", "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({ 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, outputSchema: saveViewSchema,
_meta: appToolMeta(), _meta: appToolMeta(),

View file

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

View file

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

View file

@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
email: z.string().optional(), email: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),
}), }),
org: z.looseObject({ id: z.string().min(1) }).optional(),
role: z.string().optional(), role: z.string().optional(),
accessType: z.enum(["full", "restricted"]).optional(), accessType: z.enum(["full", "restricted"]).optional(),
containerTags: z.array(containerTagAccessSchema).nullable().optional(), containerTags: z.array(containerTagAccessSchema).nullable().optional(),
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema> 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`. // ViewMessage — discriminated union returned by app tools as `structuredContent`.
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component. // 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. // 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" return "No content available"
} }
// metadata.url comes from ingested content, so only http(s) reaches the OS opener.
const extractUrl = (memory: SearchResult) => { const extractUrl = (memory: SearchResult) => {
if (memory.metadata?.url && typeof memory.metadata.url === "string") { 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 return null
} }

View file

@ -0,0 +1,11 @@
# Local development only. Use disposable development credentials, not production keys.
SUPERMEMORY_API_KEY=
OPENAI_API_KEY=
SUPERMEMORY_BASE_URL=
# Optional
MODEL_NAME=gpt-4o-mini
SDK_PLAYGROUND_PYTHON_URL=http://127.0.0.1:8792
SDK_PLAYGROUND_PYTHON_PORT=8792
# Leave unset unless a trusted non-local development hostname must use env keys.
# SDK_PLAYGROUND_ALLOW_ENV_KEYS=true

7
apps/sdk-playground/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.next
.env*
!.env.example
node_modules
python/.venv
next-env.d.ts
*.tsbuildinfo

View file

@ -0,0 +1,94 @@
# SDK Agent Playground
Chat with a **real agent** and switch which Supermemory SDK integration powers it.
> [!WARNING]
> This is a local, single-user development tool. It makes real API calls, stores
> browser-entered keys only in memory unless you opt into tab-scoped
> `sessionStorage`, and exposes tools that can permanently delete documents. Use
> disposable development credentials and a test container; do not deploy it or
> point it at production data.
## Integrations
| SDK | Style | What happens |
|-----|-------|----------------|
| AI SDK + middleware | automatic | `withSupermemory` injects context + saves chat |
| OpenAI + middleware | automatic | same, via OpenAI client wrapper |
| AI SDK + tools | explicit | model calls 7 memory tools via `generateText` |
| OpenAI + tools | explicit | OpenAI function-calling loop |
| `@supermemory/ai-sdk` | explicit | re-export of tools/ai-sdk |
| Python OpenAI middleware | automatic | `with_supermemory` |
| Python OpenAI tools | explicit | `SupermemoryTools` loop |
| Python supermemory direct | manual | `profile()` + OpenAI + `add()` |
## Setup
Prerequisites: Bun 1.3.6, Python 3.11+, and
[`uv`](https://docs.astral.sh/uv/). Portless is required only for the HTTPS
development hostname; the direct localhost commands below work without it.
From the repository root:
```bash
bun install --frozen-lockfile
cp apps/sdk-playground/.env.example apps/sdk-playground/.env.local
# Required:
# SUPERMEMORY_API_KEY=...
# OPENAI_API_KEY=...
```
The playground scripts build `@supermemory/tools` first and
`@supermemory/ai-sdk` second before starting, type-checking, or building the
Next.js app. Development mode also watches both workspace packages.
## Run
```bash
bun run --cwd apps/sdk-playground dev
```
Opens:
- **Chat UI** — https://sdk.dev.supermemory.ai via Portless
- **Next.js server** — http://127.0.0.1:3005
- **Python server** — http://127.0.0.1:8792
To run without Portless, use two terminals:
```bash
bun run --cwd apps/sdk-playground dev:next
bun run --cwd apps/sdk-playground dev:python
```
For a production-mode local smoke check, build first and then start. `start`
runs both the built Next.js app and the Python server, and remains intended for
local use only.
```bash
bun run --cwd apps/sdk-playground build
bun run --cwd apps/sdk-playground start
```
Try:
- "Remember that I prefer oat milk in coffee"
- "What do you know about my drink preferences?"
- "Forget that I like tea" (tools mode)
## Env
| Variable | Required |
|----------|----------|
| `SUPERMEMORY_API_KEY` | yes |
| `OPENAI_API_KEY` | yes |
| `SUPERMEMORY_BASE_URL` | optional |
| `MODEL_NAME` | optional (default `gpt-4o-mini`) |
| `SDK_PLAYGROUND_PYTHON_URL` | optional (default `http://127.0.0.1:8792`) |
| `SDK_PLAYGROUND_PYTHON_PORT` | optional (default `8792`) |
| `SDK_PLAYGROUND_ALLOW_ENV_KEYS` | optional; set `true` only when a trusted non-local hostname must use server env keys |
Server environment keys are exposed to the playground routes only on loopback
hosts and `sdk.dev.supermemory.ai` by default. Browser-provided keys remain
request-scoped and are never copied into process-global environment variables.

View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
transpilePackages: ["@supermemory/tools", "@supermemory/ai-sdk"],
}
export default nextConfig

View file

@ -0,0 +1,39 @@
{
"name": "sdk-playground",
"version": "0.1.0",
"private": true,
"portless": { "name": "sdk.dev.supermemory", "script": "dev:app" },
"scripts": {
"dev": "portless",
"dev:app": "bun run build:dependencies && concurrently -k -n tools,ai-sdk,next,py -c yellow,magenta,blue,green \"bun run --cwd ../../packages/tools dev\" \"bun run --cwd ../../packages/ai-sdk dev\" \"next dev --hostname 127.0.0.1 --port ${PORT:-3005}\" \"bun run dev:python\"",
"dev:next": "bun run build:dependencies && next dev --hostname 127.0.0.1 --port ${PORT:-3005}",
"dev:python": "cd python && uv run server.py",
"build:dependencies": "bun run --cwd ../../packages/tools build && bun run --cwd ../../packages/ai-sdk build",
"build:app": "next build",
"build": "bun run build:dependencies && bun run build:app",
"start": "concurrently -k -n next,py -c blue,green \"next start --hostname 127.0.0.1 --port ${PORT:-3005}\" \"bun run dev:python\"",
"typegen": "next typegen",
"check-types:app": "bun run typegen && tsc --noEmit --incremental false",
"check-types": "bun run build:dependencies && bun run check-types:app"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.22",
"@supermemory/ai-sdk": "workspace:*",
"@supermemory/tools": "workspace:*",
"ai": "^5.0.113",
"next": "16.0.7",
"openai": "^4.104.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"supermemory": "^4.25.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config

View file

@ -0,0 +1,16 @@
[project]
name = "sdk-playground-python"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"httpx>=0.28.0",
"uvicorn[standard]>=0.32.0",
"python-dotenv>=1.0.1",
"supermemory>=3.50.0",
"openai>=1.102.0",
"supermemory-openai-sdk[async]",
]
[tool.uv.sources]
supermemory-openai-sdk = { path = "../../../packages/openai-sdk-python", editable = true }

View file

@ -0,0 +1,885 @@
"""HTTP server for Python SDK chat integrations in the playground."""
import asyncio
import hashlib
import json
import os
import re
import time
from pathlib import Path
from typing import Annotated, Any, Literal, Optional
from urllib.parse import urlparse
from dotenv import load_dotenv
from fastapi import FastAPI, Header, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, SecretStr, model_validator
from starlette.middleware.trustedhost import TrustedHostMiddleware
_root = Path(__file__).resolve().parent
load_dotenv(_root / ".env")
load_dotenv(_root.parent / ".env.local")
load_dotenv(_root.parent / ".env")
DEFAULT_SUPERMEMORY_BASE_URL = "https://api.supermemory.ai"
HTTP_TIMEOUT_SECONDS = 60.0
CHAT_TIMEOUT_SECONDS = 115.0
CONTEXT_DEBUG_TIMEOUT_SECONDS = 10.0
DIRECT_SAVE_TIMEOUT_SECONDS = 10.0
MAX_OUTPUT_TOKENS = 2_048
MAX_MESSAGE_LENGTH = 20_000
MAX_MESSAGES = 64
MAX_TOTAL_MESSAGE_LENGTH = 100_000
MAX_API_KEY_LENGTH = 1_024
MAX_CONTAINER_TAG_LENGTH = 100
MAX_CONVERSATION_ID_LENGTH = 242
CONTAINER_TAG_PATTERN = r"^[a-zA-Z0-9_:-]+$"
TOOLS_SYSTEM_PROMPT = """You are a helpful assistant with Supermemory long-term memory.
You have tools to manage memory. Use them proactively:
- search_memories: hybrid recall search before answering whenever user-specific context could help (do not wait to be asked)
- get_profile: broad static/dynamic user context at conversation start or when you need a wide overview
- add_memory: store a new generalizable fact
- document_list / document_add / document_delete: manage source documents
- memory_forget: soft-delete one profile fact (not whole documents)
Before answering questions about the user, their preferences, or past context, search memories or get profile first. When the user asks you to remember something, use add_memory."""
app = FastAPI(title="SDK Playground Python Chat")
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["127.0.0.1", "localhost"],
)
class ChatMessage(BaseModel):
role: Literal["user", "assistant", "system"]
content: str = Field(max_length=MAX_MESSAGE_LENGTH)
class MiddlewareConfig(BaseModel):
addMemory: Literal["always", "never"] = "always"
verbose: bool = False
class PlaygroundInputError(ValueError):
"""A request value is missing after transport-level validation."""
class SupermemoryApiKeys(BaseModel):
supermemoryApiKey: SecretStr = Field(max_length=MAX_API_KEY_LENGTH)
class ApiKeys(SupermemoryApiKeys):
openaiApiKey: SecretStr = Field(max_length=MAX_API_KEY_LENGTH)
class ChatRequest(BaseModel):
sdkId: Literal[
"py-openai-middleware",
"py-openai-tools",
"py-supermemory-direct",
]
messages: list[ChatMessage] = Field(min_length=1, max_length=MAX_MESSAGES)
containerTag: str = Field(
default="sdk-playground",
min_length=1,
max_length=MAX_CONTAINER_TAG_LENGTH,
pattern=CONTAINER_TAG_PATTERN,
)
conversationId: str = Field(
min_length=1,
max_length=MAX_CONVERSATION_ID_LENGTH,
)
memoryMode: Optional[Literal["profile", "query", "full"]] = "full"
middlewareConfig: Optional[MiddlewareConfig] = None
apiKeys: Optional[ApiKeys] = None
@model_validator(mode="after")
def require_user_message(self) -> "ChatRequest":
if not any(
message.role == "user" and message.content.strip()
for message in self.messages
):
raise ValueError("messages must include a non-empty user message")
if (
sum(len(message.content) for message in self.messages)
> MAX_TOTAL_MESSAGE_LENGTH
):
raise ValueError(
f"total message content cannot exceed {MAX_TOTAL_MESSAGE_LENGTH} characters"
)
return self
class ContextRequest(BaseModel):
containerTag: str = Field(
default="sdk-playground",
min_length=1,
max_length=MAX_CONTAINER_TAG_LENGTH,
pattern=CONTAINER_TAG_PATTERN,
)
query: Optional[str] = Field(default=None, max_length=MAX_MESSAGE_LENGTH)
apiKeys: Optional[SupermemoryApiKeys] = None
def model_name() -> str:
return os.getenv("MODEL_NAME", "gpt-4o-mini")
def supplied_secret(value: Optional[SecretStr], label: str) -> str:
secret = value.get_secret_value().strip() if value else ""
if not secret:
raise PlaygroundInputError(f"{label} must be supplied with the request")
return secret
def resolve_supermemory_key(api_keys: Optional[SupermemoryApiKeys]) -> str:
return supplied_secret(
api_keys.supermemoryApiKey if api_keys else None,
"Supermemory API key",
)
def resolve_chat_keys(api_keys: Optional[ApiKeys]) -> tuple[str, str]:
return (
resolve_supermemory_key(api_keys),
supplied_secret(api_keys.openaiApiKey if api_keys else None, "OpenAI API key"),
)
def supermemory_base_url() -> str:
configured = os.getenv("SUPERMEMORY_BASE_URL", "").strip()
base_url = (configured or DEFAULT_SUPERMEMORY_BASE_URL).rstrip("/")
parsed = urlparse(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise RuntimeError("SUPERMEMORY_BASE_URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise RuntimeError(
"SUPERMEMORY_BASE_URL cannot contain credentials, a query, or a fragment"
)
return base_url
def public_error(error: Exception, *secrets: str) -> str:
message = str(error)
for secret in secrets:
if secret:
message = message.replace(secret, "[redacted]")
return message[:1_000]
async def chat_openai_middleware(
messages: list[ChatMessage],
container_tag: str,
conversation_id: str,
memory_mode: str,
middleware_config: MiddlewareConfig,
sm_key: str,
oai_key: str,
) -> str:
from openai import AsyncOpenAI
from supermemory_openai import OpenAIMiddlewareOptions, with_supermemory
client = with_supermemory(
AsyncOpenAI(
api_key=oai_key,
timeout=HTTP_TIMEOUT_SECONDS,
max_retries=1,
),
OpenAIMiddlewareOptions(
container_tag=container_tag,
custom_id=conversation_id,
mode=memory_mode,
add_memory=middleware_config.addMemory,
verbose=middleware_config.verbose,
api_key=sm_key,
base_url=supermemory_base_url(),
),
)
openai_messages = [m.model_dump() for m in messages]
if not any(m.role == "system" for m in messages):
openai_messages.insert(
0,
{
"role": "system",
"content": (
"You are a helpful assistant with long-term memory about the user."
),
},
)
response = await client.chat.completions.create(
model=model_name(),
messages=openai_messages,
max_completion_tokens=MAX_OUTPUT_TOKENS,
)
return response.choices[0].message.content or ""
async def chat_openai_tools(
messages: list[ChatMessage],
container_tag: str,
sm_key: str,
oai_key: str,
) -> tuple[str, list[dict[str, Any]]]:
from openai import AsyncOpenAI
from supermemory_openai import SupermemoryTools, execute_memory_tool_calls
openai_client = AsyncOpenAI(
api_key=oai_key,
timeout=HTTP_TIMEOUT_SECONDS,
max_retries=1,
)
config: dict[str, Any] = {
"base_url": supermemory_base_url(),
"container_tags": [container_tag],
}
tools = SupermemoryTools(sm_key, config)
tool_defs = tools.get_tool_definitions()
trace: list[dict[str, Any]] = []
convo: list[dict[str, Any]] = [
{"role": "system", "content": TOOLS_SYSTEM_PROMPT},
*[m.model_dump() for m in messages if m.role != "system"],
]
for step in range(8):
response = await openai_client.chat.completions.create(
model=model_name(),
messages=convo,
tools=tool_defs,
max_completion_tokens=MAX_OUTPUT_TOKENS,
)
message = response.choices[0].message
convo.append(message.model_dump())
if message.tool_calls:
tool_messages = await execute_memory_tool_calls(
sm_key,
message.tool_calls,
config,
)
for i, call in enumerate(message.tool_calls):
raw = tool_messages[i]["content"]
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = raw
trace.append(
{
"step": step + 1,
"toolName": call.function.name,
"args": json.loads(call.function.arguments),
"result": parsed,
}
)
convo.extend(tool_messages)
continue
return message.content or "", trace
raise RuntimeError("Tool loop exceeded max steps")
def object_field(value: Any, name: str, default: Any = None) -> Any:
if isinstance(value, dict):
return value.get(name, default)
return getattr(value, name, default)
def list_field(value: Any, name: str) -> list[Any]:
result = object_field(value, name, [])
return result if isinstance(result, list) else []
def extract_profile_context(profile_response: Any) -> dict[str, list[Any]]:
profile = object_field(profile_response, "profile", {}) or {}
search_results = object_field(profile_response, "search_results", None)
if search_results is None and isinstance(profile_response, dict):
search_results = profile_response.get("searchResults")
if isinstance(search_results, list):
search_list = search_results
else:
search_list = list_field(search_results, "results")
return {
"static": list_field(profile, "static"),
"dynamic": list_field(profile, "dynamic"),
"searchResults": search_list,
}
def display_context_item(item: Any) -> str:
if hasattr(item, "model_dump"):
return json.dumps(item.model_dump(mode="json"), ensure_ascii=False)
if isinstance(item, dict):
return json.dumps(item, ensure_ascii=False)
return str(item)
def direct_conversation_custom_id(conversation_id: str) -> str:
readable = re.sub(r"[^A-Za-z0-9._-]+", "-", conversation_id).strip("-._")
readable = readable[:40] or "session"
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:12]
return f"sdk-playground-direct-{readable}-{digest}"
def conversation_transcript(messages: list[ChatMessage], assistant_text: str) -> str:
transcript = [
f"{message.role.capitalize()}: {message.content}"
for message in messages
if message.role != "system"
]
transcript.append(f"Assistant: {assistant_text or '(empty response)'}")
return "\n\n".join(transcript)
async def fetch_profile_context(
container_tag: str,
sm_key: str,
query: Optional[str] = None,
*,
include: Optional[list[str]] = None,
) -> dict[str, list[Any]]:
from supermemory import AsyncSupermemory
client = AsyncSupermemory(
api_key=sm_key,
base_url=supermemory_base_url(),
timeout=HTTP_TIMEOUT_SECONDS,
)
request: dict[str, Any] = {"container_tag": container_tag}
if query:
request["q"] = query
if include is not None:
request["include"] = include
profile_response = await client.profile(**request)
return extract_profile_context(profile_response)
async def chat_supermemory_direct(
messages: list[ChatMessage],
container_tag: str,
conversation_id: str,
sm_key: str,
oai_key: str,
) -> tuple[str, str, dict[str, list[Any]]]:
"""Manual pattern: profile() for context, then OpenAI, then add() conversation."""
from openai import AsyncOpenAI
from supermemory import AsyncSupermemory
sm_client = AsyncSupermemory(
api_key=sm_key,
base_url=supermemory_base_url(),
timeout=HTTP_TIMEOUT_SECONDS,
)
openai_client = AsyncOpenAI(
api_key=oai_key,
timeout=HTTP_TIMEOUT_SECONDS,
max_retries=1,
)
user_messages = [m for m in messages if m.role == "user"]
last_user = user_messages[-1].content if user_messages else ""
profile_response = await sm_client.profile(
container_tag=container_tag,
**({"q": last_user} if last_user else {}),
)
profile_context = extract_profile_context(profile_response)
context = "\n".join(
(
"Profile static: "
+ ", ".join(map(display_context_item, profile_context["static"])),
"Profile dynamic: "
+ ", ".join(map(display_context_item, profile_context["dynamic"])),
"Relevant search results: "
+ ", ".join(map(display_context_item, profile_context["searchResults"])),
)
)
openai_messages: list[dict[str, str]] = [
{
"role": "system",
"content": f"You are a helpful assistant. User context:\n{context}",
},
*[m.model_dump() for m in messages if m.role != "system"],
]
response = await openai_client.chat.completions.create(
model=model_name(),
messages=openai_messages,
max_completion_tokens=MAX_OUTPUT_TOKENS,
)
assistant_text = response.choices[0].message.content or ""
custom_id = direct_conversation_custom_id(conversation_id)
return assistant_text, custom_id, profile_context
async def save_direct_conversation(
messages: list[ChatMessage],
assistant_text: str,
container_tag: str,
custom_id: str,
sm_key: str,
) -> dict[str, Any]:
from supermemory import AsyncSupermemory
try:
client = AsyncSupermemory(
api_key=sm_key,
base_url=supermemory_base_url(),
timeout=DIRECT_SAVE_TIMEOUT_SECONDS,
)
async with asyncio.timeout(DIRECT_SAVE_TIMEOUT_SECONDS):
response = await client.add(
content=conversation_transcript(messages, assistant_text),
container_tag=container_tag,
custom_id=custom_id,
)
return {
"type": "conversation_save_accepted",
"label": "Full conversation accepted for processing",
"detail": {
"nonFatal": True,
"containerTag": container_tag,
"customId": custom_id,
"documentId": object_field(response, "id"),
"status": object_field(response, "status"),
},
}
except Exception as error:
return {
"type": "conversation_save_failed",
"label": "Conversation save unavailable",
"detail": {
"nonFatal": True,
"containerTag": container_tag,
"customId": custom_id,
"error": public_error(error, sm_key),
},
}
async def fetch_container_context(
container_tag: str,
sm_key: str,
query: Optional[str] = None,
) -> dict[str, Any]:
if not sm_key:
raise RuntimeError("Supermemory API key must be supplied")
profile_context = await fetch_profile_context(container_tag, sm_key, query)
base_url = supermemory_base_url()
import httpx
async with httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS,
follow_redirects=False,
) as http:
docs_response = await http.post(
f"{base_url}/v3/documents/documents",
headers={
"Authorization": f"Bearer {sm_key}",
"Content-Type": "application/json",
},
json={
"containerTags": [container_tag],
"limit": 25,
"sort": "createdAt",
"order": "desc",
},
)
docs_response.raise_for_status()
docs = docs_response.json()
raw_documents = docs.get("documents", []) if isinstance(docs, dict) else []
documents = []
for doc in raw_documents:
record = doc if isinstance(doc, dict) else getattr(doc, "__dict__", {})
memory_entries = (
record.get("memoryEntries") or record.get("memory_entries") or []
)
if not memory_entries and isinstance(record.get("memories"), list):
nested = record.get("memories") or []
if nested and isinstance(nested[0], dict) and nested[0].get("memory"):
memory_entries = nested
documents.append(
{
"id": record.get("id"),
"title": record.get("title"),
"status": record.get("status"),
"customId": record.get("customId") or record.get("custom_id"),
"createdAt": record.get("createdAt") or record.get("created_at"),
"updatedAt": record.get("updatedAt") or record.get("updated_at"),
"summary": record.get("summary"),
"memoryEntries": memory_entries,
}
)
return {
"containerTag": container_tag,
"query": query,
"profile": profile_context,
"documents": documents,
"pagination": docs.get("pagination") if isinstance(docs, dict) else None,
}
def reconstruct_python_sdk_memory_block(
memory_mode: str,
profile: dict[str, Any],
) -> tuple[dict[str, list[str]], str]:
from supermemory_openai import convert_profile_to_markdown, deduplicate_memories
from supermemory_openai.utils import wrap_memory_context
deduplicated = deduplicate_memories(
static=profile.get("static", []) if memory_mode != "query" else [],
dynamic=profile.get("dynamic", []) if memory_mode != "query" else [],
search_results=profile.get("searchResults", []),
)
visible_profile = {
"static": deduplicated.static,
"dynamic": deduplicated.dynamic,
"searchResults": (
[] if memory_mode == "profile" else deduplicated.search_results
),
}
profile_data = ""
if memory_mode != "query":
profile_data = convert_profile_to_markdown(
{
"profile": {
"static": visible_profile["static"],
"dynamic": visible_profile["dynamic"],
},
"searchResults": {"results": []},
}
)
search_results_memories = ""
if memory_mode != "profile" and visible_profile["searchResults"]:
search_results_memories = (
"Search results for user's recent message: \n"
+ "\n".join(f"- {memory}" for memory in visible_profile["searchResults"])
)
memories = f"{profile_data}\n{search_results_memories}".strip()
return visible_profile, wrap_memory_context(memories)
def build_middleware_memory_debug(
container_tag: str,
conversation_id: str,
memory_mode: str,
last_user_message: str,
context: Optional[dict[str, Any]],
context_error: Optional[str],
middleware_config: MiddlewareConfig,
) -> list[dict[str, Any]]:
debug: list[dict[str, Any]] = []
if context is None:
debug.append(
{
"type": "context_debug_unavailable",
"label": "Post-response context snapshot unavailable",
"detail": {"error": context_error or "Unknown context error"},
}
)
else:
raw_profile = context["profile"]
profile, memory_block = reconstruct_python_sdk_memory_block(
memory_mode,
raw_profile,
)
debug.extend(
(
{
"type": "profile_fetch",
"label": "Post-response context reconstruction",
"detail": {
"authoritativeMiddlewareCapture": False,
"timing": "after model response",
"endpoint": "POST /v4/profile",
"containerTag": container_tag,
"customId": conversation_id,
"memoryMode": memory_mode,
"query": context.get("query"),
"staticCount": len(profile.get("static", [])),
"dynamicCount": len(profile.get("dynamic", [])),
"searchResultCount": len(profile.get("searchResults", [])),
},
},
{
"type": "context_preview",
"label": (
"Reconstructed SDK-owned memory block "
"(not middleware capture)"
),
"preview": memory_block,
"detail": {
"totalFacts": (
len(profile.get("static", []))
+ len(profile.get("dynamic", []))
+ len(profile.get("searchResults", []))
),
"fullLength": len(memory_block),
},
},
)
)
save_detail = {
"containerTag": container_tag,
"customId": f"conversation:{conversation_id}",
"addMemory": middleware_config.addMemory,
"verbose": middleware_config.verbose,
}
if middleware_config.addMemory == "always" and last_user_message.strip():
debug.append(
{
"type": "conversation_save_queued",
"label": "Conversation save queued by middleware",
"detail": save_detail,
}
)
else:
debug.append(
{
"type": "conversation_save_skipped",
"label": "Conversation save disabled",
"detail": save_detail,
}
)
return debug
async def fetch_context_for_debug(
container_tag: str,
query: Optional[str],
sm_key: str,
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
try:
async with asyncio.timeout(CONTEXT_DEBUG_TIMEOUT_SECONDS):
profile = await fetch_profile_context(
container_tag,
sm_key,
query,
include=["static", "dynamic"],
)
return (
{
"containerTag": container_tag,
"query": query,
"profile": profile,
},
None,
)
except Exception as error:
return None, public_error(error, sm_key)
@app.get("/context")
async def context_get(
containerTag: Annotated[
str,
Query(
min_length=1,
max_length=MAX_CONTAINER_TAG_LENGTH,
pattern=CONTAINER_TAG_PATTERN,
),
] = "sdk-playground",
query: Annotated[Optional[str], Query(max_length=MAX_MESSAGE_LENGTH)] = None,
x_supermemory_api_key: Annotated[
Optional[str],
Header(alias="X-Supermemory-API-Key"),
] = None,
):
sm_key = ""
try:
sm_key = supplied_secret(
SecretStr(x_supermemory_api_key) if x_supermemory_api_key else None,
"X-Supermemory-API-Key header",
)
async with asyncio.timeout(HTTP_TIMEOUT_SECONDS):
ctx = await fetch_container_context(containerTag, sm_key, query)
return {"ok": True, "context": ctx}
except Exception as error:
return JSONResponse(
status_code=(
504
if isinstance(error, TimeoutError)
else 400 if isinstance(error, PlaygroundInputError) else 500
),
content={"ok": False, "error": public_error(error, sm_key)},
)
@app.post("/context")
async def context_post(req: ContextRequest):
sm_key = ""
try:
sm_key = resolve_supermemory_key(req.apiKeys)
async with asyncio.timeout(HTTP_TIMEOUT_SECONDS):
ctx = await fetch_container_context(req.containerTag, sm_key, req.query)
return {"ok": True, "context": ctx}
except Exception as error:
return JSONResponse(
status_code=(
504
if isinstance(error, TimeoutError)
else 400 if isinstance(error, PlaygroundInputError) else 500
),
content={"ok": False, "error": public_error(error, sm_key)},
)
@app.get("/health")
async def health():
return {
"ok": True,
"playground": "sdk-playground",
"requiresRequestKeys": True,
"model": model_name(),
"sdks": [
"py-openai-middleware",
"py-openai-tools",
"py-supermemory-direct",
],
}
@app.post("/chat")
async def chat(req: ChatRequest):
started = time.time()
sm_key = ""
oai_key = ""
try:
sm_key, oai_key = resolve_chat_keys(req.apiKeys)
tool_trace: list[dict[str, Any]] = []
memory_debug: list[dict[str, Any]] = []
middleware_debug: Optional[tuple[MiddlewareConfig, str, Optional[str]]] = None
direct_debug: Optional[tuple[str, dict[str, list[Any]], str]] = None
async with asyncio.timeout(CHAT_TIMEOUT_SECONDS):
if req.sdkId == "py-openai-middleware":
middleware_config = req.middlewareConfig or MiddlewareConfig()
text = await chat_openai_middleware(
req.messages,
req.containerTag,
req.conversationId,
req.memoryMode or "full",
middleware_config,
sm_key,
oai_key,
)
last_user = next(
(m.content for m in reversed(req.messages) if m.role == "user"),
"",
)
query = last_user if req.memoryMode != "profile" else None
middleware_debug = (middleware_config, last_user, query)
elif req.sdkId == "py-openai-tools":
text, tool_trace = await chat_openai_tools(
req.messages, req.containerTag, sm_key, oai_key
)
elif req.sdkId == "py-supermemory-direct":
text, custom_id, profile_context = await chat_supermemory_direct(
req.messages,
req.containerTag,
req.conversationId,
sm_key,
oai_key,
)
last_user = next(
(m.content for m in reversed(req.messages) if m.role == "user"),
"",
)
direct_debug = (custom_id, profile_context, last_user)
else:
raise RuntimeError(f"Unsupported Python SDK: {req.sdkId}")
if middleware_debug is not None:
middleware_config, last_user, query = middleware_debug
ctx, context_error = await fetch_context_for_debug(
req.containerTag,
query,
sm_key,
)
memory_debug = build_middleware_memory_debug(
req.containerTag,
req.conversationId,
req.memoryMode or "full",
last_user,
ctx,
context_error,
middleware_config,
)
elif direct_debug is not None:
custom_id, profile_context, last_user = direct_debug
save_debug = await save_direct_conversation(
req.messages,
text,
req.containerTag,
custom_id,
sm_key,
)
memory_debug = [
{
"type": "manual_profile",
"label": "Profile context used for this response",
"detail": {
"containerTag": req.containerTag,
"query": last_user,
"staticCount": len(profile_context["static"]),
"dynamicCount": len(profile_context["dynamic"]),
"searchResultCount": len(profile_context["searchResults"]),
},
},
save_debug,
]
return {
"ok": True,
"sdkId": req.sdkId,
"message": {"role": "assistant", "content": text},
"toolTrace": tool_trace,
"memoryDebug": memory_debug,
"durationMs": int((time.time() - started) * 1000),
}
except TimeoutError:
return JSONResponse(
status_code=504,
content={
"ok": False,
"sdkId": req.sdkId,
"error": f"Python chat timed out after {int(CHAT_TIMEOUT_SECONDS)} seconds",
"durationMs": int((time.time() - started) * 1000),
},
)
except Exception as error:
return JSONResponse(
status_code=400 if isinstance(error, PlaygroundInputError) else 500,
content={
"ok": False,
"sdkId": req.sdkId,
"error": public_error(error, sm_key, oai_key),
"durationMs": int((time.time() - started) * 1000),
},
)
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("SDK_PLAYGROUND_PYTHON_PORT", "8792"))
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")

1557
apps/sdk-playground/python/uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
import { NextResponse } from "next/server"
import { CHAT_SDK_REGISTRY, PYTHON_SERVER_URL } from "@/lib/sdk-registry"
import {
resolveApiKeys,
resolveOpenAiApiKey,
resolveSupermemoryApiKey,
} from "@/lib/api-keys"
import {
PlaygroundChatTimeoutError,
runTypeScriptChat,
} from "@/lib/chat-handlers"
import {
PlaygroundRequestError,
assertTrustedBrowserRequest,
mayUseEnvironmentKeys,
parseApiKeys,
parseContainerTag,
parseConversationId,
parseIdentifier,
parseMemoryMode,
parseMessages,
parseMiddlewareConfig,
readJsonObject,
} from "@/lib/request-validation"
const PYTHON_HEALTH_TIMEOUT_MS = 2_000
// Python reserves 115s for the model/tool path and up to 10s for nonfatal debug.
const PYTHON_CHAT_TIMEOUT_MS = 130_000
export async function GET(request: Request) {
let pythonOk = false
try {
const res = await fetch(`${PYTHON_SERVER_URL}/health`, {
cache: "no-store",
signal: AbortSignal.timeout(PYTHON_HEALTH_TIMEOUT_MS),
})
if (res.ok) {
const data = await res.json()
pythonOk = data.playground === "sdk-playground"
}
} catch {
pythonOk = false
}
const allowEnvironment = mayUseEnvironmentKeys(request)
return NextResponse.json({
sdks: CHAT_SDK_REGISTRY,
hasSupermemoryKey: Boolean(
resolveSupermemoryApiKey(null, { allowEnvironment }),
),
hasOpenAiKey: Boolean(resolveOpenAiApiKey(null, { allowEnvironment })),
pythonUrl: PYTHON_SERVER_URL,
model: process.env.MODEL_NAME ?? "gpt-4o-mini",
pythonOk,
})
}
export async function POST(req: Request) {
const started = Date.now()
try {
assertTrustedBrowserRequest(req)
const body = await readJsonObject(req)
const sdkId = parseIdentifier(body.sdkId, "sdkId")
const messages = parseMessages(body.messages)
const containerTag = parseContainerTag(body.containerTag)
const conversationId = parseConversationId(body.conversationId)
const memoryMode = parseMemoryMode(body.memoryMode)
const middlewareConfig = parseMiddlewareConfig(body.middlewareConfig)
const apiKeys = resolveApiKeys(parseApiKeys(body.apiKeys), {
allowEnvironment: mayUseEnvironmentKeys(req),
})
if (!apiKeys) {
return NextResponse.json(
{
ok: false,
error:
"Supermemory and OpenAI API keys are required — enter them in the dashboard or set env vars.",
},
{ status: 400 },
)
}
const sdk = CHAT_SDK_REGISTRY.find((s) => s.id === sdkId)
if (!sdk?.available) {
return NextResponse.json(
{ ok: false, error: `SDK not available: ${sdkId}` },
{ status: 400 },
)
}
if (sdk.language === "python") {
const res = await fetch(`${PYTHON_SERVER_URL}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sdkId,
messages,
containerTag,
conversationId,
memoryMode,
middlewareConfig,
apiKeys,
}),
signal: AbortSignal.timeout(PYTHON_CHAT_TIMEOUT_MS),
})
const data = await res.json()
if (!res.ok && !data.error) {
return NextResponse.json(
{
ok: false,
error: `Python server error (${res.status})`,
durationMs: Date.now() - started,
},
{ status: res.status },
)
}
return NextResponse.json(
{
...data,
durationMs: Date.now() - started,
},
{ status: res.ok ? 200 : res.status },
)
}
const result = await runTypeScriptChat(
{
sdkId,
messages,
containerTag,
conversationId,
memoryMode,
middlewareConfig,
apiKeys,
containerTags: [containerTag],
},
apiKeys,
)
return NextResponse.json({
ok: true,
sdkId,
message: { role: "assistant", content: result.text },
toolTrace: result.toolTrace,
memoryDebug: result.memoryDebug,
durationMs: Date.now() - started,
})
} catch (error) {
const status =
error instanceof PlaygroundRequestError
? error.status
: error instanceof PlaygroundChatTimeoutError ||
(error instanceof Error && error.name === "TimeoutError")
? 504
: 500
return NextResponse.json(
{
ok: false,
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
},
{ status },
)
}
}

View file

@ -0,0 +1,90 @@
import { NextResponse } from "next/server"
import { resolveSupermemoryApiKey } from "@/lib/api-keys"
import { fetchContainerContext } from "@/lib/context-api"
import {
PlaygroundRequestError,
assertTrustedBrowserRequest,
mayUseEnvironmentKeys,
parseApiKeys,
parseContainerTag,
parseOptionalText,
readJsonObject,
} from "@/lib/request-validation"
export async function GET(req: Request) {
try {
assertTrustedBrowserRequest(req)
const { searchParams } = new URL(req.url)
const containerTag = parseContainerTag(searchParams.get("containerTag"))
const query = parseOptionalText(searchParams.get("query"), "query")
const supermemoryApiKey = resolveSupermemoryApiKey(null, {
allowEnvironment: mayUseEnvironmentKeys(req),
})
if (!supermemoryApiKey) {
return NextResponse.json(
{
ok: false,
error:
"Supermemory API key is required — enter it in the dashboard or set a local env var.",
},
{ status: 400 },
)
}
const context = await fetchContainerContext(
containerTag,
query,
supermemoryApiKey,
)
return NextResponse.json({ ok: true, context })
} catch (error) {
const status = error instanceof PlaygroundRequestError ? error.status : 500
return NextResponse.json(
{
ok: false,
error: error instanceof Error ? error.message : String(error),
},
{ status },
)
}
}
export async function POST(req: Request) {
try {
assertTrustedBrowserRequest(req)
const body = await readJsonObject(req)
const containerTag = parseContainerTag(body.containerTag)
const query = parseOptionalText(body.query, "query")
const supermemoryApiKey = resolveSupermemoryApiKey(
parseApiKeys(body.apiKeys),
{ allowEnvironment: mayUseEnvironmentKeys(req) },
)
if (!supermemoryApiKey) {
return NextResponse.json(
{
ok: false,
error: "Supermemory API key is required — enter it in the dashboard.",
},
{ status: 400 },
)
}
const context = await fetchContainerContext(
containerTag,
query,
supermemoryApiKey,
)
return NextResponse.json({ ok: true, context })
} catch (error) {
const status = error instanceof PlaygroundRequestError ? error.status : 500
return NextResponse.json(
{
ok: false,
error: error instanceof Error ? error.message : String(error),
},
{ status },
)
}
}

View file

@ -0,0 +1,18 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
body {
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
textarea,
select,
input,
button {
font: inherit;
}

View file

@ -0,0 +1,19 @@
import type { Metadata } from "next"
import "./globals.css"
export const metadata: Metadata = {
title: "Supermemory SDK Playground",
description: "Switch and test Supermemory SDKs across languages",
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
{children}
</body>
</html>
)
}

View file

@ -0,0 +1,623 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { ApiKeysPanel } from "@/components/ApiKeysPanel"
import { ContextPanel } from "@/components/ContextPanel"
import { ToolsReferencePanel } from "@/components/ToolsReferencePanel"
import type { MemoryDebugEntry } from "@/lib/context-api"
import {
DEFAULT_MIDDLEWARE_CONFIG,
type MiddlewareRuntimeConfig,
} from "@/lib/middleware-config"
import {
CHAT_SDK_REGISTRY,
type ChatSdkDefinition,
type ToolTraceEntry,
} from "@/lib/sdk-registry"
type UserOrAssistantMessage = {
kind: "user" | "assistant"
content: string
}
type ToolMessage = {
kind: "tool"
entry: ToolTraceEntry
}
type DebugMessage = {
kind: "debug"
entry: MemoryDebugEntry
}
type DisplayMessage = UserOrAssistantMessage | ToolMessage | DebugMessage
export default function AgentPlaygroundPage() {
const [sdks, setSdks] = useState(CHAT_SDK_REGISTRY)
const [hasSupermemoryKey, setHasSupermemoryKey] = useState(false)
const [hasOpenAiKey, setHasOpenAiKey] = useState(false)
const [pythonOk, setPythonOk] = useState(false)
const [model, setModel] = useState("gpt-4o-mini")
const [pythonUrl, setPythonUrl] = useState("http://127.0.0.1:8792")
const [supermemoryApiKey, setSupermemoryApiKey] = useState("")
const [openaiApiKey, setOpenaiApiKey] = useState("")
const apiKeys = useMemo(
() => ({ supermemoryApiKey, openaiApiKey }),
[supermemoryApiKey, openaiApiKey],
)
const supermemoryKeyReady =
supermemoryApiKey.trim().length > 0 || hasSupermemoryKey
const openAiKeyReady = openaiApiKey.trim().length > 0 || hasOpenAiKey
const keysReady = supermemoryKeyReady && openAiKeyReady
const [sdkId, setSdkId] = useState("ts-ai-sdk-middleware")
const [containerTag, setContainerTag] = useState("sdk-playground")
const [memoryMode, setMemoryMode] = useState<"profile" | "query" | "full">(
"full",
)
const [conversationId, setConversationId] = useState("")
const [middlewareConfig, setMiddlewareConfig] =
useState<MiddlewareRuntimeConfig>(DEFAULT_MIDDLEWARE_CONFIG)
const [messages, setMessages] = useState<DisplayMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [contextRefreshKey, setContextRefreshKey] = useState(0)
const [leftPanel, setLeftPanel] = useState<"sdks" | "tools">("sdks")
const lastUserMessage = useMemo(() => {
const users = messages.filter(
(m): m is UserOrAssistantMessage => m.kind === "user",
)
return users.at(-1)?.content ?? ""
}, [messages])
const selectedSdk = useMemo(
() => sdks.find((s) => s.id === sdkId),
[sdks, sdkId],
)
useEffect(() => {
setConversationId((current) => current || crypto.randomUUID())
}, [])
const refreshMeta = useCallback(async () => {
try {
const res = await fetch("/api/chat")
const data = await res.json()
if (data.sdks) setSdks(data.sdks)
setHasSupermemoryKey(Boolean(data.hasSupermemoryKey))
setHasOpenAiKey(Boolean(data.hasOpenAiKey))
setPythonOk(Boolean(data.pythonOk))
if (data.pythonUrl) setPythonUrl(data.pythonUrl)
if (data.model) setModel(data.model)
} catch {
/* ignore */
}
}, [])
useEffect(() => {
refreshMeta()
}, [refreshMeta])
const send = async () => {
if (!input.trim() || loading || !selectedSdk?.available || !keysReady)
return
const activeConversationId = conversationId.trim() || crypto.randomUUID()
if (!conversationId.trim()) setConversationId(activeConversationId)
const userMessage: UserOrAssistantMessage = {
kind: "user",
content: input.trim(),
}
const chatHistory = messages
.filter(
(m): m is UserOrAssistantMessage =>
m.kind === "user" || m.kind === "assistant",
)
.map((m) => ({ role: m.kind, content: m.content }))
const nextMessages = [...messages, userMessage]
setMessages(nextMessages)
setInput("")
setLoading(true)
setError(null)
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sdkId,
messages: [
...chatHistory,
{ role: "user", content: userMessage.content },
],
containerTag,
conversationId: activeConversationId,
memoryMode:
selectedSdk.mode === "middleware" ? memoryMode : undefined,
middlewareConfig:
selectedSdk.mode === "middleware" ? middlewareConfig : undefined,
apiKeys,
}),
})
const data = await res.json()
if (!data.ok) {
throw new Error(data.error ?? "Chat failed")
}
const content = data.message?.content ?? ""
const toolTrace = (data.toolTrace ?? []) as ToolTraceEntry[]
const memoryDebug = (data.memoryDebug ?? []) as MemoryDebugEntry[]
setMessages((prev) => [
...prev,
...memoryDebug.map((entry) => ({ kind: "debug" as const, entry })),
...toolTrace.map((entry) => ({ kind: "tool" as const, entry })),
{ kind: "assistant", content },
])
setContextRefreshKey((k) => k + 1)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
const tsSdks = sdks.filter((s) => s.language === "typescript")
const pySdks = sdks.filter((s) => s.language === "python")
return (
<div className="mx-auto flex h-screen max-w-[1400px] flex-col p-4 md:p-5">
<header className="mb-4 shrink-0 space-y-3 border-b border-zinc-800 pb-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">
Supermemory Agent Playground
</h1>
<p className="text-sm text-zinc-400">
Talk to a real agent. Switch the underlying SDK integration in the
sidebar middleware auto-injects memory; tools let the model call
memory operations explicitly.
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<Status ok={supermemoryKeyReady} label="Supermemory key" />
<Status ok={openAiKeyReady} label="OpenAI key" />
<Status
ok={pythonOk}
label={`Python ${pythonUrl.replace("http://", "")}`}
/>
<span className="rounded-full border border-zinc-700 px-3 py-1 text-zinc-400">
model: {model}
</span>
</div>
<ApiKeysPanel
supermemoryApiKey={supermemoryApiKey}
openaiApiKey={openaiApiKey}
hasSupermemoryEnvKey={hasSupermemoryKey}
hasOpenAiEnvKey={hasOpenAiKey}
onSupermemoryChange={setSupermemoryApiKey}
onOpenAiChange={setOpenaiApiKey}
/>
</header>
<div className="flex min-h-0 flex-1 gap-3 lg:gap-4">
<aside className="hidden w-60 shrink-0 min-h-0 lg:flex lg:flex-col">
<div className="mb-2 flex gap-1 shrink-0">
<SidebarTab
active={leftPanel === "sdks"}
onClick={() => setLeftPanel("sdks")}
>
SDKs
</SidebarTab>
<SidebarTab
active={leftPanel === "tools"}
onClick={() => setLeftPanel("tools")}
>
Tools
</SidebarTab>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{leftPanel === "sdks" ? (
<div className="space-y-4">
<Section title="TypeScript">
<SdkButtons
sdks={tsSdks}
selected={sdkId}
onSelect={setSdkId}
/>
</Section>
<Section title="Python">
<SdkButtons
sdks={pySdks}
selected={sdkId}
onSelect={setSdkId}
/>
</Section>
</div>
) : (
<ToolsReferencePanel />
)}
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex gap-1 shrink-0 lg:hidden">
<SidebarTab
active={leftPanel === "sdks"}
onClick={() => setLeftPanel("sdks")}
>
Chat
</SidebarTab>
<SidebarTab
active={leftPanel === "tools"}
onClick={() => setLeftPanel("tools")}
>
Tool reference
</SidebarTab>
</div>
{leftPanel === "tools" && (
<div className="max-h-72 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/20 p-3 lg:hidden">
<ToolsReferencePanel />
</div>
)}
{leftPanel === "sdks" && (
<div className="flex flex-wrap items-end gap-3 md:hidden">
<label className="flex-1 space-y-1">
<span className="text-xs text-zinc-500">SDK</span>
<select
value={sdkId}
onChange={(e) => setSdkId(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm"
>
{sdks.map((s) => (
<option key={s.id} value={s.id} disabled={!s.available}>
{s.label}
</option>
))}
</select>
</label>
</div>
)}
{selectedSdk && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-sm">
<div className="font-medium">{selectedSdk.label}</div>
<div className="text-zinc-400">{selectedSdk.description}</div>
<div className="mt-1 text-xs text-zinc-500">
{selectedSdk.package} ·{" "}
<span className="text-zinc-400">
{selectedSdk.mode === "middleware"
? "automatic memory"
: selectedSdk.mode === "tools"
? "explicit tools"
: "manual profile + save"}
</span>
</div>
</div>
)}
<div className="flex flex-wrap gap-3">
<label className="space-y-1">
<span className="text-xs text-zinc-500">Container tag</span>
<input
value={containerTag}
onChange={(e) => setContainerTag(e.target.value)}
maxLength={100}
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm w-40"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-zinc-500">customId (session)</span>
<input
value={conversationId}
onChange={(e) => setConversationId(e.target.value)}
maxLength={242}
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-mono w-52"
/>
</label>
{selectedSdk?.mode === "middleware" && (
<>
<label className="space-y-1">
<span className="text-xs text-zinc-500">Memory mode</span>
<select
value={memoryMode}
onChange={(e) =>
setMemoryMode(
e.target.value as "profile" | "query" | "full",
)
}
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm"
>
<option value="profile">profile</option>
<option value="query">query</option>
<option value="full">full</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-zinc-500">addMemory</span>
<select
value={middlewareConfig.addMemory}
onChange={(e) =>
setMiddlewareConfig((prev) => ({
...prev,
addMemory: e.target.value as "always" | "never",
}))
}
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm"
>
<option value="always">always</option>
<option value="never">never</option>
</select>
</label>
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
<input
type="checkbox"
checked={middlewareConfig.verbose}
onChange={(e) =>
setMiddlewareConfig((prev) => ({
...prev,
verbose: e.target.checked,
}))
}
/>
verbose
</label>
{selectedSdk.id === "ts-ai-sdk-middleware" && (
<>
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
<input
type="checkbox"
checked={middlewareConfig.includeToolCalls}
onChange={(e) =>
setMiddlewareConfig((prev) => ({
...prev,
includeToolCalls: e.target.checked,
}))
}
/>
includeToolCalls
</label>
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
<input
type="checkbox"
checked={middlewareConfig.skipMemoryOnError}
onChange={(e) =>
setMiddlewareConfig((prev) => ({
...prev,
skipMemoryOnError: e.target.checked,
}))
}
/>
skipMemoryOnError
</label>
</>
)}
</>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 space-y-3">
{messages.length === 0 && (
<p className="text-center text-sm text-zinc-500 py-8">
Say hi try &quot;Remember that I prefer oat milk&quot; or
&quot;What do you know about me?&quot;
</p>
)}
{messages.map((m, i) => {
if (m.kind === "debug") {
return (
<div key={i} className="flex justify-start">
<div className="max-w-[92%] rounded-lg border border-violet-900/50 bg-violet-950/25 px-3 py-2 text-xs">
<div className="font-medium text-violet-300 mb-1">
Debug · {m.entry.label}
</div>
{m.entry.detail && (
<pre className="font-mono text-violet-100/70 whitespace-pre-wrap break-all mb-2">
{JSON.stringify(m.entry.detail, null, 2)}
</pre>
)}
{m.entry.preview && (
<pre className="font-mono text-zinc-300 whitespace-pre-wrap break-all border-t border-violet-900/40 pt-2 mt-1">
{m.entry.preview}
</pre>
)}
</div>
</div>
)
}
if (m.kind === "tool") {
return (
<div key={i} className="flex justify-start">
<div className="max-w-[90%] rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs font-mono text-amber-100/90">
<div className="font-sans text-amber-400 font-medium mb-1">
Tool · step {m.entry.step} · {m.entry.toolName}
</div>
<div className="text-zinc-400">args</div>
<pre className="whitespace-pre-wrap break-all mb-2">
{JSON.stringify(m.entry.args, null, 2)}
</pre>
{m.entry.result !== undefined && (
<>
<div className="text-zinc-400">result</div>
<pre className="whitespace-pre-wrap break-all">
{JSON.stringify(m.entry.result, null, 2)}
</pre>
</>
)}
</div>
</div>
)
}
return (
<div
key={i}
className={
m.kind === "user"
? "flex justify-end"
: "flex justify-start"
}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm leading-relaxed ${
m.kind === "user"
? "bg-emerald-700 text-white"
: "bg-zinc-800 text-zinc-100"
}`}
>
{m.content}
</div>
</div>
)
})}
{loading && (
<div className="text-sm text-zinc-500 animate-pulse">
Thinking
</div>
)}
</div>
{error && (
<div className="rounded-md border border-red-900 bg-red-950/50 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="flex gap-2 shrink-0">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}}
disabled={loading || !selectedSdk?.available || !keysReady}
placeholder={
keysReady
? "Message the agent…"
: "Enter API keys above to chat…"
}
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-600/50 disabled:opacity-50"
/>
<button
type="button"
onClick={send}
disabled={
loading ||
!input.trim() ||
!selectedSdk?.available ||
!keysReady
}
className="rounded-lg bg-emerald-600 px-5 py-3 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-40"
>
Send
</button>
</div>
</div>
<aside className="hidden w-80 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/20 p-3 xl:flex xl:w-96 xl:flex-col min-h-0">
<ContextPanel
containerTag={containerTag}
lastUserMessage={lastUserMessage}
refreshKey={contextRefreshKey}
supermemoryApiKey={supermemoryApiKey}
supermemoryKeyReady={supermemoryKeyReady}
/>
</aside>
</div>
</div>
)
}
function Status({ ok, label }: { ok: boolean; label: string }) {
return (
<span
className={`rounded-full border px-3 py-1 ${
ok
? "border-emerald-800 text-emerald-400"
: "border-zinc-700 text-zinc-500"
}`}
>
{label}
</span>
)
}
function Section({
title,
children,
}: {
title: string
children: React.ReactNode
}) {
return (
<div>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">
{title}
</h2>
{children}
</div>
)
}
function SdkButtons({
sdks,
selected,
onSelect,
}: {
sdks: ChatSdkDefinition[]
selected: string
onSelect: (id: string) => void
}) {
return (
<ul className="space-y-1">
{sdks.map((sdk) => (
<li key={sdk.id}>
<button
type="button"
disabled={!sdk.available}
onClick={() => onSelect(sdk.id)}
className={`w-full rounded-md px-2 py-2 text-left text-sm transition-colors ${
selected === sdk.id
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200"
} ${!sdk.available ? "opacity-40 cursor-not-allowed" : ""}`}
>
<div>{sdk.label}</div>
<div className="text-[10px] text-zinc-500 capitalize">
{sdk.mode}
</div>
</button>
</li>
))}
</ul>
)
}
function SidebarTab({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
active
? "bg-zinc-800 text-white"
: "text-zinc-500 hover:bg-zinc-900 hover:text-zinc-300"
}`}
>
{children}
</button>
)
}

View file

@ -0,0 +1,140 @@
"use client"
import { useEffect, useState } from "react"
import {
clearStoredApiKeys,
readStoredApiKeys,
storeApiKeys,
} from "@/lib/api-keys"
export function ApiKeysPanel({
supermemoryApiKey,
openaiApiKey,
hasSupermemoryEnvKey,
hasOpenAiEnvKey,
onSupermemoryChange,
onOpenAiChange,
}: {
supermemoryApiKey: string
openaiApiKey: string
hasSupermemoryEnvKey: boolean
hasOpenAiEnvKey: boolean
onSupermemoryChange: (value: string) => void
onOpenAiChange: (value: string) => void
}) {
const [open, setOpen] = useState(false)
const [storageInitialized, setStorageInitialized] = useState(false)
const [rememberKeys, setRememberKeys] = useState(false)
useEffect(() => {
const stored = readStoredApiKeys()
const hasStoredKeys = Boolean(
stored.supermemoryApiKey || stored.openaiApiKey,
)
if (stored.supermemoryApiKey) onSupermemoryChange(stored.supermemoryApiKey)
if (stored.openaiApiKey) onOpenAiChange(stored.openaiApiKey)
setRememberKeys(hasStoredKeys)
setStorageInitialized(true)
}, [onSupermemoryChange, onOpenAiChange])
useEffect(() => {
if (!storageInitialized) return
if (rememberKeys) {
storeApiKeys({ supermemoryApiKey, openaiApiKey })
} else {
clearStoredApiKeys()
}
}, [storageInitialized, rememberKeys, supermemoryApiKey, openaiApiKey])
const supermemoryReady =
supermemoryApiKey.trim().length > 0 || hasSupermemoryEnvKey
const openAiReady = openaiApiKey.trim().length > 0 || hasOpenAiEnvKey
const ready = supermemoryReady && openAiReady
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm"
>
<span className="font-medium text-zinc-200">API keys</span>
<span className="flex items-center gap-2 text-xs">
<span className={ready ? "text-emerald-400" : "text-amber-400"}>
{ready ? "configured" : "required for chat"}
</span>
<span className="text-zinc-600">{open ? "" : "+"}</span>
</span>
</button>
{open && (
<div className="space-y-3 border-t border-zinc-800 px-3 pb-3 pt-2">
<p className="rounded border border-amber-900/60 bg-amber-950/30 px-2 py-1.5 text-[11px] leading-snug text-amber-200/80">
Use disposable development or test keys onlynever production
credentials. Keys stay in this page only unless you explicitly
enable tab-scoped storage below. Server env vars remain available as
fallbacks.
</p>
<label className="block space-y-1">
<span className="flex items-center justify-between gap-2 text-xs text-zinc-500">
<span>Supermemory API key</span>
{hasSupermemoryEnvKey && !supermemoryApiKey.trim() && (
<span className="text-emerald-500">using server env</span>
)}
</span>
<input
type="password"
value={supermemoryApiKey}
onChange={(e) => onSupermemoryChange(e.target.value)}
placeholder={
hasSupermemoryEnvKey ? "Optional browser override" : "sm_…"
}
className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-sm font-mono"
/>
</label>
<label className="block space-y-1">
<span className="flex items-center justify-between gap-2 text-xs text-zinc-500">
<span>OpenAI API key</span>
{hasOpenAiEnvKey && !openaiApiKey.trim() && (
<span className="text-emerald-500">using server env</span>
)}
</span>
<input
type="password"
value={openaiApiKey}
onChange={(e) => onOpenAiChange(e.target.value)}
placeholder={
hasOpenAiEnvKey ? "Optional browser override" : "sk-…"
}
className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-sm font-mono"
/>
</label>
<label className="flex items-start gap-2 text-[11px] leading-snug text-zinc-400">
<input
type="checkbox"
checked={rememberKeys}
onChange={(event) => setRememberKeys(event.target.checked)}
className="mt-0.5"
/>
<span>
Remember these keys for this tab using sessionStorage. They are
cleared when the tab closes; only enable this on a trusted
profile.
</span>
</label>
<button
type="button"
onClick={() => {
onSupermemoryChange("")
onOpenAiChange("")
setRememberKeys(false)
clearStoredApiKeys()
}}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Clear entered keys
</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,350 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { ContainerContext } from "@/lib/context-api"
type ContextDocument = ContainerContext["documents"][number]
export function ContextPanel({
containerTag,
lastUserMessage,
refreshKey,
supermemoryApiKey,
supermemoryKeyReady,
}: {
containerTag: string
lastUserMessage?: string
refreshKey: number
supermemoryApiKey: string
supermemoryKeyReady: boolean
}) {
const [context, setContext] = useState<ContainerContext | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [useQuery, setUseQuery] = useState(false)
const [selectedDocKey, setSelectedDocKey] = useState<string | null>(null)
const activeRequest = useRef<AbortController | null>(null)
const load = useCallback(async () => {
if (!supermemoryKeyReady) {
setError("Enter a Supermemory API key or set SUPERMEMORY_API_KEY")
setContext(null)
return
}
const normalizedContainerTag = containerTag.trim()
if (!normalizedContainerTag) {
setError("Enter a container tag to load context")
setContext(null)
return
}
activeRequest.current?.abort()
const controller = new AbortController()
activeRequest.current = controller
setLoading(true)
setError(null)
try {
const res = await fetch("/api/context", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
containerTag: normalizedContainerTag,
...(useQuery && lastUserMessage ? { query: lastUserMessage } : {}),
...(supermemoryApiKey.trim()
? {
apiKeys: {
supermemoryApiKey: supermemoryApiKey.trim(),
},
}
: {}),
}),
})
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Failed to load context")
if (controller.signal.aborted) return
setContext(data.context)
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return
setError(err instanceof Error ? err.message : String(err))
setContext(null)
} finally {
if (activeRequest.current === controller) {
activeRequest.current = null
setLoading(false)
}
}
}, [
containerTag,
lastUserMessage,
useQuery,
supermemoryApiKey,
supermemoryKeyReady,
])
useEffect(() => {
// The counter changes after a successful chat and explicitly refreshes context.
void refreshKey
if (!supermemoryKeyReady || !containerTag.trim()) {
activeRequest.current?.abort()
activeRequest.current = null
setLoading(false)
setContext(null)
setError(null)
return
}
const timeout = window.setTimeout(() => {
void load()
}, 500)
return () => {
window.clearTimeout(timeout)
activeRequest.current?.abort()
}
}, [load, refreshKey, supermemoryKeyReady, containerTag])
useEffect(() => {
// A different container must not retain the previous document selection.
void containerTag
setSelectedDocKey(null)
}, [containerTag])
const selectedDoc =
context?.documents.find((doc) => documentKey(doc) === selectedDocKey) ??
null
return (
<div className="flex min-h-0 flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">
Container context
</h2>
<button
type="button"
onClick={() => void load()}
disabled={!supermemoryKeyReady || !containerTag.trim()}
className="text-xs text-zinc-400 hover:text-zinc-200 disabled:cursor-not-allowed disabled:text-zinc-700"
>
Refresh
</button>
</div>
<label className="flex items-center gap-2 text-xs text-zinc-400">
<input
type="checkbox"
checked={useQuery}
onChange={(e) => setUseQuery(e.target.checked)}
/>
Profile with last message as query
</label>
{loading && <p className="text-xs text-zinc-500">Loading</p>}
{!supermemoryKeyReady && (
<p className="text-xs text-zinc-500">
Enter a Supermemory key or set SUPERMEMORY_API_KEY to load context.
</p>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
{context && (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<section>
<h3 className="mb-2 text-xs font-medium text-zinc-400">
Profile · {context.profile.static.length} static ·{" "}
{context.profile.dynamic.length} dynamic ·{" "}
{context.profile.searchResults.length} search
</h3>
<div className="space-y-2">
<MemoryList title="Static" items={context.profile.static} />
<MemoryList title="Dynamic" items={context.profile.dynamic} />
{context.profile.searchResults.length > 0 && (
<MemoryList
title="Search results"
items={context.profile.searchResults}
/>
)}
</div>
</section>
<section className="min-h-0">
<h3 className="mb-2 text-xs font-medium text-zinc-400">
Documents / sessions ({context.documents.length})
</h3>
{context.documents.length === 0 ? (
<p className="text-xs text-zinc-500">No documents yet</p>
) : (
<div className="flex min-h-0 gap-2">
<ul className="min-w-0 flex-1 space-y-2">
{context.documents.map((doc) => {
const key = documentKey(doc)
const isSelected = key === selectedDocKey
const memoryCount = doc.memoryEntries?.length ?? 0
return (
<li key={key}>
<button
type="button"
onClick={() =>
setSelectedDocKey(isSelected ? null : key)
}
className={`w-full rounded border p-2 text-left text-xs transition-colors ${
isSelected
? "border-emerald-700/60 bg-emerald-950/30"
: "border-zinc-800 bg-zinc-900/50 hover:border-zinc-700"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 font-mono text-zinc-300 truncate">
{doc.id ?? "—"}
</div>
<span
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
memoryCount > 0
? "bg-emerald-900/50 text-emerald-300"
: "bg-zinc-800 text-zinc-500"
}`}
>
{memoryCount}
</span>
</div>
<div className="text-zinc-500 truncate">
{doc.title ?? "untitled"}
</div>
<div className="text-zinc-600">
{doc.customId
? `session: ${doc.customId}`
: "no customId"}
{doc.status ? ` · ${doc.status}` : ""}
</div>
</button>
</li>
)
})}
</ul>
{selectedDoc && (
<div className="min-w-0 flex-1 border-l border-zinc-800 pl-2">
<DocumentMemoriesPanel doc={selectedDoc} />
</div>
)}
</div>
)}
{context.documents.length > 0 && !selectedDoc && (
<p className="mt-2 text-[10px] text-zinc-600">
Click a document to view its memories
</p>
)}
</section>
</div>
)}
</div>
)
}
function DocumentMemoriesPanel({ doc }: { doc: ContextDocument }) {
const entries = doc.memoryEntries ?? []
return (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-zinc-600">
Document memories ({entries.length})
</div>
<div className="text-xs text-zinc-500 truncate">
{doc.title ?? "untitled"}
</div>
{doc.summary && (
<p className="text-[11px] leading-snug text-zinc-500 line-clamp-3">
{doc.summary}
</p>
)}
{entries.length === 0 ? (
<p className="text-xs text-zinc-600">No memories on this document</p>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
{entries.map((entry, i) => (
<li
key={memoryEntryKey(entry, i)}
className="rounded border border-zinc-800 bg-zinc-900/60 p-2"
>
<MemoryEntryCard entry={entry} />
</li>
))}
</ul>
)}
</div>
)
}
function MemoryEntryCard({ entry }: { entry: unknown }) {
const record =
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: null
const memoryText = formatMemoryItem(entry)
const id = record?.id as string | undefined
const version = record?.version as number | undefined
const isForgotten = Boolean(record?.isForgotten)
const isStatic = Boolean(record?.isStatic)
const forgetAfter = record?.forgetAfter as string | undefined
return (
<div className="space-y-1">
{memoryText && (
<p className="text-xs leading-snug text-zinc-300">{memoryText}</p>
)}
<div className="flex flex-wrap gap-1 text-[10px] text-zinc-600">
{id && <span className="font-mono truncate max-w-full">{id}</span>}
{version != null && <span>v{version}</span>}
{isStatic && <span className="text-sky-500">static</span>}
{isForgotten && <span className="text-amber-500">forgotten</span>}
{forgetAfter && !isForgotten && (
<span className="text-orange-500">expires</span>
)}
</div>
</div>
)
}
function documentKey(doc: ContextDocument): string {
return doc.id ?? doc.customId ?? doc.title ?? "unknown"
}
function memoryEntryKey(entry: unknown, index: number): string {
if (entry && typeof entry === "object" && "id" in entry) {
return String((entry as { id: unknown }).id)
}
return `memory-${index}`
}
function MemoryList({ title, items }: { title: string; items: unknown[] }) {
if (!items.length) return null
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-zinc-600 mb-1">
{title}
</div>
<ul className="space-y-1">
{items.slice(0, 12).map((item, i) => (
<li
key={i}
className="rounded bg-zinc-900/60 px-2 py-1 text-xs text-zinc-300 leading-snug"
>
{formatMemoryItem(item)}
</li>
))}
</ul>
</div>
)
}
function formatMemoryItem(item: unknown): string {
if (typeof item === "string") return item
if (item && typeof item === "object") {
const record = item as Record<string, unknown>
if (typeof record.memory === "string") return record.memory
if (typeof record.content === "string") return record.content
if (typeof record.chunk === "string") return record.chunk
}
return JSON.stringify(item)
}

View file

@ -0,0 +1,122 @@
"use client"
import { useState } from "react"
import { TOOL_CATALOG, type CatalogTool } from "@/lib/tools-catalog"
export function ToolsReferencePanel() {
const [expandedId, setExpandedId] = useState<string | null>("documentAdd")
return (
<div className="flex min-h-0 flex-col gap-2 text-sm">
<div>
<h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">
Tool reference
</h2>
<p className="mt-1 text-[10px] leading-snug text-zinc-600">
Canonical descriptions from{" "}
<code className="text-zinc-500">@supermemory/tools</code> what the
model sees in tools mode.
</p>
</div>
<ul className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{TOOL_CATALOG.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
expanded={expandedId === tool.id}
onToggle={() =>
setExpandedId((id) => (id === tool.id ? null : tool.id))
}
/>
))}
</ul>
</div>
)
}
function ToolCard({
tool,
expanded,
onToggle,
}: {
tool: CatalogTool
expanded: boolean
onToggle: () => void
}) {
return (
<li className="rounded border border-zinc-800 bg-zinc-900/40">
<button
type="button"
onClick={onToggle}
className="w-full px-2 py-2 text-left"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-xs text-emerald-400/90">
{tool.id}
</div>
<div className="font-mono text-[10px] text-zinc-600">
py: {tool.pythonName}
</div>
</div>
<span className="shrink-0 text-[10px] text-zinc-600">
{expanded ? "" : "+"}
</span>
</div>
{!expanded && (
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-zinc-500">
{tool.description}
</p>
)}
</button>
{expanded && (
<div className="border-t border-zinc-800 px-2 pb-2 pt-1 space-y-2">
<p className="text-[11px] leading-relaxed text-zinc-300">
{tool.description}
</p>
{tool.parameters.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wide text-zinc-600 mb-1">
Parameters
</div>
<ul className="space-y-1.5">
{tool.parameters.map((param) => (
<li
key={param.name}
className="rounded bg-zinc-950/60 px-2 py-1"
>
<div className="flex flex-wrap items-center gap-1">
<span className="font-mono text-[10px] text-sky-400/90">
{param.name}
</span>
{param.pythonName && param.pythonName !== param.name && (
<span className="font-mono text-[10px] text-zinc-600">
/ {param.pythonName}
</span>
)}
{!param.pythonName && (
<span className="text-[9px] text-sky-500/80">
TypeScript only
</span>
)}
{param.required && (
<span className="text-[9px] text-amber-500/80">
required
</span>
)}
</div>
<p className="mt-0.5 text-[10px] leading-snug text-zinc-500">
{param.description}
</p>
</li>
))}
</ul>
</div>
)}
</div>
)}
</li>
)
}

View file

@ -0,0 +1,67 @@
export interface PlaygroundApiKeys {
supermemoryApiKey: string
openaiApiKey: string
}
export const API_KEYS_STORAGE_KEY = "sdk-playground-api-keys"
interface ResolveApiKeyOptions {
allowEnvironment?: boolean
}
export function resolveSupermemoryApiKey(
input?: Partial<PlaygroundApiKeys> | null,
options: ResolveApiKeyOptions = {},
): string | null {
const provided = input?.supermemoryApiKey?.trim()
if (provided) return provided
if (options.allowEnvironment === false) return null
return process.env.SUPERMEMORY_API_KEY?.trim() || null
}
export function resolveOpenAiApiKey(
input?: Partial<PlaygroundApiKeys> | null,
options: ResolveApiKeyOptions = {},
): string | null {
const provided = input?.openaiApiKey?.trim()
if (provided) return provided
if (options.allowEnvironment === false) return null
return process.env.OPENAI_API_KEY?.trim() || null
}
export function resolveApiKeys(
input?: Partial<PlaygroundApiKeys> | null,
options: ResolveApiKeyOptions = {},
): PlaygroundApiKeys | null {
const supermemoryApiKey = resolveSupermemoryApiKey(input, options)
const openaiApiKey = resolveOpenAiApiKey(input, options)
if (!supermemoryApiKey || !openaiApiKey) return null
return { supermemoryApiKey, openaiApiKey }
}
export function readStoredApiKeys(): Partial<PlaygroundApiKeys> {
if (typeof window === "undefined") return {}
try {
const raw = sessionStorage.getItem(API_KEYS_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<PlaygroundApiKeys>
return {
supermemoryApiKey: parsed.supermemoryApiKey ?? "",
openaiApiKey: parsed.openaiApiKey ?? "",
}
} catch {
return {}
}
}
export function storeApiKeys(keys: Partial<PlaygroundApiKeys>) {
if (typeof window === "undefined") return
sessionStorage.setItem(API_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function clearStoredApiKeys() {
if (typeof window === "undefined") return
sessionStorage.removeItem(API_KEYS_STORAGE_KEY)
}

View file

@ -0,0 +1,438 @@
import { createOpenAI } from "@ai-sdk/openai"
import { generateText, stepCountIs, type ModelMessage } from "ai"
import OpenAI from "openai"
import { supermemoryTools as aiSdkPackageTools } from "@supermemory/ai-sdk"
import { withSupermemory as withSupermemoryAiSdk } from "@supermemory/tools/ai-sdk"
import {
createToolCallsExecutor,
getToolDefinitions,
withSupermemory as withSupermemoryOpenAi,
} from "@supermemory/tools/openai"
import { supermemoryTools as aiSdkTools } from "@supermemory/tools/ai-sdk"
import type { SupermemoryToolsConfig } from "@supermemory/tools"
import type { PlaygroundApiKeys } from "./api-keys"
import {
buildMiddlewareMemoryDebug,
type MemoryDebugEntry,
} from "./context-api"
import {
normalizeMiddlewareConfig,
type MiddlewareRuntimeConfig,
} from "./middleware-config"
import {
TOOLS_SYSTEM_PROMPT,
getChatSdk,
type ToolTraceEntry,
} from "./sdk-registry"
export type ChatMessage = {
role: "user" | "assistant" | "system"
content: string
}
export interface ChatResult {
text: string
toolTrace: ToolTraceEntry[]
memoryDebug: MemoryDebugEntry[]
}
export interface ChatRequest {
sdkId: string
messages: ChatMessage[]
containerTag: string
conversationId: string
memoryMode?: "profile" | "query" | "full"
middlewareConfig?: Partial<MiddlewareRuntimeConfig>
apiKeys?: Partial<PlaygroundApiKeys>
containerTags?: string[]
projectId?: string
}
export class PlaygroundChatTimeoutError extends Error {
constructor(message: string) {
super(message)
this.name = "PlaygroundChatTimeoutError"
}
}
const MODEL_REQUEST_TIMEOUT_MS = 120_000
const DEBUG_REQUEST_TIMEOUT_MS = 10_000
const MAX_OUTPUT_TOKENS = 2_048
async function withChatDeadline<T>(
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new PlaygroundChatTimeoutError(
`TypeScript chat timed out after ${MODEL_REQUEST_TIMEOUT_MS / 1_000} seconds`,
)
controller.abort(error)
reject(error)
}, MODEL_REQUEST_TIMEOUT_MS)
})
try {
return await Promise.race([operation(controller.signal), deadline])
} finally {
if (timeout) clearTimeout(timeout)
}
}
async function buildBestEffortDebug(
operation: (signal: AbortSignal) => Promise<MemoryDebugEntry[]>,
): Promise<MemoryDebugEntry[]> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<MemoryDebugEntry[]>((resolve) => {
timeout = setTimeout(() => {
controller.abort()
resolve([
{
type: "debug_error",
label: "Post-response context reconstruction timed out",
detail: { nonFatal: true },
},
])
}, DEBUG_REQUEST_TIMEOUT_MS)
})
try {
return await Promise.race([operation(controller.signal), deadline])
} catch {
return [
{
type: "debug_error",
label: "Post-response context reconstruction unavailable",
detail: { nonFatal: true },
},
]
} finally {
if (timeout) clearTimeout(timeout)
}
}
function getModelName(): string {
return process.env.MODEL_NAME ?? "gpt-4o-mini"
}
function getToolsConfig(
containerTags?: string[],
projectId?: string,
): SupermemoryToolsConfig {
return {
baseUrl: process.env.SUPERMEMORY_BASE_URL,
...(containerTags?.length ? { containerTags } : {}),
...(projectId ? { projectId } : {}),
}
}
function toModelMessages(messages: ChatMessage[]): ModelMessage[] {
return messages.map((m) => ({ role: m.role, content: m.content }))
}
function toOpenAiMessages(
messages: ChatMessage[],
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
return messages.map((m) => ({ role: m.role, content: m.content }))
}
function extractAiSdkToolTrace(
steps: Array<{
toolCalls: Array<{ toolName: string; input: unknown }>
toolResults: Array<{ toolName: string; output: unknown }>
}>,
): ToolTraceEntry[] {
const trace: ToolTraceEntry[] = []
for (const [stepIndex, step] of steps.entries()) {
for (let i = 0; i < step.toolCalls.length; i++) {
const call = step.toolCalls[i]
const result = step.toolResults[i]
trace.push({
step: stepIndex + 1,
toolName: call.toolName,
args: call.input,
result: result?.output,
})
}
}
return trace
}
function lastUserMessage(messages: ChatMessage[]): string {
return [...messages].reverse().find((m) => m.role === "user")?.content ?? ""
}
async function chatAiSdkMiddleware(
keys: PlaygroundApiKeys,
messages: ChatMessage[],
containerTag: string,
conversationId: string,
memoryMode: "profile" | "query" | "full",
middlewareConfig: MiddlewareRuntimeConfig,
signal: AbortSignal,
): Promise<ChatResult> {
const openai = createOpenAI({ apiKey: keys.openaiApiKey })
const model = withSupermemoryAiSdk(openai(getModelName()), {
containerTag,
customId: conversationId,
apiKey: keys.supermemoryApiKey,
mode: memoryMode,
addMemory: middlewareConfig.addMemory,
verbose: middlewareConfig.verbose,
includeToolCalls: middlewareConfig.includeToolCalls,
skipMemoryOnError: middlewareConfig.skipMemoryOnError,
baseUrl: process.env.SUPERMEMORY_BASE_URL,
})
const result = await generateText({
model,
system: "You are a helpful assistant with long-term memory about the user.",
messages: toModelMessages(messages.filter((m) => m.role !== "system")),
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: signal,
})
return { text: result.text, toolTrace: [], memoryDebug: [] }
}
async function chatOpenAiMiddleware(
keys: PlaygroundApiKeys,
messages: ChatMessage[],
containerTag: string,
conversationId: string,
memoryMode: "profile" | "query" | "full",
middlewareConfig: MiddlewareRuntimeConfig,
signal: AbortSignal,
): Promise<ChatResult> {
const openai = new OpenAI({
apiKey: keys.openaiApiKey,
timeout: MODEL_REQUEST_TIMEOUT_MS,
maxRetries: 1,
})
const client = withSupermemoryOpenAi(openai, {
containerTag,
customId: conversationId,
apiKey: keys.supermemoryApiKey,
mode: memoryMode,
addMemory: middlewareConfig.addMemory,
verbose: middlewareConfig.verbose,
baseUrl: process.env.SUPERMEMORY_BASE_URL,
})
const response = await client.chat.completions.create(
{
model: getModelName(),
messages: toOpenAiMessages(messages),
max_tokens: MAX_OUTPUT_TOKENS,
},
{ signal },
)
return {
text: response.choices[0]?.message?.content ?? "",
toolTrace: [],
memoryDebug: [],
}
}
async function chatAiSdkTools(
keys: PlaygroundApiKeys,
toolsFactory: typeof aiSdkTools,
messages: ChatMessage[],
containerTags?: string[],
projectId?: string,
signal?: AbortSignal,
): Promise<ChatResult> {
const openai = createOpenAI({ apiKey: keys.openaiApiKey })
const tools = toolsFactory(
keys.supermemoryApiKey,
getToolsConfig(containerTags, projectId),
)
const result = await generateText({
model: openai(getModelName()),
system: TOOLS_SYSTEM_PROMPT,
messages: toModelMessages(messages.filter((m) => m.role !== "system")),
tools,
stopWhen: stepCountIs(8),
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: signal,
})
return {
text: result.text,
toolTrace: extractAiSdkToolTrace(result.steps),
memoryDebug: [],
}
}
async function chatOpenAiTools(
keys: PlaygroundApiKeys,
messages: ChatMessage[],
containerTags?: string[],
projectId?: string,
signal?: AbortSignal,
): Promise<ChatResult> {
const openai = new OpenAI({
apiKey: keys.openaiApiKey,
timeout: MODEL_REQUEST_TIMEOUT_MS,
maxRetries: 1,
})
const config = getToolsConfig(containerTags, projectId)
const executeToolCalls = createToolCallsExecutor(
keys.supermemoryApiKey,
config,
)
const toolDefs = getToolDefinitions()
const trace: ToolTraceEntry[] = []
const convo: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: TOOLS_SYSTEM_PROMPT },
...toOpenAiMessages(messages.filter((m) => m.role !== "system")),
]
for (let step = 0; step < 8; step++) {
const response = await openai.chat.completions.create(
{
model: getModelName(),
messages: convo,
tools: toolDefs,
max_tokens: MAX_OUTPUT_TOKENS,
},
{ signal },
)
const choice = response.choices[0]?.message
if (!choice) break
convo.push(choice)
if (choice.tool_calls?.length) {
const toolMessages = await executeToolCalls(choice.tool_calls)
for (let i = 0; i < choice.tool_calls.length; i++) {
const call = choice.tool_calls[i]
const rawContent = toolMessages[i]?.content
const raw =
typeof rawContent === "string"
? rawContent
: JSON.stringify(rawContent)
let parsedResult: unknown = raw
try {
parsedResult = JSON.parse(raw)
} catch {
/* keep string */
}
trace.push({
step: step + 1,
toolName: call.function.name,
args: JSON.parse(call.function.arguments),
result: parsedResult,
})
}
convo.push(...toolMessages)
continue
}
return { text: choice.content ?? "", toolTrace: trace, memoryDebug: [] }
}
throw new Error("Tool loop exceeded max steps")
}
export async function runTypeScriptChat(
request: ChatRequest,
keys: PlaygroundApiKeys,
): Promise<ChatResult> {
const sdk = getChatSdk(request.sdkId)
if (!sdk || sdk.language !== "typescript" || !sdk.available) {
throw new Error(`Invalid TypeScript chat SDK: ${request.sdkId}`)
}
const containerTags = request.containerTags ?? [request.containerTag]
const memoryMode = request.memoryMode ?? "full"
const middlewareConfig = normalizeMiddlewareConfig(request.middlewareConfig)
const result = await withChatDeadline(async (signal) => {
switch (request.sdkId) {
case "ts-ai-sdk-middleware":
return await chatAiSdkMiddleware(
keys,
request.messages,
request.containerTag,
request.conversationId,
memoryMode,
middlewareConfig,
signal,
)
case "ts-openai-middleware":
return await chatOpenAiMiddleware(
keys,
request.messages,
request.containerTag,
request.conversationId,
memoryMode,
middlewareConfig,
signal,
)
case "ts-ai-sdk-tools":
return await chatAiSdkTools(
keys,
aiSdkTools,
request.messages,
containerTags,
request.projectId,
signal,
)
case "ts-openai-tools":
return await chatOpenAiTools(
keys,
request.messages,
containerTags,
request.projectId,
signal,
)
case "ts-ai-sdk-package":
return await chatAiSdkTools(
keys,
aiSdkPackageTools,
request.messages,
containerTags,
request.projectId,
signal,
)
default:
throw new Error(`Unhandled SDK: ${request.sdkId}`)
}
})
if (
request.sdkId !== "ts-ai-sdk-middleware" &&
request.sdkId !== "ts-openai-middleware"
) {
return result
}
const memoryDebug = await buildBestEffortDebug((signal) =>
buildMiddlewareMemoryDebug(
request.containerTag,
request.conversationId,
memoryMode,
lastUserMessage(request.messages),
middlewareConfig,
request.sdkId === "ts-ai-sdk-middleware"
? {
flavor: "ai-sdk",
includeToolCalls: middlewareConfig.includeToolCalls,
skipMemoryOnError: middlewareConfig.skipMemoryOnError,
}
: { flavor: "openai" },
keys.supermemoryApiKey,
signal,
),
)
return { ...result, memoryDebug }
}

View file

@ -0,0 +1,294 @@
import Supermemory from "supermemory"
import {
type MiddlewareRuntimeConfig,
normalizeMiddlewareConfig,
} from "./middleware-config"
import {
type MemoryMode,
type MiddlewareFlavor,
reconstructSdkMemoryBlock,
} from "./memory-dedupe"
export interface MemoryDebugEntry {
type:
| "context_reconstruction"
| "context_preview"
| "conversation_save_requested"
| "conversation_save_accepted"
| "conversation_save_failed"
| "conversation_save_queued"
| "conversation_save_skipped"
| "conversation_saved"
| "profile_fetch"
| "context_debug_unavailable"
| "debug_error"
| "manual_profile"
label: string
detail?: Record<string, unknown>
preview?: string
}
export interface ContainerContext {
containerTag: string
query?: string
profile: {
static: unknown[]
dynamic: unknown[]
searchResults: unknown[]
}
documents: Array<{
id?: string
title?: string
status?: string
customId?: string
createdAt?: string
updatedAt?: string
summary?: string
memoryEntries?: unknown[]
}>
pagination?: unknown
}
function getSupermemoryClient(apiKey: string) {
if (!apiKey) throw new Error("Supermemory API key is required")
return new Supermemory({
apiKey,
timeout: 10_000,
maxRetries: 1,
...(process.env.SUPERMEMORY_BASE_URL
? { baseURL: process.env.SUPERMEMORY_BASE_URL }
: {}),
})
}
function normalizeMemoryEntries(record: Record<string, unknown>): unknown[] {
const raw =
record.memoryEntries ??
record.memory_entries ??
(Array.isArray(record.memories) &&
record.memories.length > 0 &&
typeof (record.memories[0] as Record<string, unknown>)?.memory === "string"
? record.memories
: undefined)
return Array.isArray(raw) ? raw : []
}
function memoryText(item: unknown): string {
if (typeof item === "string") return item
if (item && typeof item === "object") {
const record = item as Record<string, unknown>
if (typeof record.memory === "string") return record.memory
if (typeof record.content === "string") return record.content
if (typeof record.chunk === "string") return record.chunk
}
return JSON.stringify(item)
}
function summarizeProfile(profile: ContainerContext["profile"]) {
return {
staticCount: profile.static.length,
dynamicCount: profile.dynamic.length,
searchResultCount: profile.searchResults.length,
staticPreview: profile.static.slice(0, 5).map(memoryText),
dynamicPreview: profile.dynamic.slice(0, 5).map(memoryText),
searchPreview: profile.searchResults.slice(0, 5).map(memoryText),
}
}
function normalizeSearchResults(searchResults: unknown): unknown[] {
if (!searchResults) return []
if (Array.isArray(searchResults)) return searchResults
if (typeof searchResults === "object") {
const record = searchResults as Record<string, unknown>
if (Array.isArray(record.results)) return record.results
}
return []
}
export function resolveProfileQuery(
lastUserMessage: string,
mode: "profile" | "query" | "full",
): string | undefined {
if (mode === "profile") return undefined
return lastUserMessage || undefined
}
async function fetchProfileContext(
client: ReturnType<typeof getSupermemoryClient>,
containerTag: string,
query?: string,
signal?: AbortSignal,
): Promise<ContainerContext["profile"]> {
const profileResponse = await client.post<{
profile?: { static?: unknown[]; dynamic?: unknown[] }
searchResults?: unknown
}>("/v4/profile", {
body: {
containerTag,
include: ["static", "dynamic"],
...(query ? { q: query } : {}),
},
...(signal ? { signal } : {}),
})
const profileRaw = profileResponse.profile
return {
static: profileRaw?.static ?? [],
dynamic: profileRaw?.dynamic ?? [],
searchResults: normalizeSearchResults(profileResponse.searchResults),
}
}
export async function fetchContainerContext(
containerTag: string,
query?: string,
supermemoryApiKey?: string,
): Promise<ContainerContext> {
const apiKey =
supermemoryApiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim()
if (!apiKey) throw new Error("Supermemory API key is required")
const client = getSupermemoryClient(apiKey)
const profile = await fetchProfileContext(client, containerTag, query)
const docsResponse = await client.post<{
documents?: unknown[]
pagination?: unknown
}>("/v3/documents/documents", {
body: {
containerTags: [containerTag],
limit: 25,
sort: "createdAt",
order: "desc",
},
})
const rawDocuments = docsResponse.documents ?? []
const documents = rawDocuments.map((doc) => {
const record = doc as Record<string, unknown>
return {
id: record.id as string | undefined,
title: record.title as string | undefined,
status: record.status as string | undefined,
customId: record.customId as string | undefined,
createdAt: record.createdAt as string | undefined,
updatedAt: record.updatedAt as string | undefined,
summary: record.summary as string | undefined,
memoryEntries: normalizeMemoryEntries(record),
}
})
return {
containerTag,
query,
profile,
documents,
pagination: docsResponse.pagination,
}
}
export async function buildMiddlewareMemoryDebug(
containerTag: string,
conversationId: string,
memoryMode: MemoryMode,
lastUserMessage: string,
middlewareConfig: Partial<MiddlewareRuntimeConfig> | undefined,
sdk: {
flavor: MiddlewareFlavor
includeToolCalls?: boolean
skipMemoryOnError?: boolean
},
supermemoryApiKey?: string,
signal?: AbortSignal,
): Promise<MemoryDebugEntry[]> {
const config = normalizeMiddlewareConfig(middlewareConfig)
const query = resolveProfileQuery(lastUserMessage, memoryMode)
try {
const apiKey =
supermemoryApiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim()
if (!apiKey) throw new Error("Supermemory API key is required")
const profile = await fetchProfileContext(
getSupermemoryClient(apiKey),
containerTag,
query,
signal,
)
const reconstructed = reconstructSdkMemoryBlock(
memoryMode,
profile,
sdk.flavor,
)
const selectedProfile = reconstructed.profile
const summary = summarizeProfile(selectedProfile)
return [
{
type: "context_reconstruction",
label: "Post-response context reconstruction",
detail: {
authoritativeMiddlewareCapture: false,
timing: "after model response",
endpoint: "POST /v4/profile",
containerTag,
customId: conversationId,
memoryMode,
addMemory: config.addMemory,
verbose: config.verbose,
...(sdk.includeToolCalls !== undefined
? { includeToolCalls: sdk.includeToolCalls }
: {}),
...(sdk.skipMemoryOnError !== undefined
? { skipMemoryOnError: sdk.skipMemoryOnError }
: {}),
query: query ?? null,
...summary,
},
},
{
type: "context_preview",
label: "Reconstructed SDK-owned memory block (not middleware capture)",
preview: reconstructed.block,
detail: {
totalFacts:
summary.staticCount +
summary.dynamicCount +
summary.searchResultCount,
fullLength: reconstructed.block.length,
},
},
config.addMemory === "always"
? {
type: "conversation_save_requested",
label: "Conversation save requested by middleware",
detail: {
confirmed: false,
containerTag,
customId: conversationId,
addMemory: config.addMemory,
verbose: config.verbose,
...(sdk.includeToolCalls !== undefined
? { includeToolCalls: sdk.includeToolCalls }
: {}),
},
}
: {
type: "conversation_save_skipped",
label: "Conversation saving disabled",
detail: { addMemory: config.addMemory },
},
]
} catch (error) {
return [
{
type: "debug_error",
label: "Post-response context reconstruction unavailable",
detail: {
nonFatal: true,
error: error instanceof Error ? error.message : String(error),
},
},
]
}
}

View file

@ -0,0 +1,75 @@
import {
deduplicateMemoriesForMode,
type ProfileWithMemories,
} from "../../../../packages/tools/src/tools-shared"
import { wrapMemoryContext } from "../../../../packages/tools/src/shared/memory-context"
import {
convertProfileToMarkdown,
defaultPromptTemplate,
} from "../../../../packages/tools/src/shared/prompt-builder"
export type MemoryMode = "profile" | "query" | "full"
export type MiddlewareFlavor = "ai-sdk" | "openai"
export interface MemoryProfileSlice {
static: unknown[]
dynamic: unknown[]
searchResults: unknown[]
}
export interface ReconstructedMemoryBlock {
profile: {
static: string[]
dynamic: string[]
searchResults: string[]
}
block: string
}
/** Reconstruct the exact SDK-owned block from a post-response profile snapshot. */
export function reconstructSdkMemoryBlock(
mode: MemoryMode,
profile: MemoryProfileSlice,
flavor: MiddlewareFlavor,
): ReconstructedMemoryBlock {
const deduplicated = deduplicateMemoriesForMode(
mode,
profile as ProfileWithMemories,
)
const visibleProfile = {
static: deduplicated.static,
dynamic: deduplicated.dynamic,
searchResults: mode === "profile" ? [] : deduplicated.searchResults,
}
const userMemories =
mode === "query"
? ""
: convertProfileToMarkdown({
profile: {
static: visibleProfile.static,
dynamic: visibleProfile.dynamic,
},
searchResults: { results: [] },
})
const generalSearchMemories =
mode !== "profile" && visibleProfile.searchResults.length > 0
? `Search results for user's recent message: \n${visibleProfile.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
: ""
const memories =
flavor === "ai-sdk"
? defaultPromptTemplate({
userMemories,
generalSearchMemories,
searchResults: [],
})
: `${userMemories}\n${generalSearchMemories}`.trim()
return {
profile: visibleProfile,
block: wrapMemoryContext(memories),
}
}

View file

@ -0,0 +1,30 @@
export type AddMemoryMode = "always" | "never"
export type MemoryMode = "profile" | "query" | "full"
export interface MiddlewareRuntimeConfig {
addMemory: AddMemoryMode
verbose: boolean
includeToolCalls: boolean
skipMemoryOnError: boolean
}
export const DEFAULT_MIDDLEWARE_CONFIG: MiddlewareRuntimeConfig = {
addMemory: "always",
verbose: false,
includeToolCalls: false,
skipMemoryOnError: true,
}
export function normalizeMiddlewareConfig(
input?: Partial<MiddlewareRuntimeConfig> | null,
): MiddlewareRuntimeConfig {
if (!input) return { ...DEFAULT_MIDDLEWARE_CONFIG }
return {
addMemory: input.addMemory ?? DEFAULT_MIDDLEWARE_CONFIG.addMemory,
verbose: input.verbose ?? DEFAULT_MIDDLEWARE_CONFIG.verbose,
includeToolCalls:
input.includeToolCalls ?? DEFAULT_MIDDLEWARE_CONFIG.includeToolCalls,
skipMemoryOnError:
input.skipMemoryOnError ?? DEFAULT_MIDDLEWARE_CONFIG.skipMemoryOnError,
}
}

View file

@ -0,0 +1,354 @@
import type { PlaygroundApiKeys } from "./api-keys"
import type { ChatMessage } from "./chat-handlers"
import type { MiddlewareRuntimeConfig } from "./middleware-config"
const MAX_BODY_BYTES = 256_000
const MAX_MESSAGES = 64
const MAX_MESSAGE_LENGTH = 20_000
const MAX_TOTAL_MESSAGE_LENGTH = 100_000
const MAX_IDENTIFIER_LENGTH = 256
const MAX_API_KEY_LENGTH = 1_024
const MAX_CONTAINER_TAG_LENGTH = 100
const MAX_CONVERSATION_ID_LENGTH = 242
const CONTAINER_TAG_PATTERN = /^[a-zA-Z0-9_:-]+$/
export class PlaygroundRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
this.name = "PlaygroundRequestError"
}
}
function firstHeaderValue(value: string | null): string | null {
const first = value?.split(",", 1)[0]?.trim()
return first || null
}
function requestOrigin(request: Request): string | null {
const internalUrl = new URL(request.url)
const forwardedHostHeader = request.headers.get("x-forwarded-host")
const forwardedProtoHeader = request.headers.get("x-forwarded-proto")
const hostHeader = request.headers.get("host")
const directHost = firstHeaderValue(hostHeader)
if (hostHeader !== null && !directHost) return null
const forwardedHost = firstHeaderValue(forwardedHostHeader)
if (forwardedHostHeader !== null && !forwardedHost) return null
// Portless preserves the routed Host header but may preserve a client-supplied
// X-Forwarded-Host. Trust Host first so XFH cannot widen env-key access.
const host = directHost ?? forwardedHost
if (!host) return internalUrl.origin
const forwardedProto = firstHeaderValue(forwardedProtoHeader)
if (
forwardedProtoHeader !== null &&
forwardedProto !== "http" &&
forwardedProto !== "https"
) {
return null
}
const protocol =
forwardedProto === "http" || forwardedProto === "https"
? forwardedProto
: internalUrl.protocol.slice(0, -1)
try {
const externalUrl = new URL(`${protocol}://${host}`)
if (
externalUrl.host.toLowerCase() !== host.toLowerCase() ||
externalUrl.username ||
externalUrl.password ||
externalUrl.pathname !== "/" ||
externalUrl.search ||
externalUrl.hash
) {
return null
}
return externalUrl.origin
} catch {
return null
}
}
export function assertTrustedBrowserRequest(request: Request): void {
if (request.headers.get("sec-fetch-site") === "cross-site") {
throw new PlaygroundRequestError("Cross-site requests are not allowed", 403)
}
const expectedOrigin = requestOrigin(request)
if (!expectedOrigin) {
throw new PlaygroundRequestError("Request host is not allowed", 403)
}
const origin = request.headers.get("origin")
let normalizedOrigin: string | null = null
if (origin) {
try {
normalizedOrigin = new URL(origin).origin
} catch {
throw new PlaygroundRequestError("Request origin is not allowed", 403)
}
}
if (normalizedOrigin && normalizedOrigin !== expectedOrigin) {
throw new PlaygroundRequestError("Request origin is not allowed", 403)
}
}
export function mayUseEnvironmentKeys(request: Request): boolean {
if (process.env.SDK_PLAYGROUND_ALLOW_ENV_KEYS === "true") return true
const origin = requestOrigin(request)
if (!origin) return false
const hostname = new URL(origin).hostname.toLowerCase()
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "[::1]" ||
hostname.endsWith(".localhost") ||
hostname === "sdk.dev.supermemory.ai"
)
}
export async function readJsonObject(
request: Request,
): Promise<Record<string, unknown>> {
const contentType = request.headers.get("content-type") ?? ""
if (!contentType.toLowerCase().startsWith("application/json")) {
throw new PlaygroundRequestError(
"Content-Type must be application/json",
415,
)
}
const contentLength = Number(request.headers.get("content-length"))
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
throw new PlaygroundRequestError("Request body is too large", 413)
}
const raw = await request.text()
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) {
throw new PlaygroundRequestError("Request body is too large", 413)
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new PlaygroundRequestError("Request body must be valid JSON", 400)
}
if (!isRecord(value)) {
throw new PlaygroundRequestError("Request body must be a JSON object", 400)
}
return value
}
export function parseMessages(value: unknown): ChatMessage[] {
if (!Array.isArray(value) || value.length === 0) {
throw new PlaygroundRequestError(
"At least one chat message is required",
400,
)
}
if (value.length > MAX_MESSAGES) {
throw new PlaygroundRequestError(
`A maximum of ${MAX_MESSAGES} messages is allowed`,
400,
)
}
let totalLength = 0
const messages = value.map((item, index): ChatMessage => {
if (!isRecord(item)) {
throw new PlaygroundRequestError(`Message ${index + 1} is invalid`, 400)
}
if (
item.role !== "user" &&
item.role !== "assistant" &&
item.role !== "system"
) {
throw new PlaygroundRequestError(
`Message ${index + 1} has an invalid role`,
400,
)
}
if (typeof item.content !== "string") {
throw new PlaygroundRequestError(
`Message ${index + 1} content must be text`,
400,
)
}
if (item.content.length > MAX_MESSAGE_LENGTH) {
throw new PlaygroundRequestError(
`Message ${index + 1} exceeds ${MAX_MESSAGE_LENGTH} characters`,
400,
)
}
totalLength += item.content.length
return { role: item.role, content: item.content }
})
if (totalLength > MAX_TOTAL_MESSAGE_LENGTH) {
throw new PlaygroundRequestError("Chat history is too large", 400)
}
if (
!messages.some(
(message) => message.role === "user" && message.content.trim().length > 0,
)
) {
throw new PlaygroundRequestError(
"Chat history must include a non-empty user message",
400,
)
}
return messages
}
export function parseIdentifier(
value: unknown,
name: string,
fallback?: string,
): string {
const resolved = typeof value === "string" ? value.trim() : fallback
if (!resolved) {
throw new PlaygroundRequestError(`${name} is required`, 400)
}
if (resolved.length > MAX_IDENTIFIER_LENGTH) {
throw new PlaygroundRequestError(
`${name} must be ${MAX_IDENTIFIER_LENGTH} characters or fewer`,
400,
)
}
return resolved
}
export function parseContainerTag(
value: unknown,
fallback = "sdk-playground",
): string {
const containerTag = parseIdentifier(value, "containerTag", fallback)
if (containerTag.length > MAX_CONTAINER_TAG_LENGTH) {
throw new PlaygroundRequestError(
`containerTag must be ${MAX_CONTAINER_TAG_LENGTH} characters or fewer`,
400,
)
}
if (!CONTAINER_TAG_PATTERN.test(containerTag)) {
throw new PlaygroundRequestError(
"containerTag may only contain letters, numbers, hyphens, underscores, and colons",
400,
)
}
return containerTag
}
export function parseConversationId(value: unknown): string {
const conversationId = parseIdentifier(value, "conversationId")
if (conversationId.length > MAX_CONVERSATION_ID_LENGTH) {
throw new PlaygroundRequestError(
`conversationId must be ${MAX_CONVERSATION_ID_LENGTH} characters or fewer`,
400,
)
}
return conversationId
}
export function parseOptionalText(
value: unknown,
name: string,
maxLength = MAX_MESSAGE_LENGTH,
): string | undefined {
if (value === undefined || value === null || value === "") return undefined
if (typeof value !== "string") {
throw new PlaygroundRequestError(`${name} must be text`, 400)
}
const resolved = value.trim()
if (!resolved) return undefined
if (resolved.length > maxLength) {
throw new PlaygroundRequestError(
`${name} must be ${maxLength} characters or fewer`,
400,
)
}
return resolved
}
export function parseMemoryMode(
value: unknown,
): "profile" | "query" | "full" | undefined {
if (value === undefined || value === null) return undefined
if (value === "profile" || value === "query" || value === "full") {
return value
}
throw new PlaygroundRequestError("Invalid memory mode", 400)
}
export function parseMiddlewareConfig(
value: unknown,
): Partial<MiddlewareRuntimeConfig> | undefined {
if (value === undefined || value === null) return undefined
if (!isRecord(value)) {
throw new PlaygroundRequestError("Invalid middleware configuration", 400)
}
if (
value.addMemory !== undefined &&
value.addMemory !== "always" &&
value.addMemory !== "never"
) {
throw new PlaygroundRequestError("Invalid addMemory value", 400)
}
for (const key of [
"verbose",
"includeToolCalls",
"skipMemoryOnError",
] as const) {
if (value[key] !== undefined && typeof value[key] !== "boolean") {
throw new PlaygroundRequestError(`Invalid ${key} value`, 400)
}
}
return {
...(value.addMemory !== undefined
? { addMemory: value.addMemory as "always" | "never" }
: {}),
...(value.verbose !== undefined
? { verbose: value.verbose as boolean }
: {}),
...(value.includeToolCalls !== undefined
? { includeToolCalls: value.includeToolCalls as boolean }
: {}),
...(value.skipMemoryOnError !== undefined
? { skipMemoryOnError: value.skipMemoryOnError as boolean }
: {}),
}
}
export function parseApiKeys(value: unknown): Partial<PlaygroundApiKeys> {
if (value === undefined || value === null) return {}
if (!isRecord(value)) {
throw new PlaygroundRequestError("Invalid API key configuration", 400)
}
return {
supermemoryApiKey: parseOptionalApiKey(
value.supermemoryApiKey,
"Supermemory API key",
),
openaiApiKey: parseOptionalApiKey(value.openaiApiKey, "OpenAI API key"),
}
}
function parseOptionalApiKey(value: unknown, name: string): string {
if (value === undefined || value === null || value === "") return ""
if (typeof value !== "string" || value.length > MAX_API_KEY_LENGTH) {
throw new PlaygroundRequestError(`${name} is invalid`, 400)
}
return value.trim()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -0,0 +1,116 @@
export type SdkLanguage = "typescript" | "python"
export type IntegrationMode = "middleware" | "tools" | "direct"
export interface ToolTraceEntry {
step: number
toolName: string
args: unknown
result?: unknown
}
export interface ChatSdkDefinition {
id: string
label: string
language: SdkLanguage
mode: IntegrationMode
package: string
description: string
available: boolean
}
export const CHAT_SDK_REGISTRY: ChatSdkDefinition[] = [
{
id: "ts-ai-sdk-middleware",
label: "AI SDK + middleware",
language: "typescript",
mode: "middleware",
package: "@supermemory/tools/ai-sdk",
description:
"withSupermemory wraps the model — auto-injects profile/search and saves conversations",
available: true,
},
{
id: "ts-openai-middleware",
label: "OpenAI SDK + middleware",
language: "typescript",
mode: "middleware",
package: "@supermemory/tools/openai",
description:
"withSupermemory on OpenAI client — same automatic memory path",
available: true,
},
{
id: "ts-ai-sdk-tools",
label: "AI SDK + tools",
language: "typescript",
mode: "tools",
package: "@supermemory/tools/ai-sdk",
description:
"Agent explicitly calls the 7 Supermemory tools via generateText",
available: true,
},
{
id: "ts-openai-tools",
label: "OpenAI SDK + tools",
language: "typescript",
mode: "tools",
package: "@supermemory/tools/openai",
description: "OpenAI function calling with the 7 Supermemory tools",
available: true,
},
{
id: "ts-ai-sdk-package",
label: "@supermemory/ai-sdk",
language: "typescript",
mode: "tools",
package: "@supermemory/ai-sdk",
description: "Re-export of tools/ai-sdk — same 7-tool agent",
available: true,
},
{
id: "py-openai-middleware",
label: "OpenAI + middleware",
language: "python",
mode: "middleware",
package: "supermemory-openai-sdk",
description:
"with_supermemory — automatic profile injection + conversation save",
available: true,
},
{
id: "py-openai-tools",
label: "OpenAI + tools",
language: "python",
mode: "tools",
package: "supermemory-openai-sdk",
description: "SupermemoryTools function-calling loop (7 tools)",
available: true,
},
{
id: "py-supermemory-direct",
label: "supermemory + manual context",
language: "python",
mode: "direct",
package: "supermemory",
description: "profile() then OpenAI — manual integration pattern from docs",
available: true,
},
]
export const PYTHON_SERVER_URL =
process.env.SDK_PLAYGROUND_PYTHON_URL ?? "http://127.0.0.1:8792"
export const TOOLS_SYSTEM_PROMPT = `You are a helpful assistant with Supermemory long-term memory.
You have tools to manage memory. Use them proactively:
- searchMemories: hybrid recall search before answering whenever user-specific context could help (do not wait to be asked)
- getProfile: broad static/dynamic user context at conversation start or when you need a wide overview
- addMemory: store a new generalizable fact
- documentList / documentAdd / documentDelete: manage source documents (documentDelete is permanent)
- memoryForget: soft-delete one profile fact by memoryId or exact content (not whole documents)
Before answering questions about the user, their preferences, or past context, search memories or get profile first. When the user asks you to remember something, use addMemory.`
export function getChatSdk(id: string): ChatSdkDefinition | undefined {
return CHAT_SDK_REGISTRY.find((s) => s.id === id)
}

View file

@ -0,0 +1,160 @@
import {
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
} from "../../../../packages/tools/src/tools-shared"
export interface CatalogParameter {
name: string
/** Omitted when this parameter is only exposed by the TypeScript tool schema. */
pythonName?: string
description: string
required?: boolean
}
export interface CatalogTool {
id: string
pythonName: string
description: string
parameters: CatalogParameter[]
}
export const TOOL_CATALOG: CatalogTool[] = [
{
id: "searchMemories",
pythonName: "search_memories",
description: TOOL_DESCRIPTIONS.searchMemories,
parameters: [
{
name: "informationToGet",
pythonName: "information_to_get",
description: PARAMETER_DESCRIPTIONS.informationToGet,
required: true,
},
{
name: "includeFullDocs",
description: PARAMETER_DESCRIPTIONS.includeFullDocs,
},
{
name: "limit",
pythonName: "limit",
description: PARAMETER_DESCRIPTIONS.limit,
},
],
},
{
id: "addMemory",
pythonName: "add_memory",
description: TOOL_DESCRIPTIONS.addMemory,
parameters: [
{
name: "memory",
pythonName: "memory",
description: PARAMETER_DESCRIPTIONS.memory,
required: true,
},
],
},
{
id: "getProfile",
pythonName: "get_profile",
description: TOOL_DESCRIPTIONS.getProfile,
parameters: [
{
name: "containerTag",
description: PARAMETER_DESCRIPTIONS.containerTag,
},
{
name: "query",
pythonName: "query",
description: PARAMETER_DESCRIPTIONS.query,
},
],
},
{
id: "documentList",
pythonName: "document_list",
description: TOOL_DESCRIPTIONS.documentList,
parameters: [
{
name: "containerTag",
description: PARAMETER_DESCRIPTIONS.containerTag,
},
{
name: "limit",
pythonName: "limit",
description: PARAMETER_DESCRIPTIONS.limit,
},
{
name: "page",
pythonName: "page",
description: PARAMETER_DESCRIPTIONS.page,
},
],
},
{
id: "documentDelete",
pythonName: "document_delete",
description: TOOL_DESCRIPTIONS.documentDelete,
parameters: [
{
name: "documentId",
pythonName: "document_id",
description: PARAMETER_DESCRIPTIONS.documentId,
required: true,
},
{
name: "containerTag",
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
},
],
},
{
id: "documentAdd",
pythonName: "document_add",
description: TOOL_DESCRIPTIONS.documentAdd,
parameters: [
{
name: "content",
pythonName: "content",
description: PARAMETER_DESCRIPTIONS.content,
required: true,
},
{
name: "title",
pythonName: "title",
description: PARAMETER_DESCRIPTIONS.title,
},
{
name: "description",
pythonName: "description",
description: PARAMETER_DESCRIPTIONS.description,
},
],
},
{
id: "memoryForget",
pythonName: "memory_forget",
description: TOOL_DESCRIPTIONS.memoryForget,
parameters: [
{
name: "containerTag",
description: PARAMETER_DESCRIPTIONS.containerTag,
},
{
name: "memoryId",
pythonName: "memory_id",
description: PARAMETER_DESCRIPTIONS.memoryId,
},
{
name: "memoryContent",
pythonName: "memory_content",
description: PARAMETER_DESCRIPTIONS.memoryContent,
},
{
name: "reason",
pythonName: "reason",
description: PARAMETER_DESCRIPTIONS.reason,
},
],
},
]

View file

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

View file

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

View file

@ -5,24 +5,14 @@ import { useRouter } from "next/navigation"
import { LogoFull } from "@ui/assets/Logo" import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button" import { Button } from "@ui/components/button"
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react" import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context" import { useAuth } from "@lib/auth-context"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { import {
type BrainEntryOrganization, type BrainEntryOrganization,
resolveCompanyBrainEntry, resolveCompanyBrainEntry,
} from "@/lib/company-brain-entry" } from "@/lib/company-brain-entry"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { import { generateOrgSlug } from "@/components/onboarding-brain/types"
detectModeFromEmail,
generateOrgSlug,
workspaceDomainFromEmail,
workspaceNameFromDomain,
workspaceNameFromEmail,
type BrainMetadata,
} from "@/components/onboarding-brain/types"
const BACKEND = const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
@ -37,21 +27,13 @@ const inputBevelStyle = {
"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)", "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)",
} }
// No forms: sign up → org auto-created → Slack install.
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
export default function BrainEntryPage() { export default function BrainEntryPage() {
const router = useRouter() const router = useRouter()
const { const { user, org, organizations, isRestoring, setActiveOrg } = useAuth()
user,
org,
organizations,
isRestoring,
setActiveOrg,
refetchOrganizations,
} = useAuth()
const { email = null } = user ?? {} const { email = null } = user ?? {}
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null) const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
const [closed, setClosed] = useState(false)
const [attempt, setAttempt] = useState(0) const [attempt, setAttempt] = useState(0)
const startedRef = useRef(false) const startedRef = useRef(false)
@ -75,43 +57,6 @@ export default function BrainEntryPage() {
[org?.id, router, setActiveOrg], [org?.id, router, setActiveOrg],
) )
const createCompanyBrain = useCallback(async () => {
// Personal email → shell org; the Slack workspace resolves identity later.
const domain =
detectModeFromEmail(email) === "team"
? workspaceDomainFromEmail(email)
: null
const name =
(domain
? workspaceNameFromDomain(domain)
: workspaceNameFromEmail(email)) || "Company Brain"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: "team",
brainWorkspaceName: name,
brainWorkspaceDomain: domain,
// Always the shared Team Brain; the CB UI never selects a slug space.
brainContainerTag: SHARED_TEAM_BRAIN_TAG,
}
const result = await authClient.organization.create({
name,
slug: generateOrgSlug(name),
metadata,
})
if (result.error || !result.data?.slug) {
throw new Error(result.error?.message || "Could not create workspace.")
}
await setActiveOrg(result.data.slug)
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
mode: "team",
has_about: false,
has_domain: Boolean(domain),
})
window.location.href = `${BACKEND}/brain/slack/oauth/install`
}, [email, refetchOrganizations, setActiveOrg])
const run = useCallback(async () => { const run = useCallback(async () => {
const organizationsWithActiveMetadata = (organizations ?? []).map( const organizationsWithActiveMetadata = (organizations ?? []).map(
(organization) => (organization) =>
@ -132,8 +77,8 @@ export default function BrainEntryPage() {
setChoices(decision.organizations) setChoices(decision.organizations)
return return
} }
await createCompanyBrain() setClosed(true)
}, [continueWithOrganization, createCompanyBrain, org, organizations]) }, [continueWithOrganization, org, organizations])
const handleChoice = useCallback( const handleChoice = useCallback(
(organization: BrainEntryOrganization) => { (organization: BrainEntryOrganization) => {
@ -164,7 +109,32 @@ export default function BrainEntryPage() {
return ( return (
<EntryShell> <EntryShell>
{choices ? ( {closed ? (
<section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
style={modalCardStyle}
>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
New signups are paused
</p>
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
Company Brain isn't accepting new workspaces right now. If you have
questions, reach us at support@supermemory.com.
</p>
<Button
variant="insideOut"
onClick={() => router.replace("/")}
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Go to Supermemory
</Button>
</section>
) : choices ? (
<section <section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8" className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
style={modalCardStyle} style={modalCardStyle}

View file

@ -6,12 +6,22 @@ import {
export default async function ConfigureSectionPage({ export default async function ConfigureSectionPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ section: string }> params: Promise<{ section: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) { }) {
const { section } = await params const { section } = await params
// Default section is canonical at /configure. // Carry the query across, else deep links like ?mcpSetup= are dropped here.
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure") 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() if (!isConfigureSection(section)) notFound()
return null return null
} }

View file

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

View file

@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth" import { authClient } from "@lib/auth"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { analytics } from "@/lib/analytics" import { analytics } from "@/lib/analytics"
import { hasCompanyBrain } from "@/lib/billing-utils"
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry" import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
import { BrainShell } from "@/components/onboarding-brain/shell" import { BrainShell } from "@/components/onboarding-brain/shell"
import { import {
@ -107,6 +108,22 @@ export default function BrainOnboardingPage() {
[user?.email], [user?.email],
) )
const [mode, setMode] = useState<BrainMode>(detectedMode) const [mode, setMode] = useState<BrainMode>(detectedMode)
const hasCompanyBrainOrg = useMemo(
() =>
(organizations ?? []).some((o) =>
hasCompanyBrain(
(o as { metadata?: Record<string, unknown> | string | null })
.metadata,
),
),
[organizations],
)
useEffect(() => {
if (mode === "team" && organizations != null && !hasCompanyBrainOrg) {
setMode("personal")
}
}, [mode, organizations, hasCompanyBrainOrg])
const [about, setAbout] = useState<AboutValues>({ const [about, setAbout] = useState<AboutValues>({
name: user?.name ?? "", name: user?.name ?? "",
about: "", about: "",
@ -280,12 +297,9 @@ export default function BrainOnboardingPage() {
const metadata: BrainMetadata & { signupSource: string } = { const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer", signupSource: "consumer",
brainOnboardingVersion: "v1", brainOnboardingVersion: "v1",
brainMode: mode, brainMode: "personal",
brainWorkspaceName: name, brainWorkspaceName: name,
brainWorkspaceDomain: brainWorkspaceDomain: null,
mode === "team"
? domainOverride || about.workspaceDomain || domain
: null,
brainContainerTag: containerTag, brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
} }
@ -368,7 +382,7 @@ export default function BrainOnboardingPage() {
setCreatingOrg(false) setCreatingOrg(false)
} }
}, [ensureOrg, goNext, forceCreate, organizations, router]) }, [ensureOrg, goNext, forceCreate, organizations, router])
const isCompanyBrain = mode === "team" const isCompanyBrain = mode === "team" && hasCompanyBrainOrg
const handleBrainConfirm = useCallback( const handleBrainConfirm = useCallback(
async ( async (
@ -385,10 +399,17 @@ export default function BrainOnboardingPage() {
workspaceDomain: confirmedDomain, workspaceDomain: confirmedDomain,
workspaceName: workspaceName || a.workspaceName, workspaceName: workspaceName || a.workspaceName,
})) }))
let orgCreated = false const signupsPaused = () => {
toast.error("New Company Brain signups are paused", {
description: "Questions? Reach us at support@supermemory.com.",
})
return { ok: false as const }
}
const orgCreated = false
if (forceCreate) { if (forceCreate) {
orgCreated = await ensureOrg(confirmedDomain, true) return signupsPaused()
} else if (organizationId) { }
if (organizationId) {
const selected = organizations?.find( const selected = organizations?.find(
(organization) => organization.id === organizationId, (organization) => organization.id === organizationId,
) )
@ -418,7 +439,7 @@ export default function BrainOnboardingPage() {
if (decision.action === "switch") { if (decision.action === "switch") {
await setActiveOrg(decision.organization.slug) await setActiveOrg(decision.organization.slug)
} else if (decision.action === "create") { } else if (decision.action === "create") {
orgCreated = await ensureOrg(confirmedDomain, true) return signupsPaused()
} }
} }
// Re-entering onboarding on an existing org ("Try onboarding") must // Re-entering onboarding on an existing org ("Try onboarding") must
@ -475,15 +496,7 @@ export default function BrainOnboardingPage() {
setCreatingOrg(false) setCreatingOrg(false)
} }
}, },
[ [forceCreate, org, organizations, queryClient, setActiveOrg, router],
ensureOrg,
forceCreate,
org,
organizations,
queryClient,
setActiveOrg,
router,
],
) )
const [sendingInvites, setSendingInvites] = useState(false) const [sendingInvites, setSendingInvites] = useState(false)
@ -541,6 +554,10 @@ export default function BrainOnboardingPage() {
const mcpUrl = "https://mcp.supermemory.ai/mcp" const mcpUrl = "https://mcp.supermemory.ai/mcp"
if (mode === "team" && organizations == null) {
return null
}
if (isCompanyBrain) { if (isCompanyBrain) {
return ( return (
<CompanyBrainOnboarding <CompanyBrainOnboarding
@ -565,10 +582,6 @@ export default function BrainOnboardingPage() {
{step === "about" && ( {step === "about" && (
<StepAbout <StepAbout
mode={mode} mode={mode}
onModeChange={(m) => {
analytics.onboardingModeSelected({ mode: m })
setMode(m)
}}
domain={domain} domain={domain}
suggestedWorkspaceName={suggestedWorkspaceName} suggestedWorkspaceName={suggestedWorkspaceName}
defaultName={user?.name ?? ""} defaultName={user?.name ?? ""}

View file

@ -591,6 +591,79 @@ export default function LoginPage() {
/> />
</div> </div>
) : null} ) : 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> </div>
<TextSeparator <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 { interface OGResponse {
title: string title: string
description: 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 { function isPrivateIPv4Octets(a: number, b: number): boolean {
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback), // 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 // 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) { export async function GET(request: Request) {
try { try {
if (!(await hasVerifiedSession(request))) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const url = searchParams.get("url") const url = searchParams.get("url")
@ -332,7 +374,13 @@ export async function GET(request: Request) {
if (contentType && !contentType.includes("text/html")) { if (contentType && !contentType.includes("text/html")) {
return Response.json({ title: "", description: "" }) 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) return processHtml(html, redirectUrl)
} }
} }
@ -349,7 +397,10 @@ export async function GET(request: Request) {
return Response.json({ title: "", description: "" }) 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) return processHtml(html, trimmedUrl)
} finally { } finally {
clearTimeout(timeoutId) 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

@ -1 +0,0 @@
export { default } from "../connect/page"

View file

@ -1,489 +0,0 @@
"use client"
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 Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
function isValidLocalhostCallback(callback: string): boolean {
try {
const url = new URL(callback)
const isLocalhost =
url.hostname === "localhost" || url.hostname === "127.0.0.1"
const isHttp = url.protocol === "http:"
const isCallbackPath = url.pathname === "/callback"
return isLocalhost && isHttp && isCallbackPath
} catch {
return false
}
}
interface PluginInfo {
name: string
description: string
features: string[]
icon: string
}
const PLUGIN_INFO: Record<string, PluginInfo> = {
claude_code: {
name: "Claude Code",
description:
"Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.",
features: [
"Auto-recalls relevant context at session start",
"Captures important observations from tool usage",
"Builds persistent user profile from interactions",
],
icon: "/images/plugins/claude-code.svg",
},
opencode: {
name: "OpenCode",
description:
"Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.",
features: [
"Semantic search across previous sessions",
"Auto-capture of coding decisions",
"Context injection before each prompt",
],
icon: "/images/plugins/opencode.svg",
},
openclaw: {
name: "OpenClaw",
description:
"Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.",
features: [
"Cross-channel memory persistence",
"Automatic conversation capture",
"User profile building across platforms",
],
icon: "/images/plugins/openclaw.svg",
},
hermes: {
name: "Hermes",
description: "Memory layer for Hermes agent",
features: [
"Semantic search across previous sessions",
"Auto-capture of conversation context",
"Builds persistent user profile from interactions",
],
icon: "/images/plugins/hermes.svg",
},
cursor: {
name: "Cursor",
description:
"Memory layer for Cursor. Enhances your AI coding assistant with persistent context.",
features: [
"Remembers coding patterns across sessions",
"Auto-capture of project decisions",
"Context-aware suggestions",
],
icon: "/images/plugins/cursor.svg",
},
codex: {
name: "OpenAI Codex",
description:
"Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.",
features: [
"Auto-recalls relevant context before each prompt",
"Captures coding decisions and patterns automatically",
"Builds persistent user profile across projects",
],
icon: "/images/plugins/codex.png",
},
}
function getPluginName(client: string): string {
return PLUGIN_INFO[client]?.name ?? "External Tool"
}
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
const cardClass = cn(
"bg-[#14161A] rounded-[14px] p-6 w-full max-w-[400px]",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)
function AuthConnectContent() {
const params = useSearchParams()
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
// Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here.
const shouldRedirectToOnboarding =
!isPending &&
!isRestoring &&
!!session &&
Array.isArray(organizations) &&
organizations.length === 0
useEffect(() => {
if (isPending || isRestoring) return
if (!session) return
if (organizations === null) return // orgs query still pending
if (organizations.length > 0) return // has orgs, nothing to do
try {
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
} catch (e) {
console.warn("Failed to access sessionStorage for pending connect URL", e)
}
router.replace("/onboarding")
}, [isPending, isRestoring, session, organizations, router])
async function handleConnect() {
if (!callback) {
setStatus("error")
setError("Missing callback parameter.")
return
}
if (!isValidLocalhostCallback(callback)) {
setStatus("error")
setError("Invalid callback URL.")
return
}
if (!session || !org) {
setStatus("error")
setError(
"Your account is not fully set up yet. Please complete onboarding first.",
)
return
}
try {
setStatus("creating")
const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient)
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
}
throw new Error(errorData.message || "Failed to get API key")
}
const data = (await res.json()) as { key: string }
setStatus("success")
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString()
} catch (err) {
console.error("Failed to get API key:", err)
setStatus("error")
setError(err instanceof Error ? err.message : "Failed to get API key")
}
}
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
if (isAuthLoading || shouldRedirectToOnboarding) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (status === "loading") {
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]",
)}
>
Connect {displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`}
</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={handleConnect}
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)]",
"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)",
}}
>
Approve Connection
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
</div>
</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}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-4 text-center">
<XCircle className="size-10 text-red-400" />
<div>
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
Connection failed
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{error}
</p>
</div>
<div className="flex flex-col gap-2 w-full">
<button
type="button"
onClick={() => window.location.reload()}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
"text-[13px] font-medium cursor-pointer transition-colors hover:bg-[#1E293B]",
dmSans125ClassName(),
)}
>
Try again
</button>
<a
href="https://app.supermemory.ai"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
Go to app
</a>
</div>
</div>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="flex flex-col items-center gap-3">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
{status === "creating" && `Connecting ${displayName}`}
{status === "success" &&
`Success! Redirecting back to ${displayName}`}
</p>
</div>
</div>
)
}
export default function AuthConnectPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
</div>
}
>
<AuthConnectContent />
</Suspense>
)
}

View file

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

View file

@ -19,6 +19,8 @@ import {
} from "lucide-react" } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { connectorPause } from "@/lib/connector-availability"
import { useConnectorNotify } from "@/lib/connector-notify"
import type { z } from "zod" import type { z } from "zod"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
@ -42,6 +44,7 @@ import {
getConnectionSubtitle, getConnectionSubtitle,
} from "@/components/settings/sync-utils" } from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type GDriveSyncScope = "scoped" | "full" type GDriveSyncScope = "scoped" | "full"
@ -309,6 +312,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) { export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const autumn = useCustomer() const autumn = useCustomer()
const promoCode = usePromoCode()
const { connectorAccess } = useConnectorAccess() const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] = const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null) useState<ConnectorProvider | null>(null)
@ -330,8 +334,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
try { try {
const result = await autumn.attach({ const result = await autumn.attach({
planId, planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href, successUrl: window.location.href,
}) })
promoCode.clear()
if (result?.paymentUrl) { if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self") window.open(result.paymentUrl, "_self")
return 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) => { const handleConnect = (provider: ConnectorProvider) => {
if (connectorPause(provider)) {
notify.request(provider)
return
}
setConnectingProvider(provider) setConnectingProvider(provider)
addConnectionMutation.mutate({ addConnectionMutation.mutate({
provider, provider,
@ -544,14 +557,32 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
<Icon className="size-6 text-[#737373]" /> <Icon className="size-6 text-[#737373]" />
<div className="space-y-[6px] flex-1"> <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]"> <p className="text-[16px] text-[#737373]">
{config.description} {config.description}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <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"> <div className="flex items-center rounded-md overflow-hidden">
<button <button
type="button" type="button"
@ -717,6 +748,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex flex-col"> <div className="flex flex-col">
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (connectorPause("google-drive")) {
notify.request("google-drive")
return
}
setConnectingProvider("google-drive") setConnectingProvider("google-drive")
addConnectionMutation.mutate({ addConnectionMutation.mutate({
provider: "google-drive", provider: "google-drive",
@ -737,6 +772,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (connectorPause("google-drive")) {
notify.request("google-drive")
return
}
setConnectingProvider("google-drive") setConnectingProvider("google-drive")
addConnectionMutation.mutate({ addConnectionMutation.mutate({
provider: "google-drive", provider: "google-drive",

View file

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

View file

@ -16,7 +16,6 @@ import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import type { ChatAttachmentDraft } from "@/components/chat/attachments" import type { ChatAttachmentDraft } from "@/components/chat/attachments"
import { DashboardView } from "@/components/dashboard-view" import { DashboardView } from "@/components/dashboard-view"
import { BrainHomeView } from "@/components/brain-home/brain-home-view" import { BrainHomeView } from "@/components/brain-home/brain-home-view"
import { CompanyBrainPromo } from "@/components/company-brain-promo"
import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { MemoriesGrid } from "@/components/memories-grid" import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view" import { GraphLayoutView } from "@/components/graph-layout-view"
@ -43,6 +42,7 @@ import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context" import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores" import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags" import { useContainerTags } from "@/hooks/use-container-tags"
import { isConnectorPaused } from "@/lib/connector-availability"
import { DEFAULT_PROJECT_ID } from "@lib/constants" import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { import {
useQuickNoteDraftReset, useQuickNoteDraftReset,
@ -603,6 +603,10 @@ export function AppExperience() {
const handleOpenIntegrations = useCallback( const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => { (integration?: IntegrationParamValue) => {
if (integration && isConnectorPaused(integration)) {
void setViewMode("integrations")
return
}
if (integration === "notion" || integration === "google-drive") { if (integration === "notion" || integration === "google-drive") {
void setAddDoc("connect") void setAddDoc("connect")
return return
@ -825,7 +829,6 @@ export function AppExperience() {
) : ( ) : (
<DashboardView <DashboardView
spaceLabel={dashboardSpaceLabel} spaceLabel={dashboardSpaceLabel}
headerNotice={<CompanyBrainPromo />}
highlights={highlightsData?.highlights ?? []} highlights={highlightsData?.highlights ?? []}
isLoadingHighlights={isLoadingHighlights} isLoadingHighlights={isLoadingHighlights}
onAddMemory={handleAddMemory} onAddMemory={handleAddMemory}

View file

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

View file

@ -8,7 +8,6 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
import { useQueryState } from "nuqs" import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal" import { useSettingsModal } from "@/components/settings/settings-modal"
import { useBrainTrial } from "@/hooks/use-brain-trial" import { useBrainTrial } from "@/hooks/use-brain-trial"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { useTrialStatus } from "@/hooks/use-trial-status" import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts" import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context" import { useViewMode } from "@/lib/view-mode-context"
@ -16,7 +15,6 @@ import {
AskInSlackCard, AskInSlackCard,
CONNECT_TOOLS_CARD_ID, CONNECT_TOOLS_CARD_ID,
ConnectToolsCard, ConnectToolsCard,
SlackBanner,
useConnectionsBoard, useConnectionsBoard,
} from "./connections-board" } from "./connections-board"
@ -172,7 +170,6 @@ export function BrainHomeView() {
const o = useBrainOverview() const o = useBrainOverview()
const trial = useBrainTrial() const trial = useBrainTrial()
const board = useConnectionsBoard() const board = useConnectionsBoard()
const { needsSetup } = useTrialStatus()
// Rows with no reported state (older orgs, pre-Slack) don't count or render. // Rows with no reported state (older orgs, pre-Slack) don't count or render.
const milestones = [ const milestones = [
...(o.researchStatus != null ? [o.researchStatus === "done"] : []), ...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
@ -189,7 +186,6 @@ export function BrainHomeView() {
return ( return (
<div className="mx-auto max-w-[1080px] space-y-6"> <div className="mx-auto max-w-[1080px] space-y-6">
<TrialSetupBanner />
<StatsRow <StatsRow
memories={o.memoriesCount} memories={o.memoriesCount}
connected={o.connectedCount} connected={o.connectedCount}
@ -199,7 +195,6 @@ export function BrainHomeView() {
setupTotal={milestonesTotal} setupTotal={milestonesTotal}
lastUpdatedAt={o.lastUpdatedAt} lastUpdatedAt={o.lastUpdatedAt}
/> />
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]"> <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 space-y-6"> <div className="min-w-0 space-y-6">
{board.showBoard && <ConnectToolsCard board={board} />} {board.showBoard && <ConnectToolsCard board={board} />}

View file

@ -18,11 +18,11 @@ const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const MCP_BASE = `${BACKEND}/brain/mcp-connections` const MCP_BASE = `${BACKEND}/brain/mcp-connections`
const cardStyle = { export const cardStyle = {
boxShadow: 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", "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: 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)", "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)",
} }
@ -442,71 +442,3 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
</div> </div>
) )
} }
export function SlackBanner() {
const { needsSetup } = useTrialStatus()
return (
<section
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
style={cardStyle}
>
<div
aria-hidden
className="absolute -top-px right-8 left-8 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-3.5">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F] sm:size-12"
style={tileStyle}
>
<SlackMark className="size-6 sm:size-7" />
</div>
<div className="min-w-0">
<p
className={cn(
"truncate text-[15px] font-semibold leading-tight text-[#fafafa] sm:text-[16px]",
dmSans125ClassName(),
)}
>
<span className="sm:hidden">Slack agent</span>
<span className="hidden sm:inline">Company Brain in Slack</span>
</p>
<p className="mt-1 truncate text-[12px] font-medium leading-[1.45] text-[#737373] sm:mt-0.5 sm:text-[13px] sm:leading-[1.5]">
<span className="sm:hidden">
Ask <span className="text-[#A1A1AA]">@supermemory</span> from
any channel.
</span>
<span className="hidden sm:inline">
Install Supermemory so your team can{" "}
<span className="text-[#A1A1AA]">@supermemory</span> in any
channel.
</span>
</p>
</div>
</div>
<a
href={
needsSetup ? "/onboarding" : `${BACKEND}/brain/slack/oauth/install`
}
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
>
{needsSetup ? (
<span>Start trial</span>
) : (
<>
<SlackMark className="hidden sm:block sm:size-[18px]" />
<span className="sm:hidden">Add</span>
<span className="hidden sm:inline">Add to Slack</span>
</>
)}
</a>
</div>
</section>
)
}

View file

@ -37,6 +37,7 @@ import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons" import { SlackMark } from "@/components/brain-connector-icons"
import { BrainTrialPill } from "@/components/brain-trial-pill" import { BrainTrialPill } from "@/components/brain-trial-pill"
import { SetupCallLink } from "@/components/setup-call-link"
import { GraphIcon } from "@/components/integration-icons" import { GraphIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector" import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu" import { UserProfileMenu } from "@/components/user-profile-menu"
@ -461,7 +462,7 @@ export function CompanyBrainHeader({
<Sun className="size-4 text-[#737373]" /> <Sun className="size-4 text-[#737373]" />
Integrations Integrations
</DropdownMenuItem> </DropdownMenuItem>
{slackConnected ? ( {slackConnected && (
<DropdownMenuItem <DropdownMenuItem
onClick={goConfigure} onClick={goConfigure}
className={menuItemClass} className={menuItemClass}
@ -469,13 +470,6 @@ export function CompanyBrainHeader({
<SlackMark className="size-4" /> <SlackMark className="size-4" />
Slack connected Slack connected
</DropdownMenuItem> </DropdownMenuItem>
) : (
<DropdownMenuItem asChild className={menuItemClass}>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</DropdownMenuItem>
)} )}
<DropdownMenuSeparator className="bg-[#2E3033]" /> <DropdownMenuSeparator className="bg-[#2E3033]" />
{canInvite && ( {canInvite && (
@ -538,29 +532,7 @@ export function CompanyBrainHeader({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{canInvite && ( <SetupCallLink />
<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>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -603,28 +575,9 @@ function SlackNavButton({
active: boolean active: boolean
onManage: () => void onManage: () => void
}) { }) {
const label = connected const label = `Slack${teamName ? ` · ${teamName}` : ""}`
? `Slack${teamName ? ` · ${teamName}` : ""}`
: "Add to Slack"
if (!connected) { if (!connected) return null
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
aria-label="Add to Slack"
className={circleNavClass(false)}
>
<SlackMark className="size-4" />
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add to Slack
</TooltipContent>
</Tooltip>
)
}
return ( return (
<Tooltip> <Tooltip>

View file

@ -1,89 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowRight, XIcon } from "lucide-react"
import { Logo } from "@ui/assets/Logo"
import { Button } from "@repo/ui/components/button"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { dmSansClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const DISMISS_KEY = "supermemory-company-brain-promo-dismissed-v1"
export function CompanyBrainPromo() {
const router = useRouter()
const hasCompanyBrain = useHasCompanyBrain()
const [dismissed, setDismissed] = useState(true)
useEffect(() => {
if (hasCompanyBrain) return
try {
setDismissed(localStorage.getItem(DISMISS_KEY) === "1")
} catch {
setDismissed(false)
}
}, [hasCompanyBrain])
const visible = !hasCompanyBrain && !dismissed
useEffect(() => {
if (visible) analytics.companyBrainPromoSeen()
}, [visible])
if (!visible) return null
const dismiss = () => {
setDismissed(true)
try {
localStorage.setItem(DISMISS_KEY, "1")
} catch {}
analytics.companyBrainPromoDismissed()
}
return (
<div
className={cn(
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
"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]">
<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>
<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]"
>
<XIcon className="size-4" />
</button>
</div>
)
}

View file

@ -31,8 +31,6 @@ import {
import { StaticGraphPreview } from "@/components/memory-graph/graph-card" import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons" import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import { SlackConnectCard } from "@/components/slack-connect-card"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons" import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
import { analytics } from "@/lib/analytics" import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params" import type { IntegrationParamValue } from "@/lib/search-params"
@ -1345,8 +1343,6 @@ export function DashboardView({
)} )}
> >
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5"> <div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
<TrialSetupBanner />
<SlackConnectCard />
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null} {headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
{/* Header */} {/* Header */}

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( const handleContentChange = useCallback(
(newContent: string) => { (newContent: string) => {
console.log("handleContentChange", newContent)
setContent(newContent) setContent(newContent)
setDraft(newContent) setDraft(newContent)
}, },

View file

@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react" import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { SectionRail } from "@/components/directory/section-rail"
import { $fetch } from "@lib/api" import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth" import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context" import { useAuth } from "@lib/auth-context"
@ -36,13 +37,17 @@ import {
FileText, FileText,
Globe, Globe,
Info, Info,
Bell,
Loader, Loader,
Pause,
Plus, Plus,
Search, Search,
X, X,
Zap, Zap,
} from "lucide-react" } from "lucide-react"
import { formatRelativeTime } from "@/components/settings/sync-utils" 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 { useConnectorAccess } from "@/hooks/use-connector-access"
import { useConnectionHealth } from "@/hooks/use-connection-health" import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useContainerTags } from "@/hooks/use-container-tags" import { useContainerTags } from "@/hooks/use-container-tags"
@ -71,8 +76,14 @@ import {
isFreeTierPlugin, isFreeTierPlugin,
normalizePluginClientId, normalizePluginClientId,
type InstallStep, type InstallStep,
type PluginInfo,
} from "@/lib/plugin-catalog" } from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" import {
CopyButton,
INSET,
InstallSteps,
PillButton,
} from "./integrations/install-steps"
import { import {
ShortcutsConnectButtons, ShortcutsConnectButtons,
useShortcutsConnect, useShortcutsConnect,
@ -80,6 +91,7 @@ import {
import { MCPSteps } from "./mcp-modal/mcp-detail-view" import { MCPSteps } from "./mcp-modal/mcp-detail-view"
import { GranolaConnectModal } from "./granola-connect-modal" import { GranolaConnectModal } from "./granola-connect-modal"
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space" import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
import { usePromoCode } from "@/hooks/use-promo-code"
type Connection = z.infer<typeof ConnectionResponseSchema> type Connection = z.infer<typeof ConnectionResponseSchema>
@ -536,13 +548,13 @@ const SECTIONS: Array<{
action: { type: "external", href: POKE_RECIPE_URL }, action: { type: "external", href: POKE_RECIPE_URL },
}, },
{ {
kind: "client", kind: "import",
id: "shortcuts", id: "x-bookmarks",
name: "Apple Shortcuts", name: "Import X bookmarks",
tagline: "Add memories from iPhone, iPad or Mac", tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Save anything from your phone or Mac", simpleTitle: "Turn your X bookmarks into memory",
icon: <AppleShortcutsIcon />, icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue }, viewMode: "import" as ViewParamValue,
}, },
{ {
kind: "client", kind: "client",
@ -555,13 +567,13 @@ const SECTIONS: Array<{
dev: true, dev: true,
}, },
{ {
kind: "import", kind: "client",
id: "x-bookmarks", id: "shortcuts",
name: "Import X bookmarks", name: "Apple Shortcuts",
tagline: "Turn your X/Twitter bookmarks into memories", tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Turn your X bookmarks into memory", simpleTitle: "Save anything from your phone or Mac",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />, icon: <AppleShortcutsIcon />,
viewMode: "import" as ViewParamValue, 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 }) { function ProChip({ children = "Pro" }: { children?: ReactNode }) {
return ( return (
<span <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 = { type InfoUseCase = {
title: string title: string
description: string description: string
@ -2067,6 +2325,8 @@ function ItemCard({
docsUrl, docsUrl,
leftIndicator, leftIndicator,
statusSlot, statusSlot,
layoutClassName,
paused,
}: { }: {
actionSlot: ReactNode actionSlot: ReactNode
infoActionSlot?: ReactNode infoActionSlot?: ReactNode
@ -2081,6 +2341,8 @@ function ItemCard({
docsUrl?: string docsUrl?: string
leftIndicator?: ReactNode leftIndicator?: ReactNode
statusSlot?: ReactNode statusSlot?: ReactNode
layoutClassName?: string
paused?: boolean
}) { }) {
const [infoOpen, setInfoOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false)
return ( return (
@ -2098,6 +2360,9 @@ function ItemCard({
className={cn( 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", "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)]", "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)} /> <ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
@ -2115,7 +2380,12 @@ function ItemCard({
<div className="flex shrink-0 items-start justify-between gap-2"> <div className="flex shrink-0 items-start justify-between gap-2">
<IconBox>{icon}</IconBox> <IconBox>{icon}</IconBox>
</div> </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="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
{leftIndicator} {leftIndicator}
@ -2127,7 +2397,8 @@ function ItemCard({
> >
{name} {name}
</span> </span>
{isNew && <NewChip />} {paused && <PausedChip />}
{isNew && !paused && <NewChip />}
{max ? <ProChip>Max</ProChip> : pro && <ProChip />} {max ? <ProChip>Max</ProChip> : pro && <ProChip />}
</div> </div>
<p <p
@ -2139,7 +2410,13 @@ function ItemCard({
{tagline} {tagline}
</p> </p>
</div> </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. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
<div <div
className="hidden min-w-0 flex-1 sm:flex" className="hidden min-w-0 flex-1 sm:flex"
@ -2150,7 +2427,11 @@ function ItemCard({
</div> </div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
<div <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()} onClick={(e) => e.stopPropagation()}
onKeyDown={(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({ export function IntegrationsView({
publicMode = false, publicMode = false,
onOpenDocument, onOpenDocument,
@ -2555,6 +2747,7 @@ export function IntegrationsView({
const { allProjects } = useContainerTags() const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect() const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } }) const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const promoCode = usePromoCode()
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
// stay on hasProProduct. See useConnectorAccess. // stay on hasProProduct. See useConnectorAccess.
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({ const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
@ -2566,18 +2759,21 @@ export function IntegrationsView({
const [connectingProvider, setConnectingProvider] = const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null) useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false) const [granolaModalOpen, setGranolaModalOpen] = useState(false)
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
const [newKey, setNewKey] = useState<{ const [newKey, setNewKey] = useState<{
open: boolean open: boolean
key: string key: string
pluginId: string | null pluginId: string | null
loading: boolean loading: boolean
}>({ open: false, key: "", pluginId: null, loading: false }) }>({ 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>( const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
null, null,
) )
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
)
const { data: pluginsData } = useQuery({ const { data: pluginsData } = useQuery({
queryFn: async () => { queryFn: async () => {
@ -2747,11 +2943,6 @@ export function IntegrationsView({
credentials: "include", credentials: "include",
}) })
if (!res.ok) { 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 { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }
@ -2761,12 +2952,7 @@ export function IntegrationsView({
}, },
onMutate: (pluginId) => setConnectingPlugin(pluginId), onMutate: (pluginId) => setConnectingPlugin(pluginId),
onError: (err) => { onError: (err) => {
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner. setNewKey((s) => ({ ...s, loading: false }))
setNewKey((s) =>
s.loading
? { open: false, key: "", pluginId: null, loading: false }
: s,
)
toast.error("Failed to connect plugin", { toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error", description: err instanceof Error ? err.message : "Unknown error",
}) })
@ -2776,10 +2962,32 @@ export function IntegrationsView({
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] }) queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
}, },
onSuccess: (data, pluginId) => { 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({ const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => { mutationFn: async (provider: ConnectorProvider) => {
const response = await $fetch("@post/connections/:provider", { 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( const handleUpgrade = useCallback(
async (planId?: unknown) => { async (planId?: unknown) => {
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro" const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
try { try {
const result = await autumn.attach({ const result = await autumn.attach({
planId: checkoutPlanId, planId: checkoutPlanId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`, successUrl: `${window.location.origin}/integrations`,
}) })
promoCode.clear()
if (result?.paymentUrl) { if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self") window.open(result.paymentUrl, "_self")
return return
@ -2842,7 +3058,7 @@ export function IntegrationsView({
toast.error("Failed to start checkout. Please try again.") toast.error("Failed to start checkout. Please try again.")
} }
}, },
[autumn], [autumn, promoCode],
) )
const redirectToLogin = useCallback(() => { const redirectToLogin = useCallback(() => {
@ -2909,10 +3125,7 @@ export function IntegrationsView({
void setConnectTarget(null) void setConnectTarget(null)
handleUpgrade("api_pro") handleUpgrade("api_pro")
} else { } else {
// Open instantly; the key fills in on mint. The ?connect param stays the source openPluginSetup(target)
// of truth until the modal closes.
setNewKey({ open: true, key: "", pluginId: target, loading: true })
createPluginKeyMutation.mutate(target)
} }
return return
} }
@ -2930,6 +3143,10 @@ export function IntegrationsView({
if (["notion", "google-drive", "onedrive"].includes(target)) { if (["notion", "google-drive", "onedrive"].includes(target)) {
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe. // The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
void setConnectTarget(null) void setConnectTarget(null)
if (connectorPause(target)) {
handlePausedConnector(target)
return
}
void setAddDoc("connect") void setAddDoc("connect")
} }
}, 0) }, 0)
@ -2947,8 +3164,9 @@ export function IntegrationsView({
redirectToLogin, redirectToLogin,
setConnectTarget, setConnectTarget,
setAddDoc, setAddDoc,
createPluginKeyMutation,
handleUpgrade, handleUpgrade,
openPluginSetup,
handlePausedConnector,
]) ])
const closeMcpModal = () => { const closeMcpModal = () => {
@ -3263,7 +3481,7 @@ export function IntegrationsView({
handleUpgrade("api_pro") handleUpgrade("api_pro")
return return
} }
createPluginKeyMutation.mutate("claude_code") openPluginSetup("claude_code")
}, },
}, },
{ {
@ -3341,7 +3559,7 @@ export function IntegrationsView({
return return
} }
trackCard(item) trackCard(item)
createPluginKeyMutation.mutate(item.pluginId) openPluginSetup(item.pluginId)
}} }}
disabled={!!connectingPlugin} disabled={!!connectingPlugin}
className={cn( className={cn(
@ -3362,12 +3580,7 @@ export function IntegrationsView({
<FinishSetupButton <FinishSetupButton
onClick={() => { onClick={() => {
trackCard(item) trackCard(item)
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) { openPluginSetup(item.pluginId)
if (connectingPlugin) return
createPluginKeyMutation.mutate(item.pluginId)
return
}
setFinishSetupPluginId(item.pluginId)
}} }}
/> />
) )
@ -3384,7 +3597,7 @@ export function IntegrationsView({
<PillButton <PillButton
onClick={() => { onClick={() => {
trackCard(item) trackCard(item)
createPluginKeyMutation.mutate(item.pluginId) openPluginSetup(item.pluginId)
}} }}
disabled={!!connectingPlugin} disabled={!!connectingPlugin}
> >
@ -3402,15 +3615,20 @@ export function IntegrationsView({
const count = connectionsByProvider[item.provider].length const count = connectionsByProvider[item.provider].length
const isGranola = item.provider === "granola" const isGranola = item.provider === "granola"
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
const pause = connectorPause(item.provider)
if (count > 0) { if (count > 0) {
return ( return (
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<button <button
type="button" type="button"
aria-label="Add another knowledge source" aria-label={pause ? "Paused" : "Add another knowledge source"}
title="Add another knowledge source" title={pause ? pause.message : "Add another knowledge source"}
onClick={() => { onClick={() => {
trackCard(item) trackCard(item)
if (pause) {
handlePausedConnector(item.provider)
return
}
if (isGranola) { if (isGranola) {
if (!connectorAccess) { if (!connectorAccess) {
handleUpgrade("api_pro") handleUpgrade("api_pro")
@ -3424,13 +3642,27 @@ export function IntegrationsView({
className={cn( 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", "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)]", "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> </button>
</div> </div>
) )
} }
if (pause) {
return (
<NotifyMeButton
onClick={() => trackCard(item)}
provider={item.provider}
title={pause.message}
/>
)
}
if (needsPlanUpgrade) { if (needsPlanUpgrade) {
return ( return (
<PillButton onClick={() => handleUpgrade("api_pro")}> <PillButton onClick={() => handleUpgrade("api_pro")}>
@ -3554,7 +3786,7 @@ export function IntegrationsView({
return return
} }
trackCard(item) trackCard(item)
createPluginKeyMutation.mutate(item.pluginId) openPluginSetup(item.pluginId)
}} }}
disabled={!!connectingPlugin} disabled={!!connectingPlugin}
> >
@ -3629,7 +3861,7 @@ export function IntegrationsView({
} }
} }
const renderItemCard = (item: Item) => ( const renderItemCard = (item: Item, layoutClassName?: string) => (
<ItemCard <ItemCard
key={item.id} key={item.id}
actionSlot={renderRight(item)} actionSlot={renderRight(item)}
@ -3645,6 +3877,8 @@ export function IntegrationsView({
docsUrl={item.docsUrl} docsUrl={item.docsUrl}
leftIndicator={renderLeftIndicator(item)} leftIndicator={renderLeftIndicator(item)}
statusSlot={renderStatus(item)} statusSlot={renderStatus(item)}
layoutClassName={layoutClassName}
paused={item.kind === "connector" && !!connectorPause(item.provider)}
/> />
) )
@ -3666,10 +3900,6 @@ export function IntegrationsView({
!isAutumnLoading && !isAutumnLoading &&
!hasProProduct && !hasProProduct &&
!isFreeTierPlugin(connectedPluginId) !isFreeTierPlugin(connectedPluginId)
const finishSetupPlugin = finishSetupPluginId
? PLUGIN_CATALOG[finishSetupPluginId]
: undefined
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
const pluginSteps = dialogPlugin?.installSteps ?? [] const pluginSteps = dialogPlugin?.installSteps ?? []
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
@ -3754,7 +3984,14 @@ export function IntegrationsView({
</p> </p>
) : q || category !== "all" ? ( ) : q || category !== "all" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <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>
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -3767,6 +4004,23 @@ export function IntegrationsView({
<SectionRail <SectionRail
key={cat} key={cat}
label={CATEGORY_LABEL[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={ headerSlot={
cat === "ai-clients" && activeMcpKey ? ( cat === "ai-clients" && activeMcpKey ? (
<McpConnectedPill <McpConnectedPill
@ -3812,6 +4066,11 @@ export function IntegrationsView({
</div> </div>
</div> </div>
<PluginCommandsDialog
open={pluginCommandsOpen}
onOpenChange={setPluginCommandsOpen}
/>
<Dialog <Dialog
open={newKey.open} open={newKey.open}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -3821,7 +4080,10 @@ export function IntegrationsView({
pluginId: open ? s.pluginId : null, pluginId: open ? s.pluginId : null,
loading: open ? s.loading : false, loading: open ? s.loading : false,
})) }))
if (!open) void setConnectTarget(null) if (!open) {
setPluginSetupTab("agent")
void setConnectTarget(null)
}
}} }}
> >
<DialogContent <DialogContent
@ -3854,9 +4116,11 @@ export function IntegrationsView({
Set up {dialogPlugin?.name ?? "your plugin"} Set up {dialogPlugin?.name ?? "your plugin"}
</p> </p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]"> <p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
{newKey.loading {pluginSetupTab === "agent"
? "Generating your key…" ? "Copy this prompt into your coding agent."
: "Copy your key and run these steps to finish."} : newKey.loading
? "Generating your key…"
: "Follow these steps to finish manually."}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <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="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div <div
className={cn( 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, 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]"> <div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
<Loader className="size-4 animate-spin" /> <Loader className="size-4 animate-spin" />
Generating your key Generating your key
</div> </div>
) : ( ) : newKey.key ? (
<InstallSteps steps={setupSteps} apiKey={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>
</div> </div>
@ -3913,6 +4190,7 @@ export function IntegrationsView({
pluginId: null, pluginId: null,
loading: false, loading: false,
}) })
setPluginSetupTab("agent")
void setConnectTarget(null) void setConnectTarget(null)
}} }}
className={cn( className={cn(
@ -4051,7 +4329,7 @@ export function IntegrationsView({
if (!connectedPluginId) return if (!connectedPluginId) return
const pluginId = connectedPluginId const pluginId = connectedPluginId
setConnectedPluginId(null) setConnectedPluginId(null)
createPluginKeyMutation.mutate(pluginId) openPluginSetup(pluginId)
}} }}
disabled={!!connectingPlugin} disabled={!!connectingPlugin}
> >
@ -4082,91 +4360,6 @@ export function IntegrationsView({
</DialogContent> </DialogContent>
</Dialog> </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 <Dialog
open={mcpModalOpen} open={mcpModalOpen}
onOpenChange={(open) => { onOpenChange={(open) => {

View file

@ -16,17 +16,22 @@ export function PillButton({
onClick, onClick,
disabled, disabled,
type = "button", type = "button",
className,
title,
}: { }: {
children: ReactNode children: ReactNode
onClick?: () => void onClick?: () => void
disabled?: boolean disabled?: boolean
type?: "button" | "submit" type?: "button" | "submit"
className?: string
title?: string
}) { }) {
return ( return (
<button <button
type={type} type={type}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
title={title}
className={cn( className={cn(
dmSans125ClassName(), 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", "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)]", "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80", "cursor-pointer transition-opacity hover:opacity-80",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
className,
)} )}
> >
{children} {children}

View file

@ -30,6 +30,7 @@ import {
type PluginInfo, type PluginInfo,
} from "@/lib/plugin-catalog" } from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./install-steps" import { INSET, InstallSteps, PillButton } from "./install-steps"
import { usePromoCode } from "@/hooks/use-promo-code"
interface ConnectedPlugin { interface ConnectedPlugin {
id: string 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() { export function PluginsDetail() {
const { org } = useAuth() const { org } = useAuth()
const autumn = useCustomer() const autumn = useCustomer()
const promoCode = usePromoCode()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null) const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>( const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null, null,
@ -572,11 +536,6 @@ export function PluginsDetail() {
credentials: "include", credentials: "include",
}) })
if (!res.ok) { 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 { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }
@ -613,8 +572,10 @@ export function PluginsDetail() {
try { try {
const result = await autumn.attach({ const result = await autumn.attach({
planId: "api_pro", planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`, successUrl: `${window.location.origin}/integrations`,
}) })
promoCode.clear()
if (result?.paymentUrl) { if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self") window.open(result.paymentUrl, "_self")
return return
@ -635,17 +596,12 @@ export function PluginsDetail() {
) )
const visibleRows = useMemo(() => { 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). // Connected plugins float to the top (stable within each group).
return [...filtered].sort( return [...catalogRows].sort(
(a, b) => (a, b) =>
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)), Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
) )
}, [catalogRows, tierFilter, connectedPluginIds]) }, [catalogRows, connectedPluginIds])
const dialogPlugin = newKey.pluginId const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId] ? PLUGIN_CATALOG[newKey.pluginId]
@ -684,12 +640,7 @@ export function PluginsDetail() {
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3"> <SectionHeader>Plugins</SectionHeader>
<SectionHeader>Plugins</SectionHeader>
{catalogRows.length > 0 && (
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
)}
</div>
<div className="flex flex-col"> <div className="flex flex-col">
{visibleRows.map((pluginId) => { {visibleRows.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId] const plugin = PLUGIN_CATALOG[pluginId]

View file

@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
}) { }) {
const { connect, isPending, pendingType } = controller const { connect, isPending, pendingType } = controller
return ( 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 <PillButton
className="h-9 flex-none" className="h-9 flex-none"
onClick={(e) => { onClick={(e) => {

View file

@ -3,6 +3,7 @@
import { LogoFull } from "@ui/assets/Logo" import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button" import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input" import { Input } from "@ui/components/input"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
import { import {
ArrowRight, ArrowRight,
@ -15,6 +16,7 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react" import { type ReactNode, useEffect, useRef, useState } from "react"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { import {
type ResearchEvent, type ResearchEvent,
@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({
setPhase("trial") setPhase("trial")
analytics.brainTrialCardViewed() analytics.brainTrialCardViewed()
}, [needsSetup, phase]) }, [needsSetup, phase])
const { org } = useAuth()
const [domain, setDomain] = useState(initialDomain) const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState< const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null CompanyBrainOrganizationChoice[] | null
>(null) >(null)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false) const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? "" 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 queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research") const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done" const researchDone = researchStatus === "done"

View file

@ -16,6 +16,7 @@ import {
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useResearchStatus } from "@/hooks/use-research-status" import { useResearchStatus } from "@/hooks/use-research-status"
import { dmSans125ClassName } from "@/lib/fonts" import { dmSans125ClassName } from "@/lib/fonts"
import { SetupCallButton } from "./setup-call-button"
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about" import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
const BACKEND = const BACKEND =
@ -480,6 +481,14 @@ export function ResearchActionRail({
})} })}
</ol> </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>
</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

@ -33,7 +33,6 @@ export interface AboutValues {
interface Props { interface Props {
mode: BrainMode mode: BrainMode
onModeChange: (m: BrainMode) => void
domain: string | null domain: string | null
suggestedWorkspaceName: string suggestedWorkspaceName: string
defaultName: string defaultName: string
@ -60,7 +59,6 @@ export const inputClass =
export function StepAbout({ export function StepAbout({
mode, mode,
onModeChange,
domain, domain,
suggestedWorkspaceName, suggestedWorkspaceName,
defaultName, defaultName,
@ -165,9 +163,7 @@ export function StepAbout({
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8" className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
style={cardSurfaceStyle} style={cardSurfaceStyle}
> >
<ModeToggle mode={mode} onChange={onModeChange} /> <div className="flex items-center gap-4">
<div className="mt-7 flex items-center gap-4">
<UserAvatar <UserAvatar
url={avatarUrl} url={avatarUrl}
name={values.name || defaultName} name={values.name || defaultName}
@ -407,53 +403,6 @@ export function DomainLogo({ domain }: { domain: string }) {
) )
} }
function ModeToggle({
mode,
onChange,
}: {
mode: BrainMode
onChange: (m: BrainMode) => void
}) {
const items: { id: BrainMode; label: string }[] = [
{ id: "personal", label: "Personal" },
{ id: "team", label: "Team" },
]
return (
<div
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
animate={{ x: mode === "team" ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
/>
{items.map((item) => {
const isActive = mode === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
isActive
? "text-[#fafafa]"
: "text-[#737373] hover:text-[#fafafa]",
)}
>
{item.label}
</button>
)
})}
</div>
)
}
export function UserAvatar({ export function UserAvatar({
url, url,
name, name,

View file

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

View file

@ -2,36 +2,12 @@
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons" import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
import { GradientLogo } from "@ui/assets/Logo" import { GradientLogo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
import { ArrowRight, Loader2, ShieldCheck } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { SlackMark } from "@/components/brain-connector-icons" import { SlackMark } from "@/components/brain-connector-icons"
import { analytics } from "@/lib/analytics"
import { dmSans125ClassName } from "@/lib/fonts" import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export const CHECKOUT_RETURN_PARAM = "brainTrial" export const CHECKOUT_RETURN_PARAM = "brainTrial"
const TRIAL_DAYS = 14
/** The only reminder that lands before the charge; 15 and 17 are post-trial. */
const REMINDER_DAY = 12
const MONTHLY_PRICE = "$100"
function checkoutReturnUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete")
return url.toString()
}
function dayOffset(days: number): string {
const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
const ORBIT = [ const ORBIT = [
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> }, { key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> }, { key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
@ -85,91 +61,11 @@ function BrainPanel() {
) )
} }
function TimelineRow({ // Trial checkout is disabled while new Company Brain signups are paused.
date, export function StepTrial(_props: { onActive: () => void }) {
title,
value,
current,
}: {
date: string
title: string
value?: string
current?: boolean
}) {
return (
<li className="relative flex items-start gap-3 pl-[18px]">
<span
aria-hidden="true"
className={cn(
"absolute left-0 top-[5px] size-[7px] rounded-full",
current
? "bg-[#fafafa] ring-4 ring-[#fafafa]/10"
: "bg-[#2b3138] ring-1 ring-white/15",
)}
/>
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-[13px] font-medium text-[#fafafa]">{date}</span>
<span className="text-[12px] leading-snug text-[#8b8b8b]">
{title}
</span>
</div>
{value ? (
<span className="shrink-0 text-[14px] font-medium text-[#fafafa] tabular-nums">
{value}
</span>
) : null}
</div>
</li>
)
}
export function StepTrial({ onActive }: { onActive: () => void }) {
const [starting, setStarting] = useState(false)
const start = async () => {
if (starting) return
setStarting(true)
analytics.brainTrialCheckoutStarted()
try {
const res = await fetch(`${BACKEND}/brain/trial/start`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ successUrl: checkoutReturnUrl() }),
})
const data = (await res.json()) as {
checkoutUrl?: string | null
status?: string
error?: string
}
if (res.status === 409 || data.error === "trial_unavailable") {
throw new Error(
"This workspace has already used its free trial. Upgrade from billing to continue.",
)
}
if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.")
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl
return
}
if (data.status === "already_active" || data.status === "attached") {
onActive()
return
}
throw new Error("Couldn't start the trial.")
} catch (error) {
console.error("Failed to start trial:", error)
toast.error(
error instanceof Error ? error.message : "Couldn't start the trial.",
)
setStarting(false)
}
}
return ( return (
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex min-w-0 flex-1 flex-col gap-5"> <div className="flex min-w-0 flex-1 flex-col justify-center gap-5">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<h2 <h2
className={cn( className={cn(
@ -177,58 +73,11 @@ export function StepTrial({ onActive }: { onActive: () => void }) {
"text-[22px] leading-tight font-medium text-[#fafafa]", "text-[22px] leading-tight font-medium text-[#fafafa]",
)} )}
> >
Start your {TRIAL_DAYS}-day free trial New signups are paused
</h2> </h2>
<p className="text-[13px] leading-relaxed text-[#8b8b8b]"> <p className="text-[13px] leading-relaxed text-[#8b8b8b]">
Add a payment method to start. You will not be charged today. We Company Brain isn't accepting new workspaces right now. If you have
will email you before your first payment. questions, reach us at support@supermemory.com.
</p>
</div>
<ol className="relative flex flex-col gap-5 py-1">
<span
aria-hidden="true"
className="absolute left-[3px] top-2.5 bottom-[22px] w-px bg-white/10"
/>
<TimelineRow
date="Today"
title="Full access to Company Brain"
value="$0"
current
/>
<TimelineRow
date={dayOffset(REMINDER_DAY)}
title="We email you before the charge"
/>
<TimelineRow
date={dayOffset(TRIAL_DAYS)}
title="Trial ends"
value={`${MONTHLY_PRICE}/mo`}
/>
</ol>
<div className="flex flex-col items-center gap-3">
<Button
variant="insideOut"
onClick={start}
disabled={starting}
className="w-full justify-center rounded-full px-5 py-[11px] text-[13px] font-medium text-[#fafafa]"
>
{starting ? (
<>
Opening checkout
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Start free trial
<ArrowRight className="size-3.5" />
</>
)}
</Button>
<p className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<ShieldCheck className="size-3.5" />
Secured by Stripe · Cancel in one click
</p> </p>
</div> </div>
</div> </div>

View file

@ -394,11 +394,6 @@ export function SelectSpacesModal({
credentials: "include", credentials: "include",
}) })
if (!res.ok) { 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 { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -90,13 +90,6 @@ const ROWS: {
effortHelp: effortHelp:
"Deeper thinking routes messages more carefully but takes longer.", "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> type FullConfig = Required<BrainModelConfig>
@ -132,10 +125,8 @@ const PRESETS: PresetDef[] = [
? "grok-4-fast" ? "grok-4-fast"
: defaults.main, : defaults.main,
triage: defaults.triage, triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "low", "low"), mainEffort: pickEffort(choices.mainEffort, "low", "low"),
triageEffort: pickEffort(choices.triageEffort, "low", "low"), triageEffort: pickEffort(choices.triageEffort, "low", "low"),
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
}), }),
}, },
{ {
@ -145,35 +136,24 @@ const PRESETS: PresetDef[] = [
build: (defaults) => ({ build: (defaults) => ({
main: defaults.main, main: defaults.main,
triage: defaults.triage, triage: defaults.triage,
research: defaults.research,
mainEffort: defaults.mainEffort ?? "high", mainEffort: defaults.mainEffort ?? "high",
triageEffort: defaults.triageEffort ?? "low", triageEffort: defaults.triageEffort ?? "low",
researchEffort: defaults.researchEffort ?? "high",
}), }),
}, },
{ {
id: "thorough", id: "thorough",
label: "Most thorough", label: "Most thorough",
description: "Deepest answers and research. Slower, uses more credits.", description: "Deepest answers. Slower, uses more credits.",
build: (defaults, choices) => ({ build: (defaults, choices) => ({
main: defaults.main, main: defaults.main,
triage: defaults.triage, triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"), mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
triageEffort: pickEffort(choices.triageEffort, "medium", "low"), triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
}), }),
}, },
] ]
const CONFIG_KEYS = [ const CONFIG_KEYS = ["main", "triage", "mainEffort", "triageEffort"] as const
"main",
"triage",
"research",
"mainEffort",
"triageEffort",
"researchEffort",
] as const
const extraHighIsBounded = (model: string): boolean => const extraHighIsBounded = (model: string): boolean =>
model.startsWith("grok-") || model.startsWith("gpt-") model.startsWith("grok-") || model.startsWith("gpt-")

View file

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

@ -1,120 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SlackStatus = { connected: boolean; teamName: string | null }
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
export function SlackConnectCard() {
const isCompanyBrain = useHasCompanyBrain()
const [status, setStatus] = useState<SlackStatus | null>(null)
const [trialActive, setTrialActive] = useState(true)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isCompanyBrain) return
let active = true
;(async () => {
try {
const [slackRes, trialRes] = await Promise.all([
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }),
])
if (!active) return
if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus)
if (trialRes.ok) {
const trial = (await trialRes.json()) as { active?: boolean }
setTrialActive(Boolean(trial.active))
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [isCompanyBrain])
if (!isCompanyBrain || loading) return null
const connected = status?.connected
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:px-5">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg-primary">
Add Supermemory to your Slack
</p>
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
{connected
? `Connected to ${status?.teamName ?? "your workspace"}.`
: "Answer from your company brain and act on connected apps — right inside Slack."}
</p>
</div>
{connected ? (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-surface-skeleton px-3 py-1.5 text-[12px] font-medium text-fg-muted ring-1 ring-surface-border">
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
Connected
</span>
) : trialActive ? (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
>
<SlackMark className="size-4" />
Add to Slack
</a>
) : (
<a
href="/onboarding"
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white/10 px-3.5 py-2 text-[13px] font-semibold text-fg-primary ring-1 ring-surface-border transition-colors hover:bg-white/15"
>
Finish setting up
</a>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more