chore: merge latest stacked changes

This commit is contained in:
ved015 2026-08-22 19:57:18 +05:30
commit 8a1fae3bb7
101 changed files with 4967 additions and 1627 deletions

View file

@ -9,97 +9,231 @@ on:
- "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
name: agent-framework-python (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.13"]
defaults:
run:
working-directory: packages/agent-framework-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/agent-framework-python/pyproject.toml
- name: Install package and test dependencies
run: |
pip install -e .
pip install pytest pytest-asyncio
- 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 runtime dependencies
run: python -m pip install "$RUNNER_TEMP"/wheels/*.whl
- name: Check dependency compatibility
run: python -m pip check
- name: Verify installed wheel
run: >-
python -c "from pathlib import Path; import supermemory_agent_framework;
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
- name: Run tests
run: pytest
run: python -m pytest
openai-sdk-python:
name: 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@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
- name: Install package and test dependencies
run: |
pip install -e .
pip install pytest pytest-asyncio python-dotenv
- 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: Run tests
run: pytest
- 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
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-supermemory
supermemory-spec: "supermemory==3.16.0"
supermemory-version: "3.16.0"
- python-version: "3.12"
dependency-lane: current-supermemory
supermemory-spec: "supermemory==3.59.0"
supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/cartesia-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
# Suite stubs cartesia-line / loguru / pydantic at import time, so it
# runs against sources with nothing installed.
- name: Run tests
run: PYTHONPATH=src python -m unittest discover -s tests -v
- name: Install build tools and lightweight test dependencies
run: >-
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
"${{ matrix.supermemory-spec }}"
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel without the voice framework
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
- name: Run tests against the installed wheel and real lightweight dependencies
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
import supermemory_cartesia;
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
raise SystemExit(result)"
pipecat-sdk-python:
name: pipecat-sdk-python
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-supermemory
supermemory-spec: "supermemory==3.16.0"
supermemory-version: "3.16.0"
- python-version: "3.12"
dependency-lane: current-supermemory
supermemory-spec: "supermemory==3.59.0"
supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/pipecat-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
# Suite stubs pipecat-ai / loguru / pydantic at import time, so it
# runs against sources with nothing installed.
- name: Run tests
run: PYTHONPATH=src python -m unittest discover -s tests -v
- name: Install build tools and lightweight test dependencies
run: >-
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
"${{ matrix.supermemory-spec }}"
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel without the voice framework
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
- name: Run tests against the installed wheel and real lightweight dependencies
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
import supermemory_pipecat;
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
raise SystemExit(result)"

View file

@ -29,5 +29,22 @@ jobs:
- name: Run TypeScript type checking
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
- name: Detect Tools package changes
id: tools-changes
run: |
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Run Tools unit tests
if: steps.tools-changes.outputs.changed == 'true'
run: bun run --cwd packages/tools test:unit
- name: Build Tools package
if: steps.tools-changes.outputs.changed == 'true'
run: bun run --cwd packages/tools build
- name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
memoryLength: memoryText.length,
})
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
memoryLength: memoryText.length,
})
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
if (response?.success && response?.data && input) {
const memoryText = showMemorySuggestion("gemini", input, response.data)
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
iconElement.dataset.supermemories = memoryText
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -12,6 +12,46 @@ export function buildSupermemoryText(memories: unknown): string {
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
}
function normalizeMemoryList(memories: unknown): string[] {
const list = Array.isArray(memories)
? memories
: memories == null
? []
: [memories]
return list
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0)
}
export function serializeMemoriesForDataset(memories: unknown): string {
const list = normalizeMemoryList(memories)
return list.length > 0 ? JSON.stringify(list) : ""
}
export function parseMemoriesFromDataset(
raw: string | null | undefined,
): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
} catch {
// Not JSON — fall through to the legacy delimiter split.
}
return raw
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
}
export function renumberIncludedMemories(memories: string[]): string[] {
return memories.map((memory, index) => {
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
return `${index + 1}. ${text} \n`
})
}
export function showMemorySuggestion(
platform: string,
input: SuggestionInput,
@ -305,10 +345,7 @@ export function showMarkerPopover(
color: rgba(255, 255, 255, 0.76);
`
memories
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
parseMemoriesFromDataset(memories)
.slice(0, 5)
.forEach((memory) => {
const item = document.createElement("div")

View file

@ -10,11 +10,30 @@ import {
autoCapturePromptsEnabled,
} from "../../utils/storage"
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
import {
buildSupermemoryText,
parseMemoriesFromDataset,
renumberIncludedMemories,
serializeMemoriesForDataset,
} from "./memory-suggestion"
let t3DebounceTimeout: NodeJS.Timeout | null = null
let t3RouteObserver: MutationObserver | null = null
let t3UrlCheckInterval: NodeJS.Timeout | null = null
let t3ObserverThrottle: NodeJS.Timeout | null = null
let t3IncludedPopup: {
el: HTMLElement
onClick: (event: MouseEvent) => void
timer: ReturnType<typeof setTimeout>
} | null = null
function disposeT3IncludedPopup() {
if (!t3IncludedPopup) return
document.removeEventListener("click", t3IncludedPopup.onClick)
clearTimeout(t3IncludedPopup.timer)
t3IncludedPopup.el.remove()
t3IncludedPopup = null
}
export function initializeT3() {
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
@ -53,6 +72,7 @@ function setupT3RouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
disposeT3IncludedPopup()
currentUrl = window.location.href
setTimeout(() => {
addSupermemoryIconToT3Input()
@ -231,9 +251,13 @@ async function getRelatedMemoriesForT3(actionSource: string) {
}
if (textareaElement) {
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
textareaElement.dataset.supermemories = buildSupermemoryText(
response.data,
)
iconElement.dataset.memoriesData = response.data
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
updateT3IconFeedback("Included Memories", iconElement)
} else {
@ -268,6 +292,8 @@ function updateT3IconFeedback(
iconElement.dataset.originalHtml = iconElement.innerHTML
}
disposeT3IncludedPopup()
const feedbackDiv = document.createElement("div")
feedbackDiv.style.cssText = `
display: flex;
@ -329,11 +355,9 @@ function updateT3IconFeedback(
overflow-y: auto;
`
const memoriesText = iconElement.dataset.memoriesData || ""
const individualMemories = memoriesText
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
const individualMemories = parseMemoriesFromDataset(
iconElement.dataset.memoriesData,
)
individualMemories.forEach((memory, index) => {
const memoryItem = document.createElement("div")
@ -405,66 +429,65 @@ function updateT3IconFeedback(
popup.style.display = "block"
})
document.addEventListener("click", (e) => {
const onClick = (e: MouseEvent) => {
if (!popup.contains(e.target as Node)) {
popup.style.display = "none"
}
})
}
document.addEventListener("click", onClick)
t3IncludedPopup = {
el: popup,
onClick,
timer: setTimeout(disposeT3IncludedPopup, 300000),
}
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
const htmlButton = button as HTMLButtonElement
htmlButton.addEventListener("click", () => {
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
const memoryItem = htmlButton.parentElement
htmlButton.parentElement?.remove()
if (memoryItem) {
content.removeChild(memoryItem)
}
const currentMemories = (iconElement.dataset.memoriesData || "")
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
currentMemories.splice(index, 1)
const updatedMemories = currentMemories.join(" ,")
iconElement.dataset.memoriesData = updatedMemories
const remainingMemories = parseMemoriesFromDataset(
iconElement.dataset.memoriesData,
)
remainingMemories.splice(index, 1)
const remaining = renumberIncludedMemories(remainingMemories)
const textareaElement =
(document.querySelector("textarea") as HTMLTextAreaElement) ||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
// Only wipe when nothing remains — `<= 1` used to discard the last kept memory.
if (remaining.length === 0) {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
delete iconElement.dataset.memoriesData
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
delete iconElement.dataset.originalHtml
disposeT3IncludedPopup()
return
}
iconElement.dataset.memoriesData =
serializeMemoriesForDataset(remaining)
if (textareaElement) {
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
textareaElement.dataset.supermemories =
buildSupermemoryText(remaining)
}
content
.querySelectorAll("button[data-memory-index]")
.forEach((btn, newIndex) => {
const htmlBtn = btn as HTMLButtonElement
htmlBtn.dataset.memoryIndex = newIndex.toString()
htmlBtn.dataset.memoryIndex = String(newIndex)
const label = htmlBtn.previousElementSibling
if (label) {
label.textContent = remaining[newIndex].trim()
}
})
if (currentMemories.length <= 1) {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
delete iconElement.dataset.memoriesData
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
delete iconElement.dataset.originalHtml
}
popup.style.display = "none"
if (document.body.contains(popup)) {
document.body.removeChild(popup)
}
}
})
})
setTimeout(() => {
if (document.body.contains(popup)) {
document.body.removeChild(popup)
}
}, 300000)
}
iconElement.innerHTML = ""
@ -556,6 +579,7 @@ function setupT3PromptCapture() {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
disposeT3IncludedPopup()
}
const handleT3SendButtonClick = async (event: Event) => {
@ -711,6 +735,7 @@ async function setupT3AutoFetch() {
if (textareaElement.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
disposeT3IncludedPopup()
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}

View file

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

View file

@ -194,6 +194,11 @@
{
"group": "Other resources",
"pages": [
{
"group": "General",
"icon": "book-open",
"pages": ["ingestion/batch-ingest-historical-data"]
},
{
"group": "Benchmarking",
"icon": "flask-conical",

View file

@ -496,6 +496,7 @@ console.log(doc.status); // "queued" | "processing" | "done"
## Next Steps
- [How to backfill historical data](/ingestion/batch-ingest-historical-data) — Import dated content with the batch API
- [Search Memories](/recall/search) — Query your content
- [User Profiles](/recall/user-profiles) — Get user context
- [Organizing & Filtering](/concepts/filtering) — Container tags and metadata

View file

@ -0,0 +1,145 @@
---
title: "How to backfill historical data into Supermemory"
sidebarTitle: "Backfill historical data"
description: "Backfill historical documents into Supermemory with documentDate, stable custom IDs, and the batch ingestion API."
icon: "history"
---
Use `POST /v3/documents/batch` to backfill exports, emails, messages, or other dated records.
<Warning>
Sort the source data oldest to newest, add `documentDate` to every document.
</Warning>
## Backfill in batches
Backfill dated content by setting `documentDate` on each document, sorting the source records oldest to newest, and sending them in batches. Each request can contain up to 600 documents.
**Endpoint:** [`POST /v3/documents/batch`](/api-reference/ingest/batch-add-documents)
<CodeGroup>
```typescript TypeScript
import Supermemory from "supermemory";
type SourceDocument = {
id: string;
content: string;
createdAt: string;
};
const client = new Supermemory();
const batchSize = 100;
async function backfillHistoricalData(sourceDocuments: SourceDocument[]) {
const documents = sourceDocuments
.map((document) => ({
content: document.content,
customId: document.id,
documentDate: new Date(document.createdAt).toISOString()
}))
.sort((a, b) => a.documentDate.localeCompare(b.documentDate));
for (let offset = 0; offset < documents.length; offset += batchSize) {
const result = await client.documents.batchAdd({
containerTag: "historical_import",
documents: documents.slice(offset, offset + batchSize)
});
if (result.failed > 0) {
throw new Error(`${result.failed} documents failed to ingest`);
}
}
}
```
```python Python
from datetime import datetime, timezone
from supermemory import Supermemory
client = Supermemory()
batch_size = 100
def to_utc(value: str) -> str:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("created_at must include a timezone")
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def backfill_historical_data(source_documents: list[dict[str, str]]) -> None:
documents = sorted(
[
{
"content": document["content"],
"custom_id": document["id"],
"document_date": to_utc(document["created_at"]),
}
for document in source_documents
],
key=lambda document: document["document_date"],
)
for offset in range(0, len(documents), batch_size):
result = client.documents.batch_add(
container_tag="historical_import",
documents=documents[offset : offset + batch_size],
)
if result.failed > 0:
raise RuntimeError(f"{result.failed} documents failed to ingest")
```
</CodeGroup>
## Optional: wait for processing to finish
**Endpoint:** [`GET /v3/documents/{id}`](/api-reference/documents/get-document)
The batch endpoint returns after accepting the documents. If a later step depends on completed memory generation, poll the returned document IDs until both `status` and `dreamingStatus` are `done`.
<CodeGroup>
```typescript TypeScript
async function waitUntilDone(ids: string[]) {
while (true) {
const documents = await Promise.all(
ids.map((id) => client.documents.get(id))
);
if (documents.some((document) => document.status === "failed")) {
throw new Error("A document failed to process");
}
if (
documents.every(
(document) =>
document.status === "done" && document.dreamingStatus === "done"
)
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
```
```python Python
import time
def wait_until_done(ids: list[str]) -> None:
while True:
documents = [client.documents.get(document_id) for document_id in ids]
if any(document.status == "failed" for document in documents):
raise RuntimeError("A document failed to process")
if all(
document.status == "done" and document.dreaming_status == "done"
for document in documents
):
return
time.sleep(10)
```
</CodeGroup>

View file

@ -27,6 +27,28 @@ bunx supermemory local
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
### Pin or change versions
Pass an explicit version to install (or roll back to) a specific release instead of `latest`:
```bash
curl -fsSL https://supermemory.ai/install | bash -s -- 0.0.3
```
<Warning>
Before rolling back, back up your [data directory](#where-things-live). The installer replaces the binary, but an older server may not understand data or schema changes made by a newer release.
</Warning>
Release tags are `server-v<version>` on [GitHub Releases](https://github.com/supermemoryai/supermemory/releases) (for example [`server-v0.0.3`](https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.3)).
To move to the newest release later:
```bash
supermemory-server upgrade
```
The binary may also print an “update available” notification on startup. If you intentionally pinned an older version (for example while debugging a regression), you can ignore that message until you are ready to upgrade.
## Run
```bash

View file

@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi
<JourneyStep number="2" title="Ingest — get content in">
<JourneyItem icon="plus" title="Add memories & documents" href="/ingestion/add-memories" />
<JourneyItem icon="history" title="Backfill historical data" href="/ingestion/batch-ingest-historical-data" />
<JourneyItem icon="files" title="Document operations" href="/ingestion/document-operations" />
<JourneyItem icon="plug" title="Connectors" href="/connectors/overview" />
<JourneyItem icon="file-stack" title="Content types" href="/concepts/content-types" />

View file

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

View file

@ -52,6 +52,51 @@ export async function fetchSession(
return result.data
}
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
// instead of JWT verification. Successful lookups are cached per isolate so a
// busy MCP session doesn't re-validate on every JSON-RPC message.
const API_KEY_PATTERN = /^sm_\S{17,}$/
const API_KEY_CACHE_TTL_MS = 60_000
const API_KEY_CACHE_MAX_ENTRIES = 1000
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
export function isApiKey(token: string): boolean {
return API_KEY_PATTERN.test(token)
}
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)
return null
}
}
export async function validateOAuthToken(
token: string,
apiUrl: string,

View file

@ -7,11 +7,14 @@ import { z } from "zod"
import {
containerTagSchema,
documentsApiResponseSchema,
paginationSchema,
memoriesListSchema,
type ContainerTag,
type DocumentMemoryEntry,
type DocumentsApiResponse,
type DocumentWithMemories,
type MemoriesList,
type MemoryEntry,
type MemoryEntryHistory,
} from "../../shared/types"
const MAX_CHARS = 200000
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
pagination: SdkDocumentListResponse["pagination"]
}
const memoryEntryHistorySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
parentMemoryId: z.string().nullish(),
rootMemoryId: z.string().nullish(),
isLatest: z.boolean().optional(),
isForgotten: z.boolean().optional(),
})
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
const memoryEntrySchema = z.looseObject({
id: z.string(),
memory: z.string(),
version: z.number(),
isLatest: z.boolean(),
isForgotten: z.boolean(),
isStatic: z.boolean().optional(),
isInference: z.boolean().optional(),
createdAt: z.string(),
updatedAt: z.string(),
sourceCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
history: z.array(memoryEntryHistorySchema).optional(),
})
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
const memoryEntriesResponseSchema = z.object({
memoryEntries: z.array(memoryEntrySchema),
pagination: paginationSchema,
})
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
// Memory-entry shapes live in shared/types so the client parser and the
// listMemories output schema share one definition and can't drift.
export type { MemoryEntry, MemoryEntryHistory }
export type MemoryEntriesResponse = MemoriesList
export type Memory =
| {
@ -149,6 +119,19 @@ function objectProperty(value: unknown, key: string): unknown {
: undefined
}
// API error bodies are JSON like {"error": "..."} — unwrap them so users see
// the real reason instead of raw JSON or a generic fallback.
function extractApiErrorMessage(raw: unknown): string | undefined {
if (typeof raw !== "string" || !raw) return undefined
try {
const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown }
if (typeof parsed.error === "string" && parsed.error) return parsed.error
if (typeof parsed.message === "string" && parsed.message)
return parsed.message
} catch {}
return raw
}
export class SupermemoryClient {
private client: Supermemory
private containerTag: string
@ -371,7 +354,8 @@ export class SupermemoryClient {
signal,
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
const message = extractApiErrorMessage(await response.text())
throw Object.assign(new Error(message ?? ""), {
status: response.status,
})
}
@ -432,14 +416,13 @@ export class SupermemoryClient {
})
if (!response.ok) {
const message = await response.text()
throw Object.assign(
new Error(message || "Failed to fetch memory entries"),
{ status: response.status },
)
const message = extractApiErrorMessage(await response.text())
throw Object.assign(new Error(message ?? ""), {
status: response.status,
})
}
return memoryEntriesResponseSchema.parse(await response.json())
return memoriesListSchema.parse(await response.json())
} catch (error) {
this.handleError(error)
}
@ -466,8 +449,7 @@ export class SupermemoryClient {
const status = objectProperty(error, "status")
if (typeof status === "number") {
const rawMessage = objectProperty(error, "message")
const message = typeof rawMessage === "string" ? rawMessage : undefined
const message = extractApiErrorMessage(objectProperty(error, "message"))
switch (status) {
case 400:
case 422:
@ -479,7 +461,7 @@ export class SupermemoryClient {
case 403:
throw new Error(
message ||
"Access forbidden. Your account may be restricted or blocked.",
"Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status.",
)
case 404:
throw new Error("Not found.")

View file

@ -2,7 +2,12 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
import { createMcpHandler } from "agents/mcp/server"
import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import { validateOAuthToken, type AuthUser } from "./auth"
import {
isApiKey,
validateApiKey,
validateOAuthToken,
type AuthUser,
} from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types"
@ -176,7 +181,9 @@ async function handleMcpRequest(
if (!token) return unauthorizedResponse(resourceMetadataUrl)
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
const authUser = isApiKey(token)
? await validateApiKey(token, apiUrl)
: await validateOAuthToken(token, apiUrl, mcpResource)
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
const actor: ActorContext = {

View file

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

View file

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

View file

@ -7,6 +7,7 @@
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3004}",
"build": "next build",
"check-types": "tsc --noEmit",
"start": "next start"
},
"dependencies": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ import {
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type GDriveSyncScope = "scoped" | "full"
@ -309,6 +310,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const promoCode = usePromoCode()
const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

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

View file

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

View file

@ -8,6 +8,8 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useBrainTrial } from "@/hooks/use-brain-trial"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import {
@ -170,6 +172,7 @@ export function BrainHomeView() {
const o = useBrainOverview()
const trial = useBrainTrial()
const board = useConnectionsBoard()
const { needsSetup } = useTrialStatus()
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
const milestones = [
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
@ -186,6 +189,7 @@ export function BrainHomeView() {
return (
<div className="mx-auto max-w-[1080px] space-y-6">
<TrialSetupBanner />
<StatsRow
memories={o.memoriesCount}
connected={o.connectedCount}
@ -195,7 +199,7 @@ export function BrainHomeView() {
setupTotal={milestonesTotal}
lastUpdatedAt={o.lastUpdatedAt}
/>
{board.slack && !board.slack.connected && <SlackBanner />}
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 space-y-6">
{board.showBoard && <ConnectToolsCard board={board} />}
@ -486,7 +490,7 @@ function BrainTimeline({
canInvite: boolean
toolsCardVisible: boolean
}) {
const trial = useBrainTrial()
const { needsSetup } = useTrialStatus()
const { openSettings } = useSettingsModal()
const { setViewMode } = useViewMode()
const [, setInvite] = useQueryState("invite")
@ -532,12 +536,13 @@ function BrainTimeline({
title: slackConnected ? "Slack connected" : "Connect Slack",
hint: slackConnected
? undefined
: trial.state === "trialing"
? "Ask your brain from any channel."
: "Starts your 14-day free trial. No credit card needed.",
action: slackConnected
? undefined
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
: needsSetup
? "Starts with your trial."
: "Ask your brain from any channel.",
action:
slackConnected || needsSetup
? undefined
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
},
...(rollout != null
? [

View file

@ -4,6 +4,7 @@ import { cn } from "@lib/utils"
import { ArrowRight, Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
@ -192,6 +193,7 @@ export const CONNECT_TOOLS_CARD_ID = "connect-tools"
export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
const { setViewMode } = useViewMode()
const { loading, featured, overflow, busy, isConnected, connect } = board
const { needsSetup } = useTrialStatus()
return (
<section
@ -209,11 +211,19 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
Connect your tools
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
Give your Slack agent live access to the apps your team already uses.
{needsSetup
? "Starts with your trial."
: "Give your Slack agent live access to the apps your team already uses."}
</p>
</div>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
<div
className={cn(
"overflow-hidden rounded-[12px] bg-[#14161A]",
needsSetup && "pointer-events-none opacity-40 select-none",
)}
aria-disabled={needsSetup || undefined}
>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<TileSkeleton key={i} showDivider={i < 2} />
@ -248,6 +258,7 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
const { previewApps, isConnected, connectedCount } = board
const { needsSetup } = useTrialStatus()
const prompts = previewApps
.filter((a) => AGENT_PROMPTS[a.slug])
.slice(0, 6)
@ -275,12 +286,19 @@ export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
</p>
</div>
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
{connectedCount > 0
? "Things your agent can answer now:"
: "Connect a tool and your agent can answer:"}
{needsSetup
? "Starts with your trial."
: connectedCount > 0
? "Things your agent can answer now:"
: "Connect a tool and your agent can answer:"}
</p>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
<div
className={cn(
"overflow-hidden rounded-[12px] bg-[#14161A]",
needsSetup && "opacity-40 select-none",
)}
>
{prompts.map((p, i) => (
<div
key={p.slug}
@ -426,6 +444,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
}
export function SlackBanner() {
const { needsSetup } = useTrialStatus()
return (
<section
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
@ -472,12 +491,20 @@ export function SlackBanner() {
</div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
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]"
>
<SlackMark className="hidden sm:block sm:size-[18px]" />
<span className="sm:hidden">Add</span>
<span className="hidden sm:inline">Add to Slack</span>
{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

@ -36,9 +36,9 @@ export function HomeChatComposer({
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.5")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("grok-4.3"),
getDefaultReasoningEffort("grok-4.5"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([

View file

@ -206,11 +206,11 @@ export function ChatSidebar({
>([])
const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
const [selectedModel, setSelectedModel] = useState<ModelId>(
initialSelectedModel ?? "grok-4.3",
initialSelectedModel ?? "grok-4.5",
)
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
initialReasoningEffort ??
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"),
)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel

View file

@ -23,8 +23,7 @@ export default function ChatModelSelector({
minimal = false,
dropdownDirection = "up",
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
const [internalModel, setInternalModel] = useState<ModelId>("claude-sonnet-5")
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)

View file

@ -21,6 +21,7 @@ import {
LifeBuoy,
LayoutGrid,
MenuIcon,
Plus,
SearchIcon,
Settings,
Settings2,
@ -55,6 +56,7 @@ const BACKEND =
type SlackStatus = { connected: boolean; teamName: string | null }
interface CompanyBrainHeaderProps {
onAddMemory?: () => void
onOpenSearch?: () => void
}
@ -108,7 +110,10 @@ function useSlackStatus() {
})
}
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
export function CompanyBrainHeader({
onAddMemory,
onOpenSearch,
}: CompanyBrainHeaderProps) {
const { user, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
@ -412,6 +417,18 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{onAddMemory && (
<>
<DropdownMenuItem
onClick={onAddMemory}
className={menuItemClass}
>
<Plus className="size-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
</>
)}
<DropdownMenuItem
onClick={goOverview}
className={menuItemClass}
@ -498,6 +515,29 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
) : (
<>
<BrainTrialPill className="h-9 px-3" />
{onAddMemory && (
<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={onAddMemory}
aria-label="Add memory"
>
<Plus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Add</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
)}
{canInvite && (
<Tooltip>
<TooltipTrigger asChild>

View file

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

View file

@ -162,16 +162,19 @@ export function ConfigureView() {
</nav>
<div className="min-w-0 flex-1">
<header className="mb-5">
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
>
{active.label}
</h2>
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
{active.description}
</p>
<header className="mb-5 flex items-start justify-between gap-4">
<div>
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
>
{active.label}
</h2>
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
{active.description}
</p>
</div>
<div id="configure-section-actions" className="shrink-0" />
</header>
<ErrorBoundary

View file

@ -32,6 +32,7 @@ import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
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 { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
@ -1344,6 +1345,7 @@ export function DashboardView({
)}
>
<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}

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

@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) =>
export function Header(props: HeaderProps) {
const hasCompanyBrain = useHasCompanyBrain()
if (hasCompanyBrain) {
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
return (
<CompanyBrainHeader
onAddMemory={props.onAddMemory}
onOpenSearch={props.onOpenSearch}
/>
)
}
return <PersonalBrainHeader {...props} />
}

View file

@ -92,8 +92,8 @@ export function HighlightsCard({
if (isReplyOpen) replyInputRef.current?.focus()
}, [isReplyOpen])
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
setIsReplyOpen(false)
setReplyText("")
setIsExpanded(false)

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import {
ArrowRight,
@ -15,6 +16,7 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
type ResearchEvent,
@ -30,6 +32,9 @@ import {
UserAvatar,
} from "./step-about"
import { ResearchActionRail } from "./research-action-rail"
import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { analytics } from "@/lib/analytics"
import {
type CompanyBrainConfirmResult,
type CompanyBrainOrganizationChoice,
@ -52,7 +57,7 @@ interface CompanyBrainOnboardingProps {
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Phase = "confirm" | "research"
type Phase = "confirm" | "trial" | "research"
function normalizeDomain(input: string): string {
const host = input
@ -82,13 +87,36 @@ export function CompanyBrainOnboarding({
onUsePersonal,
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const { needsSetup } = useTrialStatus()
const resumedRef = useRef(false)
useEffect(() => {
if (resumedRef.current) return
const url = new URL(window.location.href)
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
resumedRef.current = true
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
setPhase("research")
}, [])
useEffect(() => {
if (resumedRef.current || !needsSetup || phase !== "confirm") return
resumedRef.current = true
setPhase("trial")
analytics.brainTrialCardViewed()
}, [needsSetup, phase])
const { org } = useAuth()
const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null
>(null)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
// Returning from checkout remounts and reseeds local state from the email domain,
// so past the confirm step the org's stored domain is the one to trust.
const confirmedDomain = getBrainWorkspaceDomain(org?.metadata)
const clean = normalizeDomain(
phase === "confirm" ? domain : confirmedDomain || domain,
)
const queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done"
@ -107,7 +135,8 @@ export function CompanyBrainOnboarding({
}
setOrganizationChoices(null)
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("research")
setPhase("trial")
analytics.brainTrialCardViewed()
}
// New-org signup schedules research after provisioning; if that hook is slow
@ -203,9 +232,9 @@ export function CompanyBrainOnboarding({
<main
className={cn(
"relative z-10 flex-1 flex flex-col min-h-0",
phase === "confirm"
? "justify-center items-center px-4 md:px-10"
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
phase === "research"
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
: "justify-center items-center px-4 md:px-10",
)}
>
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
@ -215,13 +244,25 @@ export function CompanyBrainOnboarding({
style={cardSurfaceStyle}
className={cn(
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
phase === "confirm"
? "max-w-xl p-6 md:p-8"
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
phase === "research"
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
: phase === "trial"
? "max-w-4xl p-6 md:p-7"
: "max-w-xl p-6 md:p-8",
)}
>
<AnimatePresence mode="wait" initial={false}>
{phase === "confirm" ? (
{phase === "trial" ? (
<motion.div
key="trial"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<StepTrial onActive={() => setPhase("research")} />
</motion.div>
) : phase === "confirm" ? (
<motion.div
key="confirm"
initial={{ opacity: 0 }}

View file

@ -525,6 +525,8 @@ function SlackStepBody({
<div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
dmSans125ClassName(),
@ -534,7 +536,7 @@ function SlackStepBody({
Add to Slack
</a>
<p className="mt-2 text-center text-[11px] font-medium leading-[1.5] text-[#525D6E]">
Starts your 14-day free trial. No credit card needed.
Included in your 14-day trial.
</p>
</div>
)

View file

@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
import { usePromoCode } from "@/hooks/use-promo-code"
type SourceId =
| "drive"
@ -97,7 +98,7 @@ type SourceId =
| "raycast"
type SourceState = "idle" | "connecting" | "connected" | "waitlist"
type DriveScope = "selective" | "full"
type RequiredPlan = "pro" | "max"
type RequiredPlan = "pro" | "max" | "scale"
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
"google-drive": "drive",
@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
const PLAN_LABELS: Record<RequiredPlan, string> = {
pro: "Pro",
max: "Max",
scale: "Scale",
}
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
@ -148,11 +150,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
credits: "$20",
productId: "api_pro",
description: "For people building with AI memory",
features: [
"Auto top-up when balance runs low",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
features: ["Auto top-up when balance runs low", "Priority support"],
},
{
id: "max",
@ -277,7 +275,12 @@ export function StepSources({
const [granolaOpen, setGranolaOpen] = useState(false)
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
const [requestedConnector, setRequestedConnector] = useState("This connector")
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
const {
hasMax,
hasScale,
connectorAccess,
loading: planLoading,
} = useConnectorAccess()
const { org, isRestoring } = useAuth()
useEffect(() => {
@ -362,10 +365,12 @@ export function StepSources({
}
}, [connectedParam])
// company_brain unlocks pro connectors; max stays gated
// company_brain unlocks pro connectors; max and scale stay gated, and a
// higher tier satisfies a lower requirement.
const isLocked = (plan?: RequiredPlan) => {
if (!plan || planLoading) return false
if (plan === "max") return !hasMax
if (plan === "scale") return !hasScale
if (plan === "max") return !(hasMax || hasScale)
return !connectorAccess
}
@ -610,6 +615,7 @@ function OnboardingPlansModal({
requestedPlan: RequiredPlan
}) {
const autumn = useCustomer()
const promoCode = usePromoCode()
const { currentPlan, isLoading } = useTokenUsage(autumn)
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
null,
@ -628,8 +634,10 @@ function OnboardingPlansModal({
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
@ -1185,14 +1193,14 @@ function MoreSourcesGrid({
icon={<Github className="size-6 text-[#fafafa]" />}
state={values.connected.github ?? "idle"}
ctaLabel="Connect"
locked={isLocked("max")}
requiredPlan="max"
locked={isLocked("scale")}
requiredPlan="scale"
perks={[
"PRs and issues parsed",
"READMEs and docs indexed",
"Stays in sync with new activity",
]}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
onConnect={guard("scale", "GitHub", () => requestWaitlist("github"))}
/>
{mode === "personal" ? (
<GranolaSourceCard

View file

@ -0,0 +1,239 @@
"use client"
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
import { GradientLogo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
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 { analytics } from "@/lib/analytics"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
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 = [
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
{ key: "notion", r: 74, deg: 236, node: <Notion className="size-4" /> },
{ key: "drive", r: 112, deg: 58, node: <GoogleDrive className="size-4" /> },
{ key: "granola", r: 112, deg: 172, node: <Granola className="size-4" /> },
{ key: "mcp", r: 112, deg: 296, node: <MCPIcon className="size-4" /> },
]
const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]"
const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]"
function BrainPanel() {
return (
<div className="relative hidden aspect-[3/2] w-[56%] shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#0B0E13] ring-1 ring-white/[0.06] md:flex">
<span
aria-hidden="true"
className="pointer-events-none absolute size-44 rounded-full bg-[#4BA0FA]/15 blur-3xl"
/>
<div aria-hidden="true" className="relative size-[248px]">
<span className="absolute left-1/2 top-1/2 size-[148px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.07]" />
<span className="absolute left-1/2 top-1/2 size-[224px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.05]" />
<div className={cn("absolute inset-0", SPIN)}>
{ORBIT.map(({ key, r, deg, node }) => (
<span
key={key}
style={{
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${r}px)`,
}}
className="absolute left-1/2 top-1/2 flex size-8 items-center justify-center rounded-full bg-[#161B22] ring-1 ring-white/10"
>
<span
className={cn("flex", SPIN_BACK)}
style={{ rotate: `${-deg}deg` }}
>
{node}
</span>
</span>
))}
</div>
<GradientLogo className="absolute left-1/2 top-1/2 h-auto w-[68px] -translate-x-1/2 -translate-y-1/2" />
</div>
<p className="absolute inset-x-0 bottom-5 text-center text-[13px] font-medium text-[#8b8b8b]">
Meet <span className="text-[#4BA0FA]">@supermemory</span>
</p>
</div>
)
}
function TimelineRow({
date,
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 (
<div className="flex gap-6">
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
className={cn(
dmSans125ClassName(),
"text-[22px] leading-tight font-medium text-[#fafafa]",
)}
>
Start your {TRIAL_DAYS}-day free trial
</h2>
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
Add a payment method to start. You will not be charged today. We
will email you before your first payment.
</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>
</div>
</div>
<BrainPanel />
</div>
)
}

View file

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

View file

@ -23,6 +23,7 @@ import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { useMutation, useQuery } from "@tanstack/react-query"
import {
Copy,
LoaderIcon,
ChevronDown,
Users,
@ -458,10 +459,26 @@ export default function Account({
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
"flex items-center gap-1 text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Organization
{org?.id ? (
<button
type="button"
aria-label="Copy organization ID"
title={org.id}
onClick={() => {
navigator.clipboard
.writeText(org.id)
.then(() => toast.success("Organization ID copied"))
.catch(() => toast.error("Couldn't copy"))
}}
className="inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors hover:text-[#FAFAFA]"
>
<Copy className="size-2.5" />
</button>
) : null}
</span>
{isEditingOrgName ? (
<form

View file

@ -36,6 +36,7 @@ import {
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { usePromoCode } from "@/hooks/use-promo-code"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Pay-as-you-go after $5 runs out",
"Full search and memory access",
"All plugins (Claude Code, Cursor, Hermes...)",
"Email support",
],
},
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Auto top-up when balance runs low",
"Google Drive, Notion, OneDrive & Granola connectors",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
},
@ -203,8 +204,24 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
},
]
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
// Company Brain workspaces sell Max / Scale / Enterprise (no Free, Pro).
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
{
id: "max",
name: "Max",
price: "$100",
period: "/mo",
credits: "$130",
productId: "api_max",
description: "Company Brain for teams with everyday usage",
mostPopular: true,
features: [
"Company Brain Slack agent & shared memory",
"$130 monthly usage credits",
"Unlimited seats",
"Auto top-up & spend caps",
],
},
{
id: "scale",
name: "Scale",
@ -212,13 +229,13 @@ const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
period: "/mo",
credits: "$600",
productId: "api_scale",
description: "Company Brain for your team, with production usage",
mostPopular: true,
description: "Company Brain for production workloads",
includesFrom: "Max",
features: [
"Company Brain Slack agent & shared memory",
"$600 monthly usage credits when paid",
"Auto top-up & spend caps",
"Team connectors & dedicated support",
"$600 monthly usage credits",
"GitHub, S3 & Web Crawler connectors",
"Restricted access, container tags & User Insights",
"Dedicated support",
],
},
{
@ -515,6 +532,7 @@ export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer()
const promoCode = usePromoCode()
const posthog = usePostHog()
const isCompanyBrain = useHasCompanyBrain()
const brainTrial = useMemo(
@ -682,8 +700,10 @@ export default function Billing() {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#billing`,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
@ -914,6 +934,27 @@ export default function Billing() {
)
}
// The trial runs on api_scale, so Max ranks below the current plan and would
// otherwise render as a dead "Included with Scale" button. Trial users are
// exactly who we want on Max, so it needs its own actionable path.
if (plan.id === "max" && (isOnTrial || isBrainTrialEnded)) {
return (
<button
type="button"
onClick={() => handleUpgrade("api_max")}
disabled={disabled}
className={cn(
dmSans125ClassName(),
PLAN_CARD_ACTION_CLASS,
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
)}
>
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
Activate Max
</button>
)
}
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
return (
@ -1471,6 +1512,20 @@ export default function Billing() {
}
/>
))}
<div className="md:col-span-2 space-y-2 rounded-lg border border-white/[0.08] bg-white/[0.02] p-4 text-[13px] leading-relaxed text-[#A3A3A3]">
{isOnTrial ? (
<p>
Your trial runs on Scale. Moving to Max keeps the agent,
shared memory and unlimited seats, and drops the GitHub, S3
and Web Crawler connectors, restricted access and container
tags, and User Insights.
</p>
) : null}
<p>
Using more than about $400 of credits a month? Scale works out
cheaper than Max plus top-ups.
</p>
</div>
</div>
) : (
<>

View file

@ -19,7 +19,8 @@ import {
Radar,
Trash2,
} from "lucide-react"
import { useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { toast } from "sonner"
import {
Select,
@ -35,6 +36,8 @@ import {
TooltipTrigger,
} from "@ui/components/tooltip"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { configureSectionToPath } from "@/lib/configure-routes"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
@ -301,6 +304,9 @@ function AutomationCard({
id,
channels,
ownerLabel,
personalOnlyApps = [],
isAdmin = false,
appCatalog = {},
onDone,
onCancelNew,
onCollapse,
@ -309,6 +315,9 @@ function AutomationCard({
id: string | null
channels: Channel[]
ownerLabel?: string
personalOnlyApps?: string[]
isAdmin?: boolean
appCatalog?: Record<string, { name: string; iconDomain?: string }>
onDone: () => void
onCancelNew?: () => void
onCollapse?: () => void
@ -349,9 +358,18 @@ function AutomationCard({
const b = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(b.error ?? "Couldn't save.")
}
const b = (await res.json().catch(() => ({}))) as {
warnings?: { app: string }[]
}
return b.warnings ?? []
},
onSuccess: () => {
onSuccess: (warnings) => {
toast.success("Automation saved.")
if (warnings.length)
toast.warning(
`Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`,
{ duration: 10000 },
)
onDone()
},
onError: (err) =>
@ -619,6 +637,51 @@ function AutomationCard({
Cancel
</button>
) : null}
{draft.deliverTo === "channel" && personalOnlyApps.length > 0 && (
<span className="flex min-w-0 items-center gap-2 text-[12px] leading-snug text-amber-300/80">
<TooltipProvider>
<span className="flex shrink-0 -space-x-1">
{personalOnlyApps.map((app) => (
<Tooltip key={app}>
<TooltipTrigger asChild>
{appCatalog[app]?.iconDomain ? (
<img
alt={appCatalog[app]?.name ?? app}
className="size-4 cursor-default select-none rounded-full bg-white/10 ring-1 ring-black/50"
src={`https://www.google.com/s2/favicons?domain=${appCatalog[app]?.iconDomain}&sz=32`}
/>
) : (
<span className="flex size-4 cursor-default select-none items-center justify-center rounded-full bg-amber-500/20 text-[9px] uppercase ring-1 ring-black/50">
{app.slice(0, 1)}
</span>
)}
</TooltipTrigger>
<TooltipContent>
{appCatalog[app]?.name ?? app}
</TooltipContent>
</Tooltip>
))}
</span>
</TooltipProvider>
<span className="truncate">
only connected to you ·{" "}
{isAdmin ? (
<>
<a
className="underline underline-offset-2 hover:text-amber-200"
href={configureSectionToPath("tools")}
>
Connect for workspace
</a>{" "}
to use here
</>
) : (
"ask an admin to connect it for the workspace"
)}
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
{id ? (
@ -851,8 +914,14 @@ function PresetCard({
export default function CompanyBrainAutomations() {
const isCompanyBrain = useHasCompanyBrain()
const { user, org } = useAuth()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const queryClient = useQueryClient()
const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
const [showAllTemplates, setShowAllTemplates] = useState(false)
const [actionSlot, setActionSlot] = useState<HTMLElement | null>(null)
useEffect(() => {
setActionSlot(document.getElementById("configure-section-actions"))
}, [])
const [openId, setOpenId] = useState<string | null>(null)
const draftKey = useRef(0)
const addDraft = (draft: Draft) =>
@ -886,11 +955,15 @@ export default function CompanyBrainAutomations() {
const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
credentials: "include",
})
if (!res.ok) return [] as string[]
if (!res.ok) return [] as { serverSlug: string; userId: string | null }[]
const body = (await res.json()) as {
connections?: { serverSlug: string }[]
connections?: {
serverSlug: string
userId: string | null
status: string
}[]
}
return (body.connections ?? []).map((c) => c.serverSlug)
return (body.connections ?? []).filter((c) => c.status === "active")
},
enabled: isCompanyBrain,
})
@ -899,7 +972,39 @@ export default function CompanyBrainAutomations() {
const channels = channelsQuery.data ?? []
const automations = listQuery.data ?? []
const presets = sortPresets(new Set(appsQuery.data ?? []))
const catalogQuery = useQuery({
queryKey: ["company-brain-automations", "catalog", "v2"],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/mcp-connections/catalog`, {
credentials: "include",
})
if (!res.ok)
return {} as Record<string, { name: string; iconDomain?: string }>
const body = (await res.json()) as {
catalog?: { slug: string; name?: string; iconDomain?: string }[]
}
return Object.fromEntries(
(body.catalog ?? []).map((e) => [
e.slug,
{ name: e.name ?? e.slug, iconDomain: e.iconDomain },
]),
)
},
enabled: isCompanyBrain,
})
const connections = appsQuery.data ?? []
const presets = sortPresets(new Set(connections.map((c) => c.serverSlug)))
const sharedApps = new Set(
connections.filter((c) => c.userId === null).map((c) => c.serverSlug),
)
const personalOnlyApps = [
...new Set(
connections
.filter((c) => c.userId !== null && !sharedApps.has(c.serverSlug))
.map((c) => c.serverSlug),
),
]
const nameFor = (userId: string | null): string | undefined => {
if (!userId) return undefined
if (userId === user?.id) return "You"
@ -913,10 +1018,34 @@ export default function CompanyBrainAutomations() {
}
const usedTitles = new Set(automations.map((a) => a.title))
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
const shownPresets = showAllTemplates
? availablePresets
: availablePresets.slice(0, 3)
const hiddenTemplateCount = availablePresets.length - shownPresets.length
const hasList = automations.length > 0 || drafts.length > 0
const newAutomationButton = (
<button
type="button"
onClick={() => addDraft(emptyDraft())}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820]",
)}
>
<Plus className="size-4" />
New automation
</button>
)
const newAutomationPortal = actionSlot ? (
createPortal(newAutomationButton, actionSlot)
) : (
<div className="flex justify-end">{newAutomationButton}</div>
)
return (
<section className="flex flex-col gap-3 px-1">
{newAutomationPortal}
<div className="flex flex-col gap-3">
{automations.map((a) =>
openId === a.id ? (
@ -925,6 +1054,9 @@ export default function CompanyBrainAutomations() {
id={a.id}
initial={toDraft(a)}
channels={channels}
personalOnlyApps={personalOnlyApps}
isAdmin={isAdmin}
appCatalog={catalogQuery.data ?? {}}
onDone={() => {
setOpenId(null)
refresh()
@ -953,6 +1085,9 @@ export default function CompanyBrainAutomations() {
id={null}
initial={draft}
channels={channels}
personalOnlyApps={personalOnlyApps}
isAdmin={isAdmin}
appCatalog={catalogQuery.data ?? {}}
onDone={() => {
removeDraft(key)
refresh()
@ -961,37 +1096,38 @@ export default function CompanyBrainAutomations() {
/>
))}
{hasList ? (
<p
className={cn(
dmSans125ClassName(),
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
)}
>
Templates
</p>
) : null}
<p
className={cn(
dmSans125ClassName(),
hasList ? "pt-2" : "",
"text-[12px] font-medium text-[#6B6B6B]",
)}
>
{showAllTemplates ? "Templates" : "Ideas for your setup"}
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{availablePresets.map((p) => (
{shownPresets.map((p) => (
<PresetCard
key={p.id}
preset={p}
onPick={() => addDraft(presetToDraft(p))}
/>
))}
</div>
{hiddenTemplateCount > 0 || showAllTemplates ? (
<button
type="button"
onClick={() => addDraft(emptyDraft())}
onClick={() => setShowAllTemplates((v) => !v)}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
"self-start text-[12px] font-medium text-[#737B87] transition-colors hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
New automation
{showAllTemplates
? "Show fewer"
: `Show all ${availablePresets.length} templates`}
</button>
</div>
) : null}
</div>
</section>
)

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) {
export function SlackConnectCard() {
const isCompanyBrain = useHasCompanyBrain()
const [status, setStatus] = useState<SlackStatus | null>(null)
const [trialActive, setTrialActive] = useState(true)
const [loading, setLoading] = useState(true)
useEffect(() => {
@ -58,10 +59,16 @@ export function SlackConnectCard() {
let active = true
;(async () => {
try {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
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)
}
@ -92,7 +99,7 @@ export function SlackConnectCard() {
<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]"
@ -100,6 +107,13 @@ export function SlackConnectCard() {
<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>
)

View file

@ -0,0 +1,41 @@
"use client"
import { ArrowRight, CreditCard } from "lucide-react"
import Link from "next/link"
import { useTrialStatus } from "@/hooks/use-trial-status"
export function TrialSetupBanner() {
const { needsSetup, data } = useTrialStatus()
if (!needsSetup) return null
const endedTrial = data?.reason === "trial_ended"
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] px-4 py-3 ring-1 ring-[#4BA0FA]/20 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]/12">
<CreditCard className="size-4 text-[#4BA0FA]" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-fg-primary">
{endedTrial
? "Your Company Brain trial has ended"
: "Finish setting up Company Brain"}
</p>
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
{endedTrial
? "Move to Max or Scale to switch the brain back on."
: "Add a card to start your 14-day trial. $0 today."}
</p>
</div>
</div>
<Link
href={endedTrial ? "/?settings=billing" : "/onboarding"}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
>
{endedTrial ? "Upgrade" : "Add card"}
<ArrowRight className="size-3.5" />
</Link>
</div>
)
}

View file

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

View file

@ -9,9 +9,12 @@ export function useConnectorAccess(opts?: { enabled?: boolean }) {
const hasCompanyBrain = useHasCompanyBrain()
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
const hasScale =
enabled && hasActivePlan(autumn.data?.subscriptions, "api_scale")
return {
hasPro,
hasMax,
hasScale,
hasCompanyBrain,
connectorAccess: hasPro || hasCompanyBrain,
loading: enabled && autumn.isLoading,

View file

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

View file

@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export type TrialStatus = {
active: boolean
reason: string | null
}
/** Distinguishes a named Company Brain org from one whose trial is actually live. */
export function useTrialStatus() {
const isCompanyBrain = useHasCompanyBrain()
const query = useQuery({
queryKey: ["brain", "trial-status"],
queryFn: async (): Promise<TrialStatus> => {
const res = await fetch(`${BACKEND}/brain/trial/status`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to load trial status")
const data = (await res.json()) as { active?: boolean; reason?: string }
return { active: Boolean(data.active), reason: data.reason ?? null }
},
enabled: isCompanyBrain,
staleTime: 30 * 1000,
})
return {
...query,
needsSetup: isCompanyBrain && query.data ? !query.data.active : false,
}
}

View file

@ -271,4 +271,9 @@ export const analytics = {
}) => safeCapture("company_brain_promo_clicked", props),
companyBrainPromoDismissed: () =>
safeCapture("company_brain_promo_dismissed"),
brainTrialCardViewed: () => safeCapture("brain_trial_card_viewed"),
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
brainTrialCheckoutAbandoned: () =>
safeCapture("brain_trial_checkout_abandoned"),
}

View file

@ -112,6 +112,15 @@ export function getBrainMode(
: null
}
export function isCompanyBrainOrg(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean {
const override = getCompanyBrainOverride(metadataRaw)
if (override !== undefined) return override
if (hasCompanyBrain(metadataRaw)) return true
return getBrainMode(metadataRaw) === "team"
}
export type BrainTrialStatus =
| "active"
| "exhausted"
@ -185,18 +194,26 @@ export function getBrainTrialInfo(
}
/**
* Format a number with K/M suffix for display
* Format a number with K/M/B suffix for display
* @example formatUsageNumber(1500000) => "1.5M"
* @example formatUsageNumber(50000) => "50K"
* @example formatUsageNumber(999950) => "1.0M"
*/
export function formatUsageNumber(value: number): string {
const withSuffix = (n: number, suffix: string) =>
n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}`
if (value >= 1_000_000) {
const millions = value / 1_000_000
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
return millions >= 999.95
? withSuffix(value / 1_000_000_000, "B")
: withSuffix(millions, "M")
}
if (value >= 1_000) {
const thousands = value / 1_000
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
return thousands >= 999.95
? withSuffix(value / 1_000_000, "M")
: withSuffix(thousands, "K")
}
return value.toString()
}

View file

@ -1,9 +1,9 @@
import type { ModelId } from "@/lib/models"
const OTHER_MODELS: ModelId[] = [
"gpt-5.1",
"claude-sonnet-4.6",
"gemini-2.5-pro",
"gpt-5.6-terra",
"claude-sonnet-5",
"gemini-3.1-pro-preview",
]
function flattenError(e: unknown): string {

View file

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

View file

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

View file

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

View file

@ -1,22 +1,22 @@
export const models = [
{
id: "grok-4.3",
name: "Grok 4.3",
id: "grok-4.5",
name: "Grok 4.5",
description: "xAI's latest model",
},
{
id: "gpt-5.1",
name: "GPT 5.1",
id: "gpt-5.6-terra",
name: "GPT 5.6",
description: "OpenAI's latest model",
},
{
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
description: "Anthropic's advanced model",
},
{
id: "gemini-2.5-pro",
name: "Gemini 3 Pro",
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
description: "Google's most capable model",
},
] as const
@ -25,10 +25,10 @@ export type ModelId = (typeof models)[number]["id"]
export type ReasoningEffort = "instant" | "thinking"
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"grok-4.3": { name: "Grok", version: "4.3" },
"gpt-5.1": { name: "GPT", version: "5.1" },
"claude-sonnet-4.6": { name: "Claude", version: "4.6" },
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
"grok-4.5": { name: "Grok", version: "4.5" },
"gpt-5.6-terra": { name: "GPT", version: "5.6" },
"claude-sonnet-5": { name: "Claude", version: "Sonnet 5" },
"gemini-3.1-pro-preview": { name: "Gemini", version: "3.1 Pro" },
}
export const reasoningOptions: Array<{

View file

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

View file

@ -10,6 +10,7 @@
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
"build": "next build",
"check-types": "tsc --noEmit",
"start": "next start",
"lint": "biome check --write",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",

View file

@ -337,7 +337,7 @@
},
"packages/tools": {
"name": "@supermemory/tools",
"version": "2.1.1",
"version": "2.2.0",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.25",
"@ai-sdk/openai": "^2.0.23",

View file

@ -1,5 +1,8 @@
{
"name": "@repo/hooks",
"version": "0.0.0",
"private": true
"private": true,
"scripts": {
"check-types": "tsc --noEmit"
}
}

View file

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

View file

@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
},
"exports": {
"./*": "./*"
},

View file

@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => {
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
})
it("does not treat an already-elapsed forgetAfter as expiring", () => {
const past = new Date(Date.now() - 60 * 1000).toISOString()
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ forgetAfter: past, createdAt: old })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
})
it("returns recent color for memories created within 24 hours", () => {
const recent = new Date(Date.now() - 1000).toISOString()
const mem = makeMemory({ createdAt: recent })

View file

@ -467,10 +467,10 @@ export function MemoryGraph({
n.x,
n.y,
containerSize.width,
containerSize.height,
graphFitHeight,
)
},
[nodes, containerSize.width, containerSize.height],
[nodes, containerSize.width, graphFitHeight],
)
const navigateUp = useCallback(() => {

View file

@ -47,7 +47,7 @@ export function getMemoryBorderColor(
if (mem.isForgotten) return colors.memBorderForgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return colors.memBorderRecent

View file

@ -229,6 +229,17 @@ openai_with_memory = with_supermemory(
## Manual Memory Tools
`SupermemoryTools` exposes seven OpenAI function-calling tools:
- `search_memories` and `add_memory`
- `get_profile`
- `document_list`, `document_add`, and `document_delete`
- `memory_forget`
The configured `project_id` or `container_tags` define the trusted scope. The
primary tag is used for profile, list, search, and forget operations, and the
model cannot select a different tag.
### SupermemoryTools Class
```python
@ -245,8 +256,7 @@ tools = SupermemoryTools(
# Search memories
result = await tools.search_memories(
information_to_get="user preferences",
limit=10,
include_full_docs=True
limit=10
)
# Add memory
@ -254,24 +264,48 @@ result = await tools.add_memory(
memory="User prefers tea over coffee"
)
# Fetch specific memory
result = await tools.fetch_memory(
memory_id="memory-id-here"
# Get the configured user's profile
result = await tools.get_profile(query="favorite drinks")
# List, add, or delete source documents
documents = await tools.document_list(limit=10, page=1)
document = await tools.document_add(
content="Meeting notes...",
title="Weekly meeting"
)
deleted = await tools.document_delete(document_id="document-id-here")
# Soft-forget one extracted memory
forgotten = await tools.memory_forget(
memory_id="memory-entry-id-here",
reason="outdated"
)
```
`include_full_docs` is retained as a deprecated Python argument for compatibility,
but v4 search returns relevant memories and chunks instead of full source documents.
It is no longer exposed in the OpenAI tool schema.
### Individual Tools
```python
from supermemory_openai import (
create_search_memories_tool,
create_add_memory_tool,
create_fetch_memory_tool
create_get_profile_tool,
create_document_list_tool,
create_document_delete_tool,
create_document_add_tool,
create_memory_forget_tool,
)
search_tool = create_search_memories_tool("your-api-key")
add_tool = create_add_memory_tool("your-api-key")
fetch_tool = create_fetch_memory_tool("your-api-key")
profile_tool = create_get_profile_tool("your-api-key")
list_tool = create_document_list_tool("your-api-key")
delete_tool = create_document_delete_tool("your-api-key")
document_add_tool = create_document_add_tool("your-api-key")
forget_tool = create_memory_forget_tool("your-api-key")
```
### Function Calling Integration
@ -343,6 +377,11 @@ SupermemoryTools(
- `get_tool_definitions()` - Get OpenAI function definitions
- `search_memories()` - Search user memories
- `add_memory()` - Add new memory
- `get_profile()` - Get the configured user's profile
- `document_list()` - List source document metadata
- `document_add()` - Queue a source document for processing
- `document_delete()` - Delete an in-scope source document
- `memory_forget()` - Soft-forget one extracted memory
- `execute_tool_call()` - Execute individual tool call
## Error Handling
@ -408,7 +447,7 @@ Optional for testing:
### Required
- `openai>=1.102.0` - Official OpenAI Python SDK
- `supermemory>=3.1.0` - Supermemory client
- `supermemory>=3.50.0` - Supermemory client
- `requests>=2.25.0` - HTTP requests (fallback)
### Optional

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "supermemory-openai-sdk"
version = "1.0.6"
version = "1.0.7"
description = "Memory tools for OpenAI function calling with supermemory"
readme = "README.md"
license = "MIT"
@ -25,7 +25,7 @@ classifiers = [
requires-python = ">=3.9"
dependencies = [
"openai>=1.102.0",
"supermemory>=3.16.0",
"supermemory>=3.50.0",
"typing-extensions>=4.0.0",
"requests>=2.25.0",
]

View file

@ -1,59 +0,0 @@
"""Forget memory via DELETE /v4/memories (not exposed on supermemory SDK v3)."""
from typing import Optional
DEFAULT_BASE_URL = "https://api.supermemory.ai"
async def forget_memory_request(
api_key: str,
container_tag: str,
memory_id: Optional[str] = None,
memory_content: Optional[str] = None,
reason: Optional[str] = None,
base_url: str = DEFAULT_BASE_URL,
) -> None:
"""Mark a memory as forgotten via the v4 memories endpoint."""
payload: dict[str, str] = {"containerTag": container_tag}
if memory_id:
payload["id"] = memory_id
if memory_content:
payload["content"] = memory_content
if reason:
payload["reason"] = reason
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{base_url}/v4/memories",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
json=payload,
) as response:
if not response.ok:
error_text = await response.text()
raise RuntimeError(
f"Supermemory forget memory failed: {response.status} "
f"{response.reason}. {error_text}"
)
except ImportError:
import requests
response = requests.delete(
f"{base_url}/v4/memories",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
json=payload,
timeout=30,
)
if not response.ok:
raise RuntimeError(
f"Supermemory forget memory failed: {response.status_code} "
f"{response.reason}. {response.text}"
)

View file

@ -222,15 +222,15 @@ async def add_memory_tool(
) -> None:
"""Add a new memory to the SuperMemory system."""
try:
add_params = {
"content": content,
"container_tag": container_tag,
}
if custom_id is not None:
add_params["custom_id"] = custom_id
# Handle both sync and async supermemory clients
result = client.add(**add_params)
if custom_id is None:
result = client.add(content=content, container_tag=container_tag)
else:
result = client.add(
content=content,
container_tag=container_tag,
custom_id=custom_id,
)
if inspect.isawaitable(result):
response = await result
else:

View file

@ -1,7 +1,8 @@
"""Supermemory tools for OpenAI function calling."""
import json
from typing import Any, Dict, List, Optional, TypedDict, Union
import warnings
from typing import Any, Dict, List, Optional, TypedDict
import supermemory
from openai.types.chat import (
@ -10,28 +11,17 @@ from openai.types.chat import (
ChatCompletionToolMessageParam,
ChatCompletionToolParam,
)
from supermemory.types import (
AddResponse,
DocumentGetResponse,
SearchMemoriesResponse,
)
from supermemory.types.search_memories_response import Result
from openai.types.shared_params import FunctionDefinition
from supermemory.types import AddResponse, SearchMemoriesResponse
from .exceptions import (
SupermemoryConfigurationError,
SupermemoryMemoryOperationError,
SupermemoryNetworkError,
)
from .forget_memory import DEFAULT_BASE_URL, forget_memory_request
from .exceptions import SupermemoryConfigurationError
TOOL_DESCRIPTIONS = {
"search_memories": (
"Search (recall) stored memories for facts, preferences, history, and context about the user "
"or any topic. Use proactively before answering whenever memory could help — do not wait for "
"the user to explicitly ask you to search or recall. Search when the question touches personal "
"context, past conversations, preferences, projects, people, plans, or anything you may have "
"learned before. Results include memory/chunk IDs — use those IDs with memory_forget to remove "
"a specific learned fact."
"Search stored memories and source chunks for relevant facts, preferences, "
"history, and context. Use proactively whenever prior context could help. "
"Hybrid results may contain either a memory or a source chunk; only an ID "
"from a result containing a memory can be passed to memory_forget."
),
"add_memory": (
"Add (remember) memories/details/information about the user or other facts or entities. "
@ -40,19 +30,19 @@ TOOL_DESCRIPTIONS = {
),
"get_profile": (
"Get user profile containing static memories (permanent facts) and dynamic memories "
"(recent context). Optionally include search results by providing a query. "
"Profile and search result entries may include memory IDs useful for memory_forget."
"(recent context). Optionally include query-relevant search results. Static and dynamic "
"profile entries are text; only memory entries in search results have forgettable IDs."
),
"document_list": (
"List stored source documents (conversations, URLs, files, pasted text) with pagination. "
"Returns document IDs for document_delete — not memory IDs for memory_forget. "
"Use to browse raw stored content before permanently removing a source."
"Returns document metadata and summaries, including IDs for document_delete. "
"It does not return full source content or memory IDs."
),
"document_delete": (
"Permanently delete a stored document and ALL memories extracted from it (hard delete). "
"Use document IDs from document_list. Use when the user wants to remove an entire "
"conversation, file, URL, or other source — not when correcting a single learned fact "
"(use memory_forget for that)."
"Permanently delete a stored source document and soft-forget memories extracted from it. "
"Use a document ID from document_list when the user wants to remove an entire source. "
"Deletion is refused for documents outside the configured scope, shared with another "
"scope, or still processing. Use memory_forget to remove one learned fact."
),
"document_add": (
"Store a source document for asynchronous processing and automatic memory extraction. "
@ -67,10 +57,9 @@ TOOL_DESCRIPTIONS = {
),
"memory_forget": (
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in "
"profile or search. Does NOT delete source documents. Provide memory_id (preferred — from "
"search_memories or get_profile) OR memory_content for an exact text match. Use when the "
"user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). "
"To remove an entire conversation or file, use document_delete instead."
"profile or search. This does not delete source documents. Provide a memory_id from a "
"search result containing a memory, or memory_content for an exact text match. Use "
"document_delete to remove an entire source."
),
}
@ -79,19 +68,14 @@ PARAMETER_DESCRIPTIONS = {
"What to look up in memory — keywords from the user's message, topic, entity names, or "
"question phrasing. Search even when the user did not explicitly ask you to recall."
),
"include_full_docs": (
"Whether to include the full document content in the response. "
"Defaults to true for better AI context."
),
"limit": "Maximum number of results to return",
"memory": (
"The text content of the memory to add. This should be a single sentence or a short paragraph."
),
"container_tag": "Tag to filter/scope the operation (e.g., user ID, project ID)",
"query": "Optional search query to include relevant search results",
"page": "Page number to fetch, 1-based (default: 1)",
"document_id": (
"Document ID from document_list — permanently deletes the source document and all "
"Document ID from document_list. Permanently deletes the source and soft-forgets its "
"extracted memories. Not a profile memory ID."
),
"content": (
@ -102,8 +86,8 @@ PARAMETER_DESCRIPTIONS = {
"title": "Optional title for the document",
"description": "Optional description for the document",
"memory_id": (
"Profile memory ID from search_memories or get_profile — soft-deletes one learned fact. "
"Not a document ID."
"Memory entry ID from a search_memories result containing a memory. Chunk and document "
"IDs are invalid."
),
"memory_content": (
"Exact text of the profile memory to forget (alternative to memory_id). Must match "
@ -114,7 +98,6 @@ PARAMETER_DESCRIPTIONS = {
DEFAULT_LIMIT = 10
DEFAULT_CHUNK_THRESHOLD = 0.6
DEFAULT_INCLUDE_FULL_DOCS = True
ALL_TOOL_NAMES = (
"search_memories",
@ -131,6 +114,8 @@ class SupermemoryToolsConfig(TypedDict, total=False):
"""Configuration for Supermemory tools.
Only one of `project_id` or `container_tags` can be provided.
The first container tag is used for single-space operations. All configured
tags are applied to additions and define the allowed document-delete scope.
"""
base_url: Optional[str]
@ -138,15 +123,15 @@ class SupermemoryToolsConfig(TypedDict, total=False):
project_id: Optional[str]
# Type aliases using inferred types from supermemory package
MemoryObject = Union[DocumentGetResponse, AddResponse]
# Type alias retained for compatibility with earlier releases.
MemoryObject = AddResponse
class MemorySearchResult(TypedDict, total=False):
"""Result type for memory search operations."""
success: bool
results: Optional[List[Result]]
results: Optional[List[Dict[str, object]]]
count: Optional[int]
error: Optional[str]
@ -163,8 +148,8 @@ class ProfileResult(TypedDict, total=False):
"""Result type for profile operations."""
success: bool
profile: Optional[Dict[str, Any]]
search_results: Optional[Any]
profile: Optional[Dict[str, object]]
search_results: Optional[Dict[str, object]]
error: Optional[str]
@ -172,8 +157,8 @@ class DocumentListResult(TypedDict, total=False):
"""Result type for document list operations."""
success: bool
documents: Optional[List[Any]]
pagination: Optional[Any]
documents: Optional[List[Dict[str, object]]]
pagination: Optional[Dict[str, object]]
error: Optional[str]
@ -202,7 +187,7 @@ class MemoryForgetResult(TypedDict, total=False):
# Function schemas for OpenAI function calling
MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
MEMORY_TOOL_SCHEMAS: Dict[str, FunctionDefinition] = {
"search_memories": {
"name": "search_memories",
"description": TOOL_DESCRIPTIONS["search_memories"],
@ -213,18 +198,16 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["information_to_get"],
},
"include_full_docs": {
"type": "boolean",
"description": PARAMETER_DESCRIPTIONS["include_full_docs"],
"default": DEFAULT_INCLUDE_FULL_DOCS,
},
"limit": {
"type": "number",
"type": "integer",
"description": PARAMETER_DESCRIPTIONS["limit"],
"default": DEFAULT_LIMIT,
"minimum": 1,
"maximum": 100,
},
},
"required": ["information_to_get"],
"additionalProperties": False,
},
},
"add_memory": {
@ -239,6 +222,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
},
},
"required": ["memory"],
"additionalProperties": False,
},
},
"get_profile": {
@ -247,16 +231,13 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
"parameters": {
"type": "object",
"properties": {
"container_tag": {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["container_tag"],
},
"query": {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["query"],
},
},
"required": [],
"additionalProperties": False,
},
},
"document_list": {
@ -265,21 +246,22 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
"parameters": {
"type": "object",
"properties": {
"container_tag": {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["container_tag"],
},
"limit": {
"type": "number",
"type": "integer",
"description": PARAMETER_DESCRIPTIONS["limit"],
"default": DEFAULT_LIMIT,
"minimum": 1,
"maximum": 1100,
},
"page": {
"type": "number",
"type": "integer",
"description": PARAMETER_DESCRIPTIONS["page"],
"default": 1,
"minimum": 1,
},
},
"required": [],
"additionalProperties": False,
},
},
"document_delete": {
@ -294,6 +276,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
},
},
"required": ["document_id"],
"additionalProperties": False,
},
},
"document_add": {
@ -316,6 +299,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
},
},
"required": ["content"],
"additionalProperties": False,
},
},
"memory_forget": {
@ -324,10 +308,6 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
"parameters": {
"type": "object",
"properties": {
"container_tag": {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["container_tag"],
},
"memory_id": {
"type": "string",
"description": PARAMETER_DESCRIPTIONS["memory_id"],
@ -342,20 +322,28 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
},
},
"required": [],
"additionalProperties": False,
},
},
}
def _resolve_container_tags(config: SupermemoryToolsConfig) -> List[str]:
if config.get("project_id") is not None and config.get("container_tags") is not None:
project_id = config.get("project_id")
configured_tags = config.get("container_tags")
if project_id is not None and configured_tags is not None:
raise SupermemoryConfigurationError(
"Supermemory tools config accepts either project_id or container_tags, not both."
)
if config.get("project_id"):
return [f"sm_project_{config['project_id']}"]
if config.get("container_tags"):
return config["container_tags"]
if project_id:
return [f"sm_project_{project_id}"]
if configured_tags is not None:
if not configured_tags or any(not tag for tag in configured_tags):
raise SupermemoryConfigurationError(
"container_tags must contain at least one non-empty tag."
)
return list(configured_tags)
return ["sm_project_default"]
@ -363,8 +351,25 @@ def _tool_definition(name: str) -> ChatCompletionToolParam:
return {"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
def _model_to_dict(value: Any) -> Dict[str, object]:
"""Normalize generated SDK models and already-plain response values."""
if isinstance(value, dict):
return dict(value)
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
if isinstance(dumped, dict):
return dumped
raise TypeError(f"Unsupported SDK response type: {type(value).__name__}")
def _all_tool_definitions() -> List[ChatCompletionFunctionToolParam]:
return [{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} for name in ALL_TOOL_NAMES]
return [
{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
for name in ALL_TOOL_NAMES
]
class SupermemoryTools:
@ -378,18 +383,18 @@ class SupermemoryTools:
config: Optional configuration
"""
config = config or {}
self.api_key = api_key
self.base_url = config.get("base_url") or DEFAULT_BASE_URL
client_kwargs = {"api_key": api_key}
if config.get("base_url"):
client_kwargs["base_url"] = config["base_url"]
self.client = supermemory.AsyncSupermemory(**client_kwargs)
base_url = config.get("base_url")
if base_url:
self.client = supermemory.AsyncSupermemory(
api_key=api_key,
base_url=base_url,
)
else:
self.client = supermemory.AsyncSupermemory(api_key=api_key)
self.container_tags = _resolve_container_tags(config)
def _primary_container_tag(self, container_tag: Optional[str] = None) -> str:
return container_tag or self.container_tags[0]
def _primary_container_tag(self) -> str:
return self.container_tags[0]
def get_tool_definitions(self) -> List[ChatCompletionFunctionToolParam]:
"""Get OpenAI function definitions for all memory tools."""
@ -398,9 +403,7 @@ class SupermemoryTools:
async def execute_tool_call(self, tool_call: ChatCompletionMessageToolCall) -> str:
"""Execute a tool call based on the function name and arguments."""
function_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
handlers = {
handlers: Dict[str, Any] = {
"search_memories": self.search_memories,
"add_memory": self.add_memory,
"get_profile": self.get_profile,
@ -417,6 +420,24 @@ class SupermemoryTools:
"error": f"Unknown function: {function_name}",
}
else:
try:
args = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, TypeError):
return json.dumps({"success": False, "error": "Invalid tool arguments"})
if not isinstance(args, dict):
return json.dumps({"success": False, "error": "Invalid tool arguments"})
parameters = MEMORY_TOOL_SCHEMAS[function_name]["parameters"] or {}
properties = parameters.get("properties", {})
required = parameters.get("required", [])
if not isinstance(properties, dict) or not isinstance(required, list):
return json.dumps({"success": False, "error": "Invalid tool arguments"})
required_names = {name for name in required if isinstance(name, str)}
if set(args) - set(properties) or required_names - set(args):
return json.dumps({"success": False, "error": "Invalid tool arguments"})
result = await handler(**args)
return json.dumps(result)
@ -424,14 +445,26 @@ class SupermemoryTools:
async def search_memories(
self,
information_to_get: str,
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
include_full_docs: Optional[bool] = None,
limit: int = DEFAULT_LIMIT,
) -> MemorySearchResult:
"""Search memories."""
"""Search memories.
``include_full_docs`` remains a deprecated Python-only argument for
source compatibility. V4 search cannot return full source documents.
"""
if include_full_docs is not None:
warnings.warn(
"include_full_docs is deprecated and ignored because v4 search "
"does not return full source documents",
DeprecationWarning,
stacklevel=2,
)
try:
response: SearchMemoriesResponse = await self.client.search.memories(
q=information_to_get,
container_tags=self.container_tags,
container_tag=self._primary_container_tag(),
limit=limit,
threshold=DEFAULT_CHUNK_THRESHOLD,
search_mode="hybrid",
@ -479,27 +512,28 @@ class SupermemoryTools:
async def get_profile(
self,
container_tag: Optional[str] = None,
query: Optional[str] = None,
) -> ProfileResult:
"""Get user profile with optional query-scoped search results."""
try:
kwargs: Dict[str, Any] = {
"container_tag": self._primary_container_tag(container_tag),
}
if query:
kwargs["q"] = query
response = await self.client.profile(**kwargs)
profile = response.profile if hasattr(response, "profile") else None
search_results = (
response.search_results if hasattr(response, "search_results") else None
)
response = await self.client.profile(
container_tag=self._primary_container_tag(),
q=query,
)
else:
response = await self.client.profile(
container_tag=self._primary_container_tag(),
)
return ProfileResult(
success=True,
profile=profile if isinstance(profile, dict) else None,
search_results=search_results,
profile=_model_to_dict(response.profile),
search_results=(
_model_to_dict(response.search_results)
if response.search_results is not None
else None
),
)
except (OSError, ConnectionError) as network_error:
return ProfileResult(
@ -514,27 +548,24 @@ class SupermemoryTools:
async def document_list(
self,
container_tag: Optional[str] = None,
limit: Optional[int] = None,
page: Optional[int] = None,
) -> DocumentListResult:
"""List stored documents."""
try:
kwargs: Dict[str, Any] = {
"container_tags": [self._primary_container_tag(container_tag)],
"limit": limit or DEFAULT_LIMIT,
"container_tags": [self._primary_container_tag()],
"limit": DEFAULT_LIMIT if limit is None else limit,
}
if page is not None:
kwargs["page"] = page
response = await self.client.documents.list(**kwargs)
documents = response.memories if hasattr(response, "memories") else []
pagination = response.pagination if hasattr(response, "pagination") else None
return DocumentListResult(
success=True,
documents=documents,
pagination=pagination,
documents=[_model_to_dict(document) for document in response.memories],
pagination=_model_to_dict(response.pagination),
)
except (OSError, ConnectionError) as network_error:
return DocumentListResult(
@ -550,7 +581,19 @@ class SupermemoryTools:
async def document_delete(self, document_id: str) -> DocumentDeleteResult:
"""Delete a document by ID."""
try:
await self.client.documents.delete(document_id)
# The delete endpoint has no container-tag argument. Resolve custom IDs
# first and refuse documents whose complete tag set is not configured.
document = await self.client.documents.get(document_id)
document_tags = set(document.container_tags or [])
configured_tags = set(self.container_tags)
if not document_tags or not document_tags.issubset(configured_tags):
return DocumentDeleteResult(
success=False,
error="Document is outside configured scope",
)
await self.client.documents.delete(document.id)
return DocumentDeleteResult(
success=True,
message=f"Document {document_id} deleted successfully",
@ -605,7 +648,6 @@ class SupermemoryTools:
async def memory_forget(
self,
container_tag: Optional[str] = None,
memory_id: Optional[str] = None,
memory_content: Optional[str] = None,
reason: Optional[str] = None,
@ -618,14 +660,17 @@ class SupermemoryTools:
)
try:
await forget_memory_request(
api_key=self.api_key,
container_tag=self._primary_container_tag(container_tag),
memory_id=memory_id,
memory_content=memory_content,
reason=reason,
base_url=self.base_url,
)
kwargs: Dict[str, Any] = {
"container_tag": self._primary_container_tag(),
}
if memory_id:
kwargs["id"] = memory_id
if memory_content:
kwargs["content"] = memory_content
if reason:
kwargs["reason"] = reason
await self.client.memories.forget(**kwargs)
return MemoryForgetResult(
success=True,
message="Memory forgotten successfully",
@ -691,7 +736,7 @@ class SearchMemoriesTool:
async def execute(
self,
information_to_get: str,
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
include_full_docs: Optional[bool] = None,
limit: int = DEFAULT_LIMIT,
) -> MemorySearchResult:
"""Execute search memories."""
@ -723,11 +768,10 @@ class GetProfileTool:
async def execute(
self,
container_tag: Optional[str] = None,
query: Optional[str] = None,
) -> ProfileResult:
"""Execute get profile."""
return await self.tools.get_profile(container_tag=container_tag, query=query)
return await self.tools.get_profile(query=query)
class DocumentListTool:
@ -739,13 +783,11 @@ class DocumentListTool:
async def execute(
self,
container_tag: Optional[str] = None,
limit: Optional[int] = None,
page: Optional[int] = None,
) -> DocumentListResult:
"""Execute document list."""
return await self.tools.document_list(
container_tag=container_tag,
limit=limit,
page=page,
)
@ -793,14 +835,12 @@ class MemoryForgetTool:
async def execute(
self,
container_tag: Optional[str] = None,
memory_id: Optional[str] = None,
memory_content: Optional[str] = None,
reason: Optional[str] = None,
) -> MemoryForgetResult:
"""Execute memory forget."""
return await self.tools.memory_forget(
container_tag=container_tag,
memory_id=memory_id,
memory_content=memory_content,
reason=reason,

View file

@ -153,6 +153,10 @@ class TestToolDefinitions:
assert search_tool is not None
assert search_tool["type"] == "function"
assert "information_to_get" in search_tool["function"]["parameters"]["required"]
assert (
"include_full_docs"
not in search_tool["function"]["parameters"]["properties"]
)
# Check addMemory
add_tool = next(
@ -200,25 +204,32 @@ class TestMemoryOperationsUnit:
@pytest.mark.asyncio
async def test_search_memories_uses_search_memories_hybrid(self):
"""search_memories must call client.search.memories with hybrid mode."""
"""V4 search must use the primary singular tag and hybrid mode."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
tools = SupermemoryTools("test-key", {"container_tags": ["unit-tag"]})
tools = SupermemoryTools(
"test-key", {"container_tags": ["primary-tag", "secondary-tag"]}
)
tools.client.search.memories = AsyncMock(
return_value=SimpleNamespace(
results=[SimpleNamespace(model_dump=lambda: {"memory": "likes tea"})]
)
)
result = await tools.search_memories("tea", limit=3)
with pytest.warns(DeprecationWarning, match="include_full_docs"):
result = await tools.search_memories(
"tea", include_full_docs=False, limit=3
)
assert result["success"] is True
assert result["count"] == 1
tools.client.search.memories.assert_awaited_once()
kwargs = tools.client.search.memories.await_args.kwargs
assert kwargs["q"] == "tea"
assert kwargs["container_tags"] == ["unit-tag"]
assert kwargs["container_tag"] == "primary-tag"
assert "container_tags" not in kwargs
assert "include_full_docs" not in kwargs
assert kwargs["limit"] == 3
assert kwargs["search_mode"] == "hybrid"

View file

@ -377,7 +377,7 @@ resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
@ -392,7 +392,7 @@ resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
@ -422,7 +422,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@ -1355,7 +1355,7 @@ wheels = [
[[package]]
name = "supermemory"
version = "3.56.0"
version = "3.59.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1365,14 +1365,14 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/43/3a7619a697554555d37254bd1e63ff2bf1262bae8ab569eba57f823af3e4/supermemory-3.56.0.tar.gz", hash = "sha256:3cceb35465e79762c2213a56d2d17b38c554924ba19fd29e24ce09701f4b377d", size = 175386, upload-time = "2026-07-24T16:29:16.695Z" }
sdist = { url = "https://files.pythonhosted.org/packages/30/83/7db28873de639f4c4ac469266a2142d1f9def545a0cac2a023011df23601/supermemory-3.59.0.tar.gz", hash = "sha256:5efd5a5a087d552b0e00e739e4eeac21379d087bdfaa1c9f5995d11272b35bb3", size = 154326, upload-time = "2026-08-14T21:28:39.913Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/2a/0d8ac89c73f540caa38ced4df3b824abae11481243c60f6ef750103c973d/supermemory-3.56.0-py3-none-any.whl", hash = "sha256:334e5cd1a8743ed9b90aa25ad0e63266f6376b4d44d5bdfc5cc4f15fc19d72f9", size = 156297, upload-time = "2026-07-24T16:29:15.491Z" },
{ url = "https://files.pythonhosted.org/packages/af/73/0cf59b31317baf66b0bb9d1fa6506a54013e49fb35ffc9c12c3f3a189c5c/supermemory-3.59.0-py3-none-any.whl", hash = "sha256:3c66ae0fcb082241d8a600b4f8aaebe68159aee7cd24c2ce1aae533ea404bb2d", size = 142020, upload-time = "2026-08-14T21:28:38.842Z" },
]
[[package]]
name = "supermemory-openai-sdk"
version = "1.0.6"
version = "1.0.7"
source = { editable = "." }
dependencies = [
{ name = "openai" },
@ -1402,7 +1402,7 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'async'", specifier = ">=3.8.0" },
{ name = "openai", specifier = ">=1.102.0" },
{ name = "requests", specifier = ">=2.25.0" },
{ name = "supermemory", specifier = ">=3.16.0" },
{ name = "supermemory", specifier = ">=3.50.0" },
{ name = "typing-extensions", specifier = ">=4.0.0" },
]
provides-extras = ["async"]

View file

@ -1,14 +1,14 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.1.1",
"version": "2.2.0",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch --ignore-watch .turbo",
"check-types": "tsc --noEmit",
"test": "vitest --testTimeout 100000",
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
"test:watch": "vitest --watch --testTimeout 100000"
},
"dependencies": {

View file

@ -5,6 +5,7 @@ import {
DEFAULT_VALUES,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
deleteDocumentByIdentifier,
getContainerTags,
} from "./tools-shared"
import { forgetMemoryRequest } from "./shared/forget-memory"
@ -56,12 +57,12 @@ export const searchMemoriesTool = (
limit = DEFAULT_VALUES.limit,
}) => {
try {
const response = await client.search({
const response = await client.search.documents({
q: informationToGet,
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
containerTags,
limit,
threshold: DEFAULT_VALUES.chunkThreshold,
searchMode: "hybrid",
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
return {
@ -196,10 +197,12 @@ export const documentListTool = (
}),
execute: async ({ containerTag, limit, page }) => {
try {
const tag = containerTag || containerTags[0]
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
const response = await client.documents.list({
containerTags: [tag],
containerTags: scopeTags,
limit: limit || DEFAULT_VALUES.limit,
...(page !== undefined && { page }),
})
@ -227,15 +230,29 @@ export const documentDeleteTool = (
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
const containerTags = getContainerTags(config)
const strict = config?.strict ?? false
return tool({
description: TOOL_DESCRIPTIONS.documentDelete,
inputSchema: z.object({
documentId: z.string().describe(PARAMETER_DESCRIPTIONS.documentId),
containerTag: strict
? z
.string()
.nullable()
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag)
: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag),
}),
execute: async ({ documentId }) => {
execute: async ({ documentId, containerTag }) => {
try {
await client.documents.delete(documentId)
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
await deleteDocumentByIdentifier(client, documentId, scopeTags)
return {
success: true,

View file

@ -1,18 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path
// can be exercised deterministically without any network access. We only need
// `client.search()` to return a single document with known multi-line content.
const searchMock = vi.fn()
// Mock the Supermemory SDK so the Claude memory tool's document-backed file
// operations can be exercised deterministically without any network access.
const documentsListMock = vi.fn()
const documentsGetMock = vi.fn()
const documentsDeleteBulkMock = vi.fn()
const addMock = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = searchMock
add = addMock
memories = { forget: vi.fn() }
documents = { delete: vi.fn() }
documents = {
list: documentsListMock,
get: documentsGetMock,
deleteBulk: documentsDeleteBulkMock,
}
},
}
})
@ -22,20 +26,58 @@ import { ClaudeMemoryTool } from "./claude-memory"
const FILE_PATH = "/memories/notes.txt"
// 5 distinct lines so an off-by-one at either end is observable.
const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5"
const FILE_DOCUMENT = {
id: "document-notes",
customId: "memories_notes_txt",
filePath: FILE_PATH,
content: FILE_CONTENT,
}
const NEIGHBOUR_DOCUMENT = {
id: "document-notes-backup",
customId: "memories_notes_backup_txt",
filePath: "/memories/notes.backup.txt",
content: "backup stuff",
}
function mockDocuments(documents: (typeof FILE_DOCUMENT)[]) {
documentsListMock.mockResolvedValue({
memories: documents.map((document) => ({
id: document.id,
customId: document.customId,
containerTags: ["claude_memory"],
metadata: {
claude_memory_type: "file",
file_path: document.filePath,
},
})),
pagination: { totalPages: 1 },
})
documentsGetMock.mockImplementation(async (id: string) => {
const document = documents.find((candidate) => candidate.id === id)
if (!document) throw new Error(`Document not found: ${id}`)
return {
id: document.id,
customId: document.customId,
containerTags: ["sm_project_default", "claude_memory"],
metadata: {
claude_memory_type: "file",
file_path: document.filePath,
},
content: document.content,
}
})
}
function mockDocument(content: string) {
// `readFile` matches by `id === normalizePathToCustomId(path)`.
// normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
searchMock.mockResolvedValue({
results: [{ id: "memories_notes_txt", chunk: content }],
})
mockDocuments([{ ...FILE_DOCUMENT, content }])
}
describe("ClaudeMemoryTool view_range", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
mockDocument(FILE_CONTENT)
tool = new ClaudeMemoryTool("test-api-key")
})
@ -90,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
addMock.mockReset()
tool = new ClaudeMemoryTool("test-api-key")
})
it("view finds the exact file even when a neighbour ranks first", async () => {
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
{ id: "memories_notes_txt", chunk: FILE_CONTENT },
],
})
it("view finds the exact file even when a neighbour is listed first", async () => {
mockDocuments([NEIGHBOUR_DOCUMENT, FILE_DOCUMENT])
const result = await tool.handleCommand({
command: "view",
@ -114,13 +152,9 @@ describe("ClaudeMemoryTool exact-file matching", () => {
})
it("view reports not-found instead of returning a different file", async () => {
// Semantic search can surface a similarly-named file; that must not
// The document list can contain a similarly-named file; that must not
// be served as the requested one.
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
],
})
mockDocuments([NEIGHBOUR_DOCUMENT])
const result = await tool.handleCommand({
command: "view",
@ -132,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => {
})
it("str_replace refuses to modify a different file than requested", async () => {
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
],
})
mockDocuments([NEIGHBOUR_DOCUMENT])
const result = await tool.handleCommand({
command: "str_replace",
@ -154,28 +184,29 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
addMock.mockReset()
searchMock.mockResolvedValue({
results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }],
})
mockDocument(FILE_CONTENT)
tool = new ClaudeMemoryTool("test-api-key")
})
it.each(["$&", "$'", "$`", "$$"])(
"stores %s literally instead of expanding it as a replacement pattern",
async (dollarSequence) => {
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "line3",
new_str: `price is ${dollarSequence} today`,
})
it.each([
"$&",
"$'",
"$`",
"$$",
])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => {
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "line3",
new_str: `price is ${dollarSequence} today`,
})
expect(result.success).toBe(true)
expect(addMock).toHaveBeenCalledTimes(1)
const stored = addMock.mock.calls[0]?.[0]?.content as string
expect(stored).toContain(`price is ${dollarSequence} today`)
},
)
expect(result.success).toBe(true)
expect(addMock).toHaveBeenCalledTimes(1)
const stored = addMock.mock.calls[0]?.[0]?.content as string
expect(stored).toContain(`price is ${dollarSequence} today`)
})
})

View file

@ -1,5 +1,5 @@
import Supermemory from "supermemory"
import { getContainerTags } from "./tools-shared"
import { deleteDocumentById, getContainerTags } from "./tools-shared"
import type { SupermemoryToolsConfig } from "./types"
// Claude Memory Tool Types
@ -37,6 +37,14 @@ export interface MemoryToolResult {
is_error: boolean
}
type ClaudeFileMetadata = Record<string, string | number | boolean | string[]>
interface ClaudeFileDocument {
documentId: string
content: string
metadata: ClaudeFileMetadata
}
/**
* Claude Memory Tool - Client-side implementation
* Maps Claude's memory tool commands to supermemory document operations
@ -44,6 +52,7 @@ export interface MemoryToolResult {
export class ClaudeMemoryTool {
private client: Supermemory
private containerTags: string[]
private scopeContainerTags: [string, ...string[]]
private memoryContainerPrefix: string
/**
@ -68,6 +77,7 @@ export class ClaudeMemoryTool {
// Get base container tags and add memory-specific tag
const baseContainerTags = getContainerTags(config)
this.scopeContainerTags = baseContainerTags
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]
}
@ -140,7 +150,7 @@ export class ClaudeMemoryTool {
default:
return {
success: false,
error: `Unknown command: ${(command as any).command}`,
error: `Unknown command: ${(command as { command: string }).command}`,
}
}
} catch (error) {
@ -193,44 +203,89 @@ export class ClaudeMemoryTool {
*/
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
try {
// Search for all memory files
const response = await this.client.search({
q: "*", // Search for all
...(this.containerTags[0]
? { containerTag: this.containerTags[0] }
: {}),
limit: 100, // Get many files (max allowed)
searchMode: "hybrid",
})
// Document search returns ranked chunks, not a complete inventory. Walk
// every page of the document-list endpoint so files cannot disappear
// from a directory merely because they did not rank in a search page.
const documents: Supermemory.DocumentListResponse.Memory[] = []
let page = 1
if (!response.results) {
return {
success: true,
content: `Directory: ${dirPath}\n(empty)`,
}
while (true) {
const response = await this.client.documents.list({
containerTags: this.scopeContainerTags,
filters: {
AND: [
{ key: "claude_memory_type", value: "file" },
{
key: "file_path",
value: dirPath,
filterType: "string_contains",
},
],
},
includeContent: false,
limit: 100,
page,
})
documents.push(...response.memories)
if (page >= response.pagination.totalPages) break
page += 1
}
// Filter files that match the directory path and extract relative paths
const files: string[] = []
const dirs = new Set<string>()
const candidates: Array<{
document: Supermemory.DocumentListResponse.Memory
filePath: string
}> = []
for (const result of response.results) {
// Get the file path from metadata (since customId is normalized)
const filePath = result.metadata?.file_path as string
if (!filePath || !filePath.startsWith(dirPath)) continue
for (const document of documents) {
if (!this.isDocumentInConfiguredScope(document)) continue
// Get relative path from directory
const relativePath = filePath.substring(dirPath.length)
if (!relativePath) continue
const filePath = this.getDocumentFilePath(document)
if (!filePath || !filePath.startsWith(dirPath)) {
continue
}
candidates.push({ document, filePath })
}
// If path contains /, it's in a subdirectory
const slashIndex = relativePath.indexOf("/")
if (slashIndex > 0) {
// It's a subdirectory
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
} else if (relativePath !== "") {
// It's a file in this directory
files.push(relativePath)
// Full GETs are required to verify hidden project tags. Keep them bounded
// so large directories do not become a long serial chain or a burst of
// unbounded requests.
const verificationBatchSize = 8
for (
let index = 0;
index < candidates.length;
index += verificationBatchSize
) {
const batch = candidates.slice(index, index + verificationBatchSize)
const verified = await Promise.all(
batch.map(async (candidate) =>
(await this.isDirectoryDocumentInExactScope(candidate.document))
? candidate
: undefined,
),
)
for (const candidate of verified) {
if (!candidate) continue
const { filePath } = candidate
// Get relative path from directory
const relativePath = filePath.substring(dirPath.length)
if (!relativePath) continue
// If path contains /, it's in a subdirectory
const slashIndex = relativePath.indexOf("/")
if (slashIndex > 0) {
// It's a subdirectory
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
} else if (relativePath !== "") {
// It's a file in this directory
files.push(relativePath)
}
}
}
@ -264,10 +319,8 @@ export class ClaudeMemoryTool {
viewRange?: [number, number],
): Promise<MemoryResponse> {
try {
// Same lookup as every mutating command: limit 5 so the exact
// customId match is findable among semantic near-neighbours.
// With the old limit of 1, a similarly-named file ranking first
// made this return the wrong file's contents as a success.
// Resolve the exact document inside the configured scope so reads and
// mutations use the complete stored file, not one ranked search chunk.
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
@ -278,7 +331,7 @@ export class ClaudeMemoryTool {
const document = readResult.document
let content: string = document.raw || document.content || ""
let content = document.content
// Apply line range if specified
if (viewRange) {
@ -376,8 +429,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
// Check if old_str exists in the content
if (!originalContent.includes(oldStr)) {
@ -435,8 +487,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
const lines = originalContent.split("\n")
// Validate line number
@ -490,9 +541,7 @@ export class ClaudeMemoryTool {
}
}
const documentId =
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
await this.client.documents.delete(documentId)
await deleteDocumentById(this.client, readResult.document.documentId)
return {
success: true,
@ -531,8 +580,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
const newNormalizedId = this.normalizePathToCustomId(newPath)
// Create new document with new path
@ -552,8 +600,7 @@ export class ClaudeMemoryTool {
// customId — the add above already replaced the content.
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
if (oldNormalizedId !== newNormalizedId) {
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
await this.client.documents.delete(oldDocumentId)
await deleteDocumentById(this.client, readResult.document.documentId)
}
return {
@ -573,48 +620,124 @@ export class ClaudeMemoryTool {
*/
private async getFileDocument(filePath: string): Promise<{
success: boolean
document?: any
document?: ClaudeFileDocument
error?: string
}> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
let page = 1
const candidates = new Map<
string,
Supermemory.DocumentListResponse.Memory
>()
const response = await this.client.search({
q: normalizedId,
...(this.containerTags[0]
? { containerTag: this.containerTags[0] }
: {}),
limit: 5,
searchMode: "hybrid",
})
// customId values are only unique within an exact container-tag set in
// Mono. Resolve the matching document inside this tool's configured
// scope before fetching by internal ID; a direct get(customId) can pick
// another project/user's same-named file.
while (true) {
const response = await this.client.documents.list({
containerTags: this.scopeContainerTags,
filters: {
AND: [
{ key: "claude_memory_type", value: "file" },
{ key: "file_path", value: filePath },
],
},
includeContent: false,
limit: 100,
page,
})
// Only accept the exact customId match. Falling back to the top
// semantic hit would let callers read — and worse, modify or
// delete — a different file than the one they asked for.
const match = response.results?.find(
(r) =>
r.id === normalizedId ||
r.documents?.some((d) => d.id === normalizedId),
)
for (const document of response.memories) {
if (
document.customId === normalizedId &&
this.getDocumentFilePath(document) === filePath &&
this.isDocumentInConfiguredScope(document)
) {
candidates.set(document.id, document)
}
}
if (!match) {
if (page >= response.pagination.totalPages) break
page += 1
}
const exactMatches: Array<{
candidate: Supermemory.DocumentListResponse.Memory
document: Supermemory.DocumentGetResponse
}> = []
let hasUnverifiedCandidate = false
for (const candidate of candidates.values()) {
let document: Supermemory.DocumentGetResponse
try {
document = await this.client.documents.get(candidate.id)
} catch (error) {
if (error instanceof Supermemory.NotFoundError) continue
throw error
}
if (document.id !== candidate.id) {
hasUnverifiedCandidate = true
continue
}
if (
document.customId !== normalizedId ||
this.getDocumentFilePath(document) !== filePath ||
!this.hasExactContainerTags(document.containerTags)
) {
continue
}
exactMatches.push({ candidate, document })
}
if (exactMatches.length === 0) {
return {
success: false,
error: `File not found: ${filePath}`,
}
}
if (exactMatches.length > 1) {
return {
success: false,
error: `File path is ambiguous in the configured container scope: ${filePath}`,
}
}
if (hasUnverifiedCandidate) {
return {
success: false,
error: `File path could not be resolved unambiguously in the configured container scope: ${filePath}`,
}
}
const content = match.chunk || match.memory || ""
const documentId = match.documents?.[0]?.id ?? match.id
const match = exactMatches[0]
if (!match) {
return { success: false, error: `File not found: ${filePath}` }
}
const { candidate, document } = match
const content =
typeof document.content === "string"
? document.content
: typeof document.raw === "string"
? document.raw
: undefined
if (content === undefined) {
return {
success: false,
error: `File content unavailable: ${filePath}`,
}
}
const metadata =
document.metadata &&
typeof document.metadata === "object" &&
!Array.isArray(document.metadata)
? (document.metadata as ClaudeFileMetadata)
: {}
return {
success: true,
document: {
documentId,
content,
raw: content,
metadata: match.metadata,
},
document: { documentId: candidate.id, content, metadata },
}
} catch (error) {
return {
@ -624,6 +747,60 @@ export class ClaudeMemoryTool {
}
}
private getDocumentFilePath(document: {
metadata: unknown
}): string | undefined {
const metadata = document.metadata
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const metadataRecord = metadata as Record<string, unknown>
return typeof metadataRecord.file_path === "string"
? metadataRecord.file_path
: undefined
}
private isDocumentInConfiguredScope(
document: Supermemory.DocumentListResponse.Memory,
): boolean {
const documentTags = document.containerTags ?? []
const expectedTags = this.containerTags.filter(
(tag) => !tag.startsWith("sm_project_"),
)
return (
documentTags.length === expectedTags.length &&
documentTags.every((tag, index) => tag === expectedTags[index])
)
}
private async isDirectoryDocumentInExactScope(
document: Supermemory.DocumentListResponse.Memory,
): Promise<boolean> {
try {
// Mono strips internal project tags from every list response, so only a
// full get can prove that no hidden tags change this document's scope.
const fullDocument = await this.client.documents.get(document.id)
return (
fullDocument.id === document.id &&
this.hasExactContainerTags(fullDocument.containerTags)
)
} catch (error) {
if (!(error instanceof Supermemory.NotFoundError)) throw error
// A document can disappear between list and get. Skip stale entries
// instead of failing the entire directory view.
return false
}
}
private hasExactContainerTags(containerTags?: string[]): boolean {
return (
containerTags?.length === this.containerTags.length &&
containerTags.every((tag, index) => tag === this.containerTags[index])
)
}
/**
* Validate that path starts with /memories for security
*/

View file

@ -4,6 +4,7 @@ import {
DEFAULT_VALUES,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
deleteDocumentByIdentifier,
getContainerTags,
} from "../tools-shared"
import { forgetMemoryRequest } from "../shared/forget-memory"
@ -14,7 +15,7 @@ import type { SupermemoryToolsConfig } from "../types"
*/
export interface MemorySearchResult {
success: boolean
results?: Awaited<ReturnType<Supermemory["search"]>>["results"]
results?: Awaited<ReturnType<Supermemory["search"]["documents"]>>["results"]
count?: number
error?: string
}
@ -31,7 +32,7 @@ export interface ProfileResult {
static: string[]
dynamic: string[]
}
searchResults?: Awaited<ReturnType<Supermemory["search"]>>
searchResults?: Awaited<ReturnType<Supermemory["profile"]>>["searchResults"]
error?: string
}
@ -159,6 +160,10 @@ export const memoryToolSchemas = {
type: "string",
description: PARAMETER_DESCRIPTIONS.documentId,
},
containerTag: {
type: "string",
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
},
},
required: ["documentId"],
},
@ -248,12 +253,12 @@ export function createSearchMemoriesFunction(
limit?: number
}): Promise<MemorySearchResult> {
try {
const response = await client.search({
const response = await client.search.documents({
q: informationToGet,
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
containerTags,
limit,
threshold: DEFAULT_VALUES.chunkThreshold,
searchMode: "hybrid",
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
return {
@ -363,10 +368,12 @@ export function createDocumentListFunction(
page?: number
}): Promise<DocumentListResult> {
try {
const tag = containerTag || containerTags[0]
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
const response = await client.documents.list({
containerTags: [tag],
containerTags: scopeTags,
limit: limit || DEFAULT_VALUES.limit,
...(page !== undefined && { page }),
})
@ -392,15 +399,20 @@ export function createDocumentDeleteFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client } = createClient(apiKey, config)
const { client, containerTags } = createClient(apiKey, config)
return async function documentDelete({
documentId,
containerTag,
}: {
documentId: string
containerTag?: string
}): Promise<DocumentDeleteResult> {
try {
await client.documents.delete(documentId)
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
await deleteDocumentByIdentifier(client, documentId, scopeTags)
return {
success: true,

View file

@ -1,4 +1,5 @@
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
const FETCH_TIMEOUT_MS = 30_000
export interface ForgetMemoryParams {
containerTag: string
@ -7,6 +8,10 @@ export interface ForgetMemoryParams {
reason?: string
}
export interface ForgetMemoryRequestOptions {
signal?: AbortSignal
}
/**
* Marks a memory as forgotten via `DELETE /v4/memories`.
*
@ -19,6 +24,7 @@ export async function forgetMemoryRequest(
apiKey: string,
params: ForgetMemoryParams,
baseUrl: string = DEFAULT_BASE_URL,
options?: ForgetMemoryRequestOptions,
): Promise<void> {
const response = await fetch(`${baseUrl}/v4/memories`, {
method: "DELETE",
@ -27,6 +33,7 @@ export async function forgetMemoryRequest(
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {

View file

@ -2,18 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool
// executions can be verified deterministically without network access.
const documentsDelete = vi.fn()
const documentsDeleteBulk = vi.fn()
const documentsGet = vi.fn()
const documentsList = vi.fn()
const searchMock = vi.fn()
const clientAdd = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = searchMock
add = clientAdd
documents = {
delete: documentsDelete,
deleteBulk: documentsDeleteBulk,
get: documentsGet,
list: documentsList,
add: vi.fn(),
}
@ -35,12 +35,20 @@ function executeTool(tool: unknown, args: Record<string, unknown>) {
}
beforeEach(() => {
documentsDelete.mockReset().mockResolvedValue(undefined)
documentsDeleteBulk.mockReset().mockResolvedValue({
success: true,
deletedCount: 1,
errors: [],
})
documentsGet.mockReset().mockResolvedValue({
id: "doc_123",
customId: "doc_123",
containerTags: ["sm_project_default"],
})
documentsList.mockReset().mockResolvedValue({
memories: [{ id: "doc_1", title: "Doc one" }],
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
})
searchMock.mockReset()
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
vi.unstubAllGlobals()
})
@ -53,7 +61,8 @@ describe("documentDelete", () => {
}
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith("doc_123")
expect(documentsGet).toHaveBeenCalledWith("doc_123")
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] })
})
})
@ -109,6 +118,22 @@ describe("memoryForget", () => {
id: "mem_1",
reason: "outdated",
})
expect(init.signal).toBeInstanceOf(AbortSignal)
})
it("uses a caller-provided signal instead of creating a timeout", async () => {
const fetchMock = stubFetch()
const controller = new AbortController()
await forgetMemoryRequest(
API_KEY,
{ containerTag: "user_1", id: "mem_1" },
undefined,
{ signal: controller.signal },
)
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
})
it("throws a descriptive error on non-2xx responses", async () => {
@ -161,16 +186,30 @@ describe("memoryForget", () => {
describe("ClaudeMemoryTool", () => {
const FILE_PATH = "/memories/prefs.txt"
const CUSTOM_ID = "memories_prefs_txt"
const DOCUMENT_ID = "doc_file_1"
function mockFileDocument(content: string) {
searchMock.mockResolvedValue({
results: [
const metadata = {
claude_memory_type: "file",
file_path: FILE_PATH,
}
documentsList.mockResolvedValue({
memories: [
{
id: CUSTOM_ID,
chunk: content,
metadata: { file_path: FILE_PATH },
id: DOCUMENT_ID,
customId: CUSTOM_ID,
containerTags: ["claude_memory"],
metadata,
},
],
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
})
documentsGet.mockResolvedValue({
id: DOCUMENT_ID,
customId: CUSTOM_ID,
containerTags: ["sm_project_default", "claude_memory"],
content,
metadata,
})
}
@ -231,7 +270,7 @@ describe("ClaudeMemoryTool", () => {
})
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
})
it("rename removes the old document after creating the new one", async () => {
@ -248,6 +287,6 @@ describe("ClaudeMemoryTool", () => {
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ customId: "memories_renamed_txt" }),
)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
})
})

View file

@ -2,49 +2,53 @@
* Shared constants and descriptions for Supermemory tools
*/
import type Supermemory from "supermemory"
import type { MemoryMode } from "./shared/types"
// Tool descriptions
export const TOOL_DESCRIPTIONS = {
searchMemories:
"Search (recall) stored memories for facts, preferences, history, and context about the user or any topic. Use proactively before answering whenever memory could help — do not wait for the user to explicitly ask you to search or recall. Search when the question touches personal context, past conversations, preferences, projects, people, plans, or anything you may have learned before. Results include memory/chunk IDs — use those IDs with memoryForget to remove a specific learned fact.",
"Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.",
addMemory:
"Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.",
getProfile:
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query. Profile and search result entries may include memory IDs useful for memoryForget.",
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.",
documentList:
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.",
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.",
documentDelete:
"Permanently delete a stored document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).",
"Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.",
documentAdd:
"Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.",
memoryForget:
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId (preferred — from searchMemories or getProfile) OR memoryContent for an exact text match. Use when the user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). To remove an entire conversation or file, use documentDelete instead.",
"Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.",
} as const
// Parameter descriptions
export const PARAMETER_DESCRIPTIONS = {
informationToGet:
"What to look up in memory — keywords from the user's message, topic, entity names, or question phrasing. Search even when the user did not explicitly ask you to recall.",
"What to look up in stored context — keywords from the user's message, topic, entity names, or question phrasing.",
includeFullDocs:
"Whether to include the full document content in the response. Defaults to true for better AI context.",
limit: "Maximum number of results to return",
memory:
"The text content of the memory to add. This should be a single sentence or a short paragraph.",
containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)",
documentContainerTag:
"Optional one-tag scope override. When deleting a document returned by documentList with a containerTag override, pass the same value here. In strict mode, pass null to use the configured union.",
query: "Optional search query to include relevant search results",
page: "Page number to fetch, 1-based (default: 1)",
documentId:
"Document ID from documentList — permanently deletes the source document and all extracted memories. Not a profile memory ID.",
"Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.",
content:
"Document body to store — plain text, a conversation transcript, a long pasted blob, or a URL to a webpage/PDF/image/video. Content is queued and memories are extracted automatically after background processing; do not split into addMemory calls.",
title: "Optional title for the document",
description: "Optional description for the document",
memoryId:
"Profile memory ID from searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.",
"Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.",
memoryContent:
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, search first and use memoryId.",
reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)",
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.",
reason:
"Optional reason recorded when forgetting (e.g. outdated, user correction)",
} as const
// Default values
@ -57,7 +61,7 @@ export const DEFAULT_VALUES = {
// Container tag constants
export const CONTAINER_TAG_CONSTANTS = {
projectPrefix: "sm_project_",
defaultTags: ["sm_project_default"] as string[],
defaultTags: ["sm_project_default"] as const,
} as const
/**
@ -66,16 +70,171 @@ export const CONTAINER_TAG_CONSTANTS = {
export function getContainerTags(config?: {
projectId?: string
containerTags?: string[]
}): string[] {
}): [string, ...string[]] {
if (config?.projectId !== undefined && config.containerTags !== undefined) {
throw new Error(
"Supermemory tools config accepts either projectId or containerTags, not both.",
)
}
if (config?.projectId) {
if (config?.projectId !== undefined) {
if (config.projectId.trim() === "") {
throw new Error(
"Supermemory tools config requires a non-empty projectId.",
)
}
return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`]
}
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
if (config?.containerTags !== undefined) {
const [firstTag, ...remainingTags] = config.containerTags
if (
firstTag === undefined ||
config.containerTags.some((tag) => tag.trim() === "")
) {
throw new Error(
"Supermemory tools config requires at least one non-empty containerTag.",
)
}
return [firstTag, ...remainingTags]
}
return [...CONTAINER_TAG_CONSTANTS.defaultTags]
}
/** Delete exactly one document by its internal ID. */
export async function deleteDocumentById(
client: Supermemory,
documentId: string,
): Promise<void> {
const response = await client.documents.deleteBulk({ ids: [documentId] })
if (response.success && response.deletedCount === 1) return
const detail = response.errors?.find(
(error) => error.id === documentId,
)?.error
throw new Error(
detail
? `Failed to delete document ${documentId}: ${detail}`
: `Failed to delete document ${documentId}: expected one deletion, received ${response.deletedCount}`,
)
}
/**
* Resolve an internal ID or customId inside the effective container-tag union,
* then delete the exact internal document ID. Internal IDs take precedence over
* customId matches.
*/
export async function deleteDocumentByIdentifier(
client: Supermemory,
documentIdentifier: string,
containerTags: readonly [string, ...string[]],
): Promise<void> {
const directMatch = await getDocumentIfFound(client, documentIdentifier)
if (
directMatch?.id === documentIdentifier &&
hasContainerTagOverlap(directMatch.containerTags, containerTags)
) {
await deleteDocumentById(client, directMatch.id)
return
}
const candidateIds = new Set<string>()
let hasInternalIdCandidate = false
let page = 1
while (true) {
const response = await client.documents.list({
containerTags: [...containerTags],
includeContent: false,
limit: 100,
page,
})
for (const document of response.memories) {
if (document.id === documentIdentifier) {
hasInternalIdCandidate = true
}
if (
document.id === documentIdentifier ||
document.customId === documentIdentifier
) {
candidateIds.add(document.id)
}
}
if (page >= response.pagination.totalPages) break
page += 1
}
let exactIdMatch: string | undefined
let hasUnverifiedCandidate = false
const customIdMatches: string[] = []
for (const candidateId of candidateIds) {
const document = await getDocumentIfFound(client, candidateId)
if (document?.id !== candidateId) {
hasUnverifiedCandidate = true
continue
}
if (!hasContainerTagOverlap(document.containerTags, containerTags)) {
continue
}
if (document.id === documentIdentifier) {
exactIdMatch = document.id
break
}
if (document.customId === documentIdentifier) {
customIdMatches.push(document.id)
} else {
hasUnverifiedCandidate = true
}
}
if (exactIdMatch) {
await deleteDocumentById(client, exactIdMatch)
return
}
if (hasInternalIdCandidate) {
throw new Error(
`Document ID ${documentIdentifier} could not be verified safely in the configured container scope.`,
)
}
if (hasUnverifiedCandidate) {
throw new Error(
`Document identifier ${documentIdentifier} could not be resolved unambiguously in the configured container scope.`,
)
}
if (customIdMatches.length === 1) {
await deleteDocumentById(client, customIdMatches[0] as string)
return
}
if (customIdMatches.length > 1) {
throw new Error(
`Document customId ${documentIdentifier} is ambiguous in the configured container scope.`,
)
}
throw new Error(
`Document ${documentIdentifier} was not found in the configured container scope.`,
)
}
async function getDocumentIfFound(client: Supermemory, documentId: string) {
try {
return await client.documents.get(documentId)
} catch (error) {
if (isNotFoundError(error)) return undefined
throw error
}
}
function isNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
error.status === 404
)
}
function hasContainerTagOverlap(
actual: string[] | undefined,
expected: readonly string[],
): boolean {
return actual?.some((tag) => expected.includes(tag)) ?? false
}
/**

View file

@ -18,7 +18,11 @@ import {
type Logger,
type MemoryMode,
} from "../shared"
import type { SupermemoryVoltAgent, VoltAgentMessage } from "./types"
import type {
SearchFilters,
SupermemoryVoltAgent,
VoltAgentMessage,
} from "./types"
/**
* Context for Supermemory middleware operations.
@ -47,7 +51,7 @@ export interface SupermemoryMiddlewareContext {
limit?: number
rerank?: boolean
rewriteQuery?: boolean
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
filters?: SearchFilters
include?: {
chunks?: boolean
documents?: boolean
@ -258,23 +262,7 @@ export const enhanceMessagesWithMemories = async (
if (useAdvancedSearch && ctx.mode !== "profile") {
ctx.logger.info("Using advanced search with custom parameters")
const searchParams: {
q: string
containerTag: string
threshold?: number
limit?: number
rerank?: boolean
rewriteQuery?: boolean
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
include?: {
chunks?: boolean
documents?: boolean
forgottenMemories?: boolean
relatedMemories?: boolean
summaries?: boolean
}
searchMode?: "memories" | "documents" | "hybrid"
} = {
const searchParams: Supermemory.SearchParams = {
q: queryText,
containerTag: ctx.containerTag,
}
@ -288,31 +276,32 @@ export const enhanceMessagesWithMemories = async (
if (ctx.include !== undefined) searchParams.include = ctx.include
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
const response = await ctx.client.search.memories(searchParams)
const response = await ctx.client.search(searchParams)
// Hybrid search returns both memory entries (`memory` field) and
// document chunks (`chunk` field). Handle both.
type SearchResult = {
memory?: string
chunk?: string
metadata?: Record<string, unknown>
}
const formattedMemories = response.results
.map((result: SearchResult) => {
const text = result.memory || result.chunk
return text ? `- ${text}` : null
})
.filter(Boolean)
// document chunks (`chunk` field). Normalize both for prompt templates.
const searchResults = response.results.flatMap((result) => {
const memory = result.memory ?? result.chunk
if (!memory) {
return []
}
return [
{
memory,
...(result.metadata ? { metadata: result.metadata } : {}),
},
]
})
const formattedMemories = searchResults
.map((result) => `- ${result.memory}`)
.join("\n")
memories = ctx.promptTemplate
? ctx.promptTemplate({
userMemories: "",
generalSearchMemories: formattedMemories,
searchResults: response.results as Array<{
memory: string
metadata?: Record<string, unknown>
}>,
searchResults,
})
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
} else {

View file

@ -5,6 +5,7 @@
* Supermemory by providing hooks that inject memories before LLM calls.
*/
import type Supermemory from "supermemory"
import type {
PromptTemplate,
MemoryMode,
@ -58,7 +59,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
/**
* Advanced filters to apply to the search using AND/OR logic.
* Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] }
* Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] }
*
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
*/
@ -99,7 +100,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
/**
* Advanced search filters using AND/OR logic
*/
export type SearchFilters = { OR: Array<unknown> } | { AND: Array<unknown> }
export type SearchFilters = NonNullable<Supermemory.SearchParams["filters"]>
/**
* Options for including additional data in search results

View file

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

View file

@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
},
"exports": {
"./*": "./*"
},

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
import {
DocumentsWithMemoriesQuerySchema,
ListMemoriesQuerySchema,
SearchRequestSchema,
Searchv4RequestSchema,
} from "./api"
describe("search threshold schemas", () => {
it("do not contain redundant number transforms or unreachable range guards", () => {
@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
).toBe(false)
})
})
describe("pagination query schemas", () => {
it("preserve page/limit defaults", () => {
const listed = ListMemoriesQuerySchema.parse({})
expect(listed.page).toBe(1)
expect(listed.limit).toBe(10)
const docs = DocumentsWithMemoriesQuerySchema.parse({})
expect(docs.page).toBe(1)
expect(docs.limit).toBe(10)
})
it.each([
1, 50, 1100,
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
})
it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
expect(parsed.page).toBe(3)
expect(parsed.limit).toBe(25)
})
it.each([
0, -5, 2.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
})
it.each([
0, -1, 1.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
})
it("ListMemoriesQuerySchema still caps limit at 1100", () => {
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
false,
)
})
it.each([
0, -1, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
false,
)
})
it.each([
0, -10, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
false,
)
})
it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
const parsed = DocumentsWithMemoriesQuerySchema.parse({
page: 2,
limit: 50,
})
expect(parsed.page).toBe(2)
expect(parsed.limit).toBe(50)
})
})

View file

@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Limit must be a positive integer",
})
.refine((value) => value <= 1100, {
message: "Limit cannot be greater than 1100",
})
@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Page must be a positive integer",
})
.default("1")
.openapi({ description: "Page number to fetch", example: "1" }),
sort: z
@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z
export const DocumentsWithMemoriesQuerySchema = z
.object({
page: z.number().default(1).openapi({
page: z.number().int().min(1).default(1).openapi({
description: "Page number to fetch",
example: 1,
}),
limit: z.number().default(10).openapi({
limit: z.number().int().min(1).default(10).openapi({
description: "Number of items per page",
example: 10,
}),

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