diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 19f7fc61..6d730c98 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -9,97 +9,249 @@ 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 (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.10" + dependency-lane: minimum-supermemory + supermemory-version: "3.16.0" + - python-version: "3.13" + dependency-lane: current-supermemory + supermemory-version: "3.59.0" defaults: run: working-directory: packages/agent-framework-python steps: - name: Checkout code - uses: actions/checkout@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 tested Supermemory SDK + run: >- + python -m pip install "$RUNNER_TEMP"/wheels/*.whl + "supermemory==${{ matrix.supermemory-version }}" + + - name: Check dependency compatibility + run: python -m pip check + + - name: Verify installed wheel and SDK version + run: >- + python -c "from importlib.metadata import version; from pathlib import Path; + import supermemory_agent_framework; + assert version('supermemory') == '${{ matrix.supermemory-version }}'; + assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts" - name: Run tests - run: 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-dependencies + supermemory-version: "3.16.0" + cartesia-line-version: "0.2.0" + - python-version: "3.12" + dependency-lane: current-dependencies + supermemory-version: "3.59.0" + cartesia-line-version: "0.2.17" defaults: run: working-directory: packages/cartesia-sdk-python steps: - name: Checkout code - uses: actions/checkout@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 and test tools + run: python -m pip install build pytest + + - name: Build wheel + run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels" + + - name: Install wheel and tested runtime dependencies + run: >- + python -m pip install "$RUNNER_TEMP"/wheels/*.whl + "supermemory==${{ matrix.supermemory-version }}" + "cartesia-line==${{ matrix.cartesia-line-version }}" + + - name: Check dependency compatibility + run: python -m pip check + + - name: Verify real Cartesia Line integration and run tests + run: >- + python -c "from importlib.metadata import version; + from pathlib import Path; import line, pytest, supermemory_cartesia; + assert version('supermemory') == '${{ matrix.supermemory-version }}'; + assert version('cartesia-line') == '${{ matrix.cartesia-line-version }}'; + assert 'site-packages' in Path(supermemory_cartesia.__file__).parts; + result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']); + raise SystemExit(result)" pipecat-sdk-python: - name: pipecat-sdk-python + 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-dependencies + supermemory-version: "3.16.0" + pipecat-version: "0.0.98" + - python-version: "3.12" + dependency-lane: current-dependencies + supermemory-version: "3.59.0" + pipecat-version: "1.7.0" defaults: run: working-directory: packages/pipecat-sdk-python steps: - name: Checkout code - uses: actions/checkout@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 and test tools + run: python -m pip install build pytest + + - name: Build wheel + run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels" + + - name: Install wheel and tested runtime dependencies + run: >- + python -m pip install "$RUNNER_TEMP"/wheels/*.whl + "supermemory==${{ matrix.supermemory-version }}" + "pipecat-ai==${{ matrix.pipecat-version }}" + + - name: Check dependency compatibility + run: python -m pip check + + - name: Verify real Pipecat integration and run tests + run: >- + python -c "from importlib.metadata import version; + from pathlib import Path; import pipecat, pytest, supermemory_pipecat; + assert version('supermemory') == '${{ matrix.supermemory-version }}'; + assert version('pipecat-ai') == '${{ matrix.pipecat-version }}'; + assert 'site-packages' in Path(supermemory_pipecat.__file__).parts; + result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']); + raise SystemExit(result)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..5262b786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,43 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run TypeScript type checking - run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Detect SDK package changes + id: sdk-changes + run: | + if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then + echo "tools=false" >> "$GITHUB_OUTPUT" + else + echo "tools=true" >> "$GITHUB_OUTPUT" + fi + + if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/ai-sdk; then + echo "ai_sdk=false" >> "$GITHUB_OUTPUT" + else + echo "ai_sdk=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run Tools unit tests + if: steps.sdk-changes.outputs.tools == 'true' + run: bun run --cwd packages/tools test:unit + + - name: Build Tools package + if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' + run: bun run --cwd packages/tools build + + - name: Run AI SDK type checking + if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' + run: bun run --cwd packages/ai-sdk check-types + + - name: Run AI SDK unit tests + if: steps.sdk-changes.outputs.ai_sdk == 'true' + run: bun run --cwd packages/ai-sdk test:unit + + - name: Build AI SDK package + if: steps.sdk-changes.outputs.ai_sdk == 'true' + run: bun run --cwd packages/ai-sdk build + + - name: Run Memory Graph type checking + run: bun run --cwd packages/memory-graph check-types - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/.github/workflows/publish-agent-framework-python.yml b/.github/workflows/publish-agent-framework-python.yml index ef74637d..fe678968 100644 --- a/.github/workflows/publish-agent-framework-python.yml +++ b/.github/workflows/publish-agent-framework-python.yml @@ -23,20 +23,22 @@ jobs: working-directory: ./packages/agent-framework-python steps: - name: Checkout - 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" - name: Install build dependencies - run: pip install hatchling build + run: python -m pip install hatchling build - name: Build package run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: packages/agent-framework-python/dist/ diff --git a/.github/workflows/publish-ai-sdk.yml b/.github/workflows/publish-ai-sdk.yml index 1ac60cf1..11559c0d 100644 --- a/.github/workflows/publish-ai-sdk.yml +++ b/.github/workflows/publish-ai-sdk.yml @@ -4,7 +4,7 @@ on: push: branches: - main - paths: + paths: - "packages/ai-sdk/package.json" concurrency: @@ -15,7 +15,7 @@ jobs: publish: runs-on: ubuntu-latest timeout-minutes: 15 - permissions: + permissions: contents: read id-token: write defaults: @@ -38,26 +38,65 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: bun install + working-directory: . + run: bun install --frozen-lockfile - name: Check if version changed id: version-check run: | PACKAGE_NAME=$(jq -r '.name' package.json) LOCAL_VERSION=$(jq -r '.version' package.json) - NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") - if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then + if npm view "$PACKAGE_NAME@$LOCAL_VERSION" version >/dev/null 2>&1; then echo "Version $LOCAL_VERSION already published, skipping." echo "changed=false" >> "$GITHUB_OUTPUT" else - echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" + echo "Publishing $LOCAL_VERSION." echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Build + - name: Wait for the Tools dependency + if: steps.version-check.outputs.changed == 'true' + run: | + TOOLS_SPEC=$(jq -r '.dependencies["@supermemory/tools"]' package.json) + TOOLS_VERSION=${TOOLS_SPEC#^} + TOOLS_VERSION=${TOOLS_VERSION#~} + + if [[ ! "$TOOLS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Unsupported @supermemory/tools dependency spec: $TOOLS_SPEC" >&2 + exit 1 + fi + + for attempt in {1..20}; do + PUBLISHED_VERSION=$(npm view "@supermemory/tools@$TOOLS_VERSION" version 2>/dev/null || true) + if [ "$PUBLISHED_VERSION" = "$TOOLS_VERSION" ]; then + echo "@supermemory/tools@$TOOLS_VERSION is available on npm." + exit 0 + fi + + echo "Waiting for @supermemory/tools@$TOOLS_VERSION (attempt $attempt/20)." + sleep 15 + done + + echo "@supermemory/tools@$TOOLS_VERSION was not published within five minutes." >&2 + exit 1 + + - name: Build Tools dependency + if: steps.version-check.outputs.changed == 'true' + run: bun run --cwd ../tools build + + - name: Build AI SDK package if: steps.version-check.outputs.changed == 'true' run: bun run build + - name: Verify packed artifact + if: steps.version-check.outputs.changed == 'true' + run: | + npm pack --dry-run --json > "$RUNNER_TEMP/ai-sdk-pack.json" + jq -e ' + (.[0].files | any(.path == "dist/index.js")) and + (.[0].files | any(.path == "dist/index.d.ts")) + ' "$RUNNER_TEMP/ai-sdk-pack.json" >/dev/null + - name: Publish if: steps.version-check.outputs.changed == 'true' run: npm publish --access public --provenance diff --git a/.github/workflows/publish-cartesia-sdk-python.yml b/.github/workflows/publish-cartesia-sdk-python.yml index aa65eb4f..1da4423a 100644 --- a/.github/workflows/publish-cartesia-sdk-python.yml +++ b/.github/workflows/publish-cartesia-sdk-python.yml @@ -23,20 +23,22 @@ jobs: working-directory: ./packages/cartesia-sdk-python steps: - name: Checkout - 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" - name: Install build dependencies - run: pip install hatchling build + run: python -m pip install hatchling build - name: Build package run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: packages/cartesia-sdk-python/dist/ diff --git a/.github/workflows/publish-pipecat-sdk-python.yml b/.github/workflows/publish-pipecat-sdk-python.yml index f98ab1c8..1182b98d 100644 --- a/.github/workflows/publish-pipecat-sdk-python.yml +++ b/.github/workflows/publish-pipecat-sdk-python.yml @@ -23,20 +23,22 @@ jobs: working-directory: ./packages/pipecat-sdk-python steps: - name: Checkout - 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" - name: Install build dependencies - run: pip install hatchling build + run: python -m pip install hatchling build - name: Build package run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: packages/pipecat-sdk-python/dist/ diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 444e3ac8..cf7a3002 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -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") diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 7bff4dfc..f31c2bb6 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -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") diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index 6ece78df..f819d3d6 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -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") diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 1722e71e..27b65b08 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -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") diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 66a11235..bddd83ed 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -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 +} | 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) } diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index f40426a8..0db9933d 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -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": { diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 9400ae9f..3891a43b 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -194,6 +194,11 @@ { "group": "Other resources", "pages": [ + { + "group": "General", + "icon": "book-open", + "pages": ["ingestion/batch-ingest-historical-data"] + }, { "group": "Benchmarking", "icon": "flask-conical", diff --git a/apps/docs/ingestion/add-memories.mdx b/apps/docs/ingestion/add-memories.mdx index 39d7c103..ab19a505 100644 --- a/apps/docs/ingestion/add-memories.mdx +++ b/apps/docs/ingestion/add-memories.mdx @@ -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 diff --git a/apps/docs/ingestion/batch-ingest-historical-data.mdx b/apps/docs/ingestion/batch-ingest-historical-data.mdx new file mode 100644 index 00000000..17ff4e1b --- /dev/null +++ b/apps/docs/ingestion/batch-ingest-historical-data.mdx @@ -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. + + + Sort the source data oldest to newest, add `documentDate` to every document. + + +## 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) + + + +```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") +``` + + + +## 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`. + + + +```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) +``` + + diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx index cb86ee33..d0b37991 100644 --- a/apps/docs/self-hosting/quickstart.mdx +++ b/apps/docs/self-hosting/quickstart.mdx @@ -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 +``` + + +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. + + +Release tags are `server-v` 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 diff --git a/apps/docs/using-supermemory.mdx b/apps/docs/using-supermemory.mdx index f65ed2e3..be38bf80 100644 --- a/apps/docs/using-supermemory.mdx +++ b/apps/docs/using-supermemory.mdx @@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi + diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index e3501890..8e8d043e 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -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() + }) }) diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index a0996933..425f0597 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -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() + +export function isApiKey(token: string): boolean { + return API_KEY_PATTERN.test(token) +} + +export async function validateApiKey( + token: string, + apiUrl: string, +): Promise { + 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, diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index cc45d438..a2ccc8e7 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -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 - -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 - -const memoryEntriesResponseSchema = z.object({ - memoryEntries: z.array(memoryEntrySchema), - pagination: paginationSchema, -}) - -export type MemoryEntriesResponse = z.infer +// 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.") diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index f435ea3a..0fac82c7 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -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 = { diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 0156d9d1..6cb0bf66 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -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 -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 diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index b8c4fe35..e7a34160 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -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 +// 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 + +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 + +export const memoriesListSchema = z.object({ + memoryEntries: z.array(memoryEntrySchema), + pagination: paginationSchema, +}) + +export type MemoriesList = z.infer + // 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. diff --git a/apps/memory-graph-playground/package.json b/apps/memory-graph-playground/package.json index 67e31a0b..debe23c7 100644 --- a/apps/memory-graph-playground/package.json +++ b/apps/memory-graph-playground/package.json @@ -7,6 +7,7 @@ "dev": "portless", "dev:app": "next dev --port ${PORT:-3004}", "build": "next build", + "check-types": "tsc --noEmit", "start": "next start" }, "dependencies": { diff --git a/apps/web/.env.example b/apps/web/.env.example index aaf5fab4..abd39cef 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai NEXT_PUBLIC_POSTHOG_KEY= EXA_API_KEY= -XAI_API_KEY= \ No newline at end of file +XAI_API_KEY= +NEXT_PUBLIC_AGENTID_AUTH_ENABLED= diff --git a/apps/web/app/(app)/configure/[section]/page.tsx b/apps/web/app/(app)/configure/[section]/page.tsx index 5acd2b3a..1d153db5 100644 --- a/apps/web/app/(app)/configure/[section]/page.tsx +++ b/apps/web/app/(app)/configure/[section]/page.tsx @@ -6,12 +6,22 @@ import { export default async function ConfigureSectionPage({ params, + searchParams, }: { params: Promise<{ section: string }> + searchParams: Promise> }) { 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 } diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 700e93a0..7d2d4d97 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -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 ( + {children} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 8500a236..442ded03 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -591,6 +591,79 @@ export default function LoginPage() { /> ) : null} + {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? ( +
+ + + AgentID + + + + + + + } + 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) + }) + }} + /> +
+ ) : null} 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, + }, + }) +} diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 0f96e52c..febd2760 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -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 = { "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 = { }, } +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 { + return btoa(JSON.stringify(value)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function PluginLogoStack({ clients }: { clients: string[] }) { + if (clients.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {clients.map((id, index) => { + const plugin = PLUGIN_INFO[id] + return ( +
+ {plugin ? ( + {plugin.name} + ) : ( + + )} +
+ ) + })} +
+ ) +} + +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("loading") const [error, setError] = useState(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 (
@@ -238,19 +341,7 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+

{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}.`)}

- {pluginInfo && ( -
    - {pluginInfo.features.map((feature) => ( +
      + {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map( + (feature) => (
    • - ))} -
    - )} + ), + )} +
- - - View all plans - -
-
-
- ) - } - if (status === "error") { return (
@@ -435,7 +430,7 @@ function AuthConnectContent() {
+ + + Add memory (C) + + + )} {canInvite && ( diff --git a/apps/web/components/company-brain-promo.tsx b/apps/web/components/company-brain-promo.tsx index 730eeb08..3bda29a8 100644 --- a/apps/web/components/company-brain-promo.tsx +++ b/apps/web/components/company-brain-promo.tsx @@ -45,42 +45,44 @@ export function CompanyBrainPromo() { return (
-
+
-
-

- Give your team a Company Brain -

-

- Lives in your Slack. Answers from your team's tools, and brings things - up before you ask. -

+
+
+

+ Give your team a Company Brain +

+

+ Lives in your Slack. Answers from your team's tools, and brings + things up before you ask. +

+
+
- diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 83f43c3e..eaa129ed 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -162,16 +162,19 @@ export function ConfigureView() {
-
-

- {active.label} -

-

- {active.description} -

+
+
+

+ {active.label} +

+

+ {active.description} +

+
+
+ {headerNotice ?
{headerNotice}
: null} diff --git a/apps/web/components/directory/connector-card.tsx b/apps/web/components/directory/connector-card.tsx new file mode 100644 index 00000000..dc735c17 --- /dev/null +++ b/apps/web/components/directory/connector-card.tsx @@ -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 ( +
+
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {topRight} +
+
+
{footerLeft}
+ {footerRight} +
+
+ ) +} + +export function ScopeChip({ + label, + connected, +}: { + label: string + connected: boolean +}) { + return ( + + + {label} + + ) +} diff --git a/apps/web/components/directory/section-rail.tsx b/apps/web/components/directory/section-rail.tsx new file mode 100644 index 00000000..080e5a24 --- /dev/null +++ b/apps/web/components/directory/section-rail.tsx @@ -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(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 ( +
+
+
+

{label}

+ {labelSlot} +
+
+ {headerSlot} + {hasOverflow ? ( + <> + + + + ) : null} +
+
+
+ {children} +
+
+ ) +} + +// 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)]" diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index c30f84c4..20f76f19 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) => export function Header(props: HeaderProps) { const hasCompanyBrain = useHasCompanyBrain() if (hasCompanyBrain) { - return + return ( + + ) } return } diff --git a/apps/web/components/highlights-card.tsx b/apps/web/components/highlights-card.tsx index 38257d2e..6caaadf7 100644 --- a/apps/web/components/highlights-card.tsx +++ b/apps/web/components/highlights-card.tsx @@ -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) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index a4a4ded6..5a37af45 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -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 @@ -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: , - 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: X, + 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: X, - 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: , + 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> = { + 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 ( +
+ {(["agent", "manual"] as const).map((tab) => ( + + ))} +
+ ) +} + +function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) { + const prompt = pluginAgentPrompt(plugin) + return ( +
+

+ {prompt} +

+ +
+ ) +} + +function PluginCommandsDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + return ( + + + + Supermemory plugin commands + +
+
+ {PLUGIN_COMMAND_CLIENTS.map((pluginId) => { + const plugin = PLUGIN_CATALOG[pluginId] + if (!plugin) return null + return ( + + + + ) + })} +
+
+

+ Plugin commands +

+

+ Install, reconnect, or remove integrations from one CLI. +

+
+ + + +
+
+
+ +
+
+
+

+ Run these commands from your terminal. +

+ + + +
+
+
+ ) +} + 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, )} > setInfoOpen(true)} /> @@ -2115,7 +2328,12 @@ function ItemCard({
{icon}
-
+
{leftIndicator} @@ -2139,7 +2357,13 @@ function ItemCard({ {tagline}

-
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
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(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 ( -
-
-

- {label} -

-
- {headerSlot} - - -
-
-
- {children} -
-
- ) -} - 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(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("agent") + const openPluginSetup = useCallback((pluginId: string) => { + setPluginSetupTab("agent") + setNewKey({ open: true, key: "", pluginId, loading: false }) + }, []) const [connectedPluginId, setConnectedPluginId] = useState( null, ) - const [finishSetupPluginId, setFinishSetupPluginId] = useState( - 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({ { 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({ { 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) => ( ) @@ -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({

) : q || category !== "all" ? (
- {visibleItems.map((item) => renderItemCard(item))} + {visibleItems.map((item) => + renderItemCard( + item, + item.id === "shortcuts" + ? "sm:w-max sm:min-w-full" + : undefined, + ), + )}
) : (
@@ -3767,6 +3920,23 @@ export function IntegrationsView({ 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]", + )} + > + Install plugins with one command + + + ) : null + } headerSlot={ cat === "ai-clients" && activeMcpKey ? (
+ + { @@ -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) + } }} >

- {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."}

@@ -3889,17 +4069,30 @@ export function IntegrationsView({
- {newKey.loading ? ( + + {pluginSetupTab === "agent" && dialogPlugin ? ( + + ) : newKey.loading ? (
Generating your key…
- ) : ( + ) : newKey.key ? ( + ) : ( +
+

+ We couldn't generate the key for the manual setup. +

+ Try again +
)}
@@ -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({ - { - if (!open) setFinishSetupPluginId(null) - }} - > - - - Finish setup {finishSetupPlugin?.name ?? "plugin"} - -
- {finishSetupPlugin && ( - - {finishSetupPlugin.name} - - )} -
-

- Finish setup {finishSetupPlugin?.name ?? "plugin"} -

-

- Complete install in the tool — this card turns active after the - first API call. -

-
- - - -
-
-
- {finishSetupSteps.length > 0 ? ( - - ) : ( -

- Open {finishSetupPlugin?.name ?? "the plugin"} and finish - authentication, then send a test memory. -

- )} -
-
-
- - - -
-
-
- { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index f924a8fb..cfcba481 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -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 ( -
- {TIER_FILTERS.map((filter) => ( - - ))} -
- ) -} - export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const queryClient = useQueryClient() - const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( 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() { )} >
-
- Plugins - {catalogRows.length > 0 && ( - - )} -
+ Plugins
{visibleRows.map((pluginId) => { const plugin = PLUGIN_CATALOG[pluginId] diff --git a/apps/web/components/integrations/shortcuts-detail.tsx b/apps/web/components/integrations/shortcuts-detail.tsx index 833488eb..3737fd36 100644 --- a/apps/web/components/integrations/shortcuts-detail.tsx +++ b/apps/web/components/integrations/shortcuts-detail.tsx @@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({ }) { const { connect, isPending, pendingType } = controller return ( -
+
{ diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index e9a0673a..61b0cddd 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -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("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({
{/* 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", )} > - {phase === "confirm" ? ( + {phase === "trial" ? ( + + setPhase("research")} /> + + ) : phase === "confirm" ? (

- Starts your 14-day free trial. No credit card needed. + Included in your 14-day trial.

) diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 358597e6..861235e2 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -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 = { "google-drive": "drive", @@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial> = { const PLAN_LABELS: Record = { 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("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( 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={} 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" ? ( }, + { key: "gmail", r: 74, deg: 128, node: }, + { key: "notion", r: 74, deg: 236, node: }, + { key: "drive", r: 112, deg: 58, node: }, + { key: "granola", r: 112, deg: 172, node: }, + { key: "mcp", r: 112, deg: 296, node: }, +] + +const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]" +const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]" + +function BrainPanel() { + return ( +
+
+ ) +} + +function TimelineRow({ + date, + title, + value, + current, +}: { + date: string + title: string + value?: string + current?: boolean +}) { + return ( +
  • +
  • + ) +} + +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 ( +
    +
    +
    +

    + Start your {TRIAL_DAYS}-day free trial +

    +

    + Add a payment method to start. You will not be charged today. We + will email you before your first payment. +

    +
    + +
      +
    + +
    + +

    + + Secured by Stripe · Cancel in one click +

    +
    +
    + + +
    + ) +} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index c9a06dd0..7b7398c6 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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 } diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 99e9bf47..41d74634 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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({ Organization + {org?.id ? ( + + ) : null} {isEditingOrgName ? (
    handleUpgrade("api_max")} + disabled={disabled} + className={cn( + dmSans125ClassName(), + PLAN_CARD_ACTION_CLASS, + "bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]", + )} + > + {disabled ? : null} + Activate Max + + ) + } + // 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() { } /> ))} +
    + {isOnTrial ? ( +

    + 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. +

    + ) : null} +

    + Using more than about $400 of credits a month? Scale works out + cheaper than Max plus top-ups. +

    +
    ) : ( <> diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx index 74f8e7b0..9a338d1d 100644 --- a/apps/web/components/settings/company-brain-automations.tsx +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -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 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 ) : null} + + {draft.deliverTo === "channel" && personalOnlyApps.length > 0 && ( + + + + {personalOnlyApps.map((app) => ( + + + {appCatalog[app]?.iconDomain ? ( + {appCatalog[app]?.name + ) : ( + + {app.slice(0, 1)} + + )} + + + {appCatalog[app]?.name ?? app} + + + ))} + + + + only connected to you ·{" "} + {isAdmin ? ( + <> + + Connect for workspace + {" "} + to use here + + ) : ( + "ask an admin to connect it for the workspace" + )} + + + )}
    {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(null) + useEffect(() => { + setActionSlot(document.getElementById("configure-section-actions")) + }, []) const [openId, setOpenId] = useState(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 + 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 = ( + + ) + const newAutomationPortal = actionSlot ? ( + createPortal(newAutomationButton, actionSlot) + ) : ( +
    {newAutomationButton}
    + ) + return (
    + {newAutomationPortal}
    {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 ? ( -

    - Templates -

    - ) : null} +

    + {showAllTemplates ? "Templates" : "Ideas for your setup"} +

    - {availablePresets.map((p) => ( + {shownPresets.map((p) => ( addDraft(presetToDraft(p))} /> ))} +
    + {hiddenTemplateCount > 0 || showAllTemplates ? ( -
    + ) : null}
    ) diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index 55a01228..754d5b78 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -1,10 +1,11 @@ "use client" +import { useRouter } from "next/navigation" import { useOrgMemberRole } from "@/hooks/use-org-member-role" import { cn } from "@lib/utils" import * as DialogPrimitive from "@radix-ui/react-dialog" -import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { ChevronDown, Loader2, Plus, Search, XIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogContent, @@ -20,14 +21,33 @@ import { import { toast } from "sonner" import { dmSans125ClassName } from "@/lib/fonts" import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import type { McpDirectoryEntry } from "@/lib/mcp-directory" import { brainConnectorIcon, SlackMark } from "../brain-connector-icons" +import { ConnectorCard, ScopeChip } from "../directory/connector-card" +import { + railItemClass, + SectionRail, + sectionLabelClass, +} from "../directory/section-rail" import { PillButton } from "../integrations/install-steps" +import { + categoryLabel, + DirectoryEntryCard, + entrySlug, + isEntrySetUppable, + listableDirectoryEntries, + McpDirectoryGrid, + normalizeServerUrl, + useMcpDirectory, +} from "./mcp-directory-browser" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const MCP_BASE = `${BACKEND}/brain/mcp-connections` +const RECOMMENDED_DIRECTORY_COUNT = 9 + type AuthType = "oauth" | "static" | "none" type CatalogEntry = { slug: string @@ -58,6 +78,25 @@ function slugifyMcpName(value: string) { .slice(0, 63) } +function customConnectionName(slug: string) { + return titleCase(slug.replace(/-sm-dir-[a-z0-9]{6}$/, "").replace(/-/g, " ")) +} + +function directorySlugOf(entry: McpDirectoryEntry) { + return `${slugifyMcpName(entry.name).slice(0, 49)}-sm-dir-${stableDirectorySuffix( + entry.url ?? entry.note ?? entry.id, + )}` +} + +function stableDirectorySuffix(value: string) { + let hash = 0x811c9dc5 + for (const character of value) { + hash ^= character.codePointAt(0) ?? 0 + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(36).slice(0, 6).padStart(6, "0") +} + const pillLinkClass = cn( "relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5", "text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]", @@ -65,35 +104,18 @@ const pillLinkClass = cn( "cursor-pointer transition-opacity hover:opacity-80", ) -function ScopeChip({ - label, - connected, -}: { - label: string - connected: boolean -}) { - return ( - - - {label} - - ) -} - const menuItemClass = "gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" +const menuContentClass = cn( + dmSans125ClassName(), + "min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]", +) + +const menuContentStyle = { + background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", +} as const + const customInputClass = "h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" @@ -125,47 +147,27 @@ function AppCard({ const adminMenu = isAdmin && !personalOnly return ( -
    -
    -
    - {icon} -
    -
    -

    - {name} -

    -

    - {subtitle} -

    -
    -
    -
    -
    - {personalOnly || !anyConnected ? ( - - ) : ( - <> - - {showOrgChip ? ( - - ) : null} - - )} -
    - {adminMenu ? ( + + ) : ( + <> + + {showOrgChip ? ( + + ) : null} + + ) + } + footerRight={ + adminMenu ? (
    -
    + ) + } + /> ) } @@ -256,30 +250,12 @@ function SlackCard({ return () => clearTimeout(timer) }, [confirming]) return ( -
    -
    -
    - -
    -
    -

    - Slack -

    -

    - Messaging -

    -
    - {connected && status?.teamName ? ( + } + topRight={ + connected && status?.teamName ? ( {status.teamName} - ) : null} -
    -
    + ) : undefined + } + footerLeft={ - {isAdmin ? ( + } + footerRight={ + isAdmin ? (
    {connected ? (
    - ) : null} -
    -
    + ) : undefined + } + /> + ) +} + +// Compact icon for an installed integration — the user already knows what it +// is, so the full card lives only in Recommended/search. Clicking opens the +// same manage menu the cards use. +function InstalledTile({ + name, + icon, + children, +}: { + name: string + icon: React.ReactNode + children: React.ReactNode +}) { + return ( + + + + + +

    + {name} +

    + {children} +
    +
    ) } @@ -360,6 +379,8 @@ export default function CompanyBrainConnections() { const [rows, setRows] = useState([]) const [slackStatus, setSlackStatus] = useState(null) const [busy, setBusy] = useState(null) + const [query, setQuery] = useState("") + const [marketplaceCategory, setMarketplaceCategory] = useState("all") const [customOpen, setCustomOpen] = useState(false) const [customName, setCustomName] = useState("") const [customServerUrl, setCustomServerUrl] = useState("") @@ -369,8 +390,20 @@ export default function CompanyBrainConnections() { { name: string; value: string }[] >([]) const [customAdvancedOpen, setCustomAdvancedOpen] = useState(false) + const [customAuthMethod, setCustomAuthMethod] = useState<"oauth" | "api-key">( + "oauth", + ) + const [directoryEntry, setDirectoryEntry] = + useState(null) + const deepLinkHandled = useRef(false) + const router = useRouter() const { isAdmin } = useOrgMemberRole(isCompanyBrain) + const directory = useMcpDirectory() + const directoryEntries = useMemo( + () => listableDirectoryEntries(directory.entries), + [directory.entries], + ) const load = useCallback(async () => { const [catRes, connRes, slackRes] = await Promise.all([ @@ -481,22 +514,55 @@ export default function CompanyBrainConnections() { const resetCustomForm = () => { setCustomOpen(false) + setDirectoryEntry(null) setCustomName("") setCustomServerUrl("") setCustomToken("") setCustomHeaderName("") setCustomExtraHeaders([]) setCustomAdvancedOpen(false) + setCustomAuthMethod("oauth") } + const setUpDirectoryEntry = useCallback((entry: McpDirectoryEntry) => { + setDirectoryEntry(entry) + setCustomName(entry.name) + setCustomServerUrl(entry.url ?? "") + setCustomAdvancedOpen(false) + setCustomAuthMethod(entry.authMethods[0] ?? "oauth") + setCustomOpen(true) + }, []) + + // Deep link from Company Brain for apps it cannot authorize on their behalf. + useEffect(() => { + if (deepLinkHandled.current) return + const slug = new URLSearchParams(window.location.search).get("mcpSetup") + if (!slug) return + if (!directory.entries.length) return + deepLinkHandled.current = true + const entry = directory.entries.find((e) => directorySlugOf(e) === slug) + if (entry) setUpDirectoryEntry(entry) + else toast.error("That app is no longer in the MCP directory.") + // Router, not history: a replaceState here races the router and gets reverted. + router.replace(window.location.pathname, { scroll: false }) + }, [directory.entries, setUpDirectoryEntry, router]) + const connectCustom = async (event: React.FormEvent) => { event.preventDefault() - const slug = slugifyMcpName(customName) + const slug = directoryEntry + ? directorySlugOf(directoryEntry) + : slugifyMcpName(customName) const serverUrl = customServerUrl.trim() if (!slug) { toast.error("Enter a custom MCP name.") return } + if (!directoryEntry && /-sm-dir-[a-z0-9]{6}$/.test(slug)) { + toast.error( + "Choose a name that doesn't use the reserved directory suffix.", + ) + return + } if (!serverUrl) { toast.error("Enter an MCP URL.") return @@ -509,7 +575,11 @@ export default function CompanyBrainConnections() { const key = `custom:${slug}` setBusy(key) try { - const token = customToken.trim() + const token = customAuthMethod === "api-key" ? customToken.trim() : "" + if (customAuthMethod === "api-key" && !token) { + toast.error("Enter an API key.") + return + } if (token) { const rows = customExtraHeaders .map((h) => [h.name.trim(), h.value.trim()] as const) @@ -543,7 +613,7 @@ export default function CompanyBrainConnections() { toast.error(data.error ?? "Couldn't connect.") return } - toast.success(`${slug} connected.`) + toast.success(`${customName} connected.`) resetCustomForm() await load() return @@ -571,7 +641,7 @@ export default function CompanyBrainConnections() { window.open(data.authUrl, "_blank", "noopener") resetCustomForm() } else if (data.ok) { - toast.success(`${slug} connected.`) + toast.success(`${customName} connected.`) resetCustomForm() await load() } else { @@ -615,6 +685,112 @@ export default function CompanyBrainConnections() { } } + const loading = catalog === null + const apps = catalog ?? [] + const catalogSlugs = useMemo( + () => new Set(apps.map((entry) => entry.slug)), + [apps], + ) + const canClassifyCustomRows = catalogLoaded && apps.length > 0 + const customRows = canClassifyCustomRows + ? rows.filter( + (row) => + row.userId !== null && + row.status === "active" && + typeof row.serverUrl === "string" && + row.serverUrl.length > 0 && + !catalogSlugs.has(row.serverSlug), + ) + : [] + + const connectedUrls = useMemo( + () => + new Set( + rows + .filter( + (row) => + row.status === "active" && + typeof row.serverUrl === "string" && + row.serverUrl.length > 0, + ) + .map((row) => normalizeServerUrl(row.serverUrl ?? "")), + ), + [rows], + ) + + const isEntryConnected = useCallback( + (entry: McpDirectoryEntry) => { + const slug = entrySlug(entry) + if (catalogSlugs.has(slug)) { + return rows.some( + (row) => row.status === "active" && row.serverSlug === slug, + ) + } + return entry.url + ? connectedUrls.has(normalizeServerUrl(entry.url)) + : false + }, + [catalogSlugs, connectedUrls, rows], + ) + + const slackConnected = slackStatus?.connected ?? false + const isAppConnected = (slug: string) => + isConnected(slug, false) || isConnected(slug, true) + const installedApps = apps.filter((entry) => isAppConnected(entry.slug)) + const recommendedApps = apps.filter((entry) => !isAppConnected(entry.slug)) + const hasInstalled = + slackConnected || installedApps.length > 0 || customRows.length > 0 + + // Popular, connectable directory servers we don't already show as apps. + const recommendedDirectoryEntries = useMemo( + () => + directoryEntries + .filter( + (entry) => + isEntrySetUppable(entry) && + !catalogSlugs.has(entrySlug(entry)) && + !isEntryConnected(entry), + ) + .sort((a, b) => b.popularity - a.popularity) + .slice(0, RECOMMENDED_DIRECTORY_COUNT), + [catalogSlugs, directoryEntries, isEntryConnected], + ) + + // Top categories become the marketplace filter tags. + const marketplaceCategories = useMemo(() => { + const counts = new Map() + for (const entry of directoryEntries) { + for (const category of entry.categories) { + counts.set(category, (counts.get(category) ?? 0) + 1) + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([category]) => category) + }, [directoryEntries]) + + const marketplaceEntries = useMemo( + () => + marketplaceCategory === "all" + ? directoryEntries + : directoryEntries.filter((entry) => + entry.categories.includes(marketplaceCategory), + ), + [directoryEntries, marketplaceCategory], + ) + + const needle = query.trim().toLowerCase() + const searching = needle.length > 0 + const catalogMatches = searching + ? apps.filter( + (entry) => + entry.name.toLowerCase().includes(needle) || + entry.category.toLowerCase().includes(needle), + ) + : [] + const slackMatches = searching && "slack messaging".includes(needle) + if (!isCompanyBrain) { return (

    entry.slug)) - const canClassifyCustomRows = catalogLoaded && apps.length > 0 - const customRows = canClassifyCustomRows - ? rows.filter( - (row) => - row.userId !== null && - row.status === "active" && - typeof row.serverUrl === "string" && - row.serverUrl.length > 0 && - !catalogSlugs.has(row.serverSlug), - ) - : [] + const slackInstallHref = `${BACKEND}/brain/slack/oauth/install` + + const disconnectSlack = async () => { + try { + const res = await fetch(`${BACKEND}/brain/slack/workspace`, { + method: "DELETE", + credentials: "include", + }) + if (res.status === 403) { + toast.error("Only admins can disconnect Slack.") + return + } + if (!res.ok) { + toast.error("Couldn't disconnect Slack.") + return + } + } catch { + toast.error("Couldn't disconnect Slack.") + return + } + toast.success("Slack disconnected.") + await load().catch(() => undefined) + } + + const slackCard = ( + + ) + + const appCard = (entry: CatalogEntry) => ( + connect(entry, shared)} + onDisconnect={(shared) => disconnect(entry, shared)} + /> + ) + return (

    -
    - {loading ? ( - <> - - - - - ) : ( - <> - { - try { - const res = await fetch(`${BACKEND}/brain/slack/workspace`, { - method: "DELETE", - credentials: "include", - }) - if (res.status === 403) { - toast.error("Only admins can disconnect Slack.") - return - } - if (!res.ok) { - toast.error("Couldn't disconnect Slack.") - return - } - } catch { - toast.error("Couldn't disconnect Slack.") - return - } - toast.success("Slack disconnected.") - await load().catch(() => undefined) - }} - /> - {apps.map((entry) => ( - connect(entry, shared)} - onDisconnect={(shared) => disconnect(entry, shared)} - /> - ))} - {customRows.map((row) => ( - {}} - onDisconnect={() => - disconnect( - { - slug: row.serverSlug, - name: titleCase(row.serverSlug.replace(/-/g, " ")), - category: "Custom OAuth MCP", - authType: "oauth", - }, - false, - ) - } - /> - ))} - - - )} +
    + +
    + {loading ? ( +
    + + + +
    + ) : searching ? ( +
    + {slackMatches || catalogMatches.length > 0 ? ( +
    + {slackMatches ? slackCard : null} + {catalogMatches.map((entry) => ( +
    {appCard(entry)}
    + ))} +
    + ) : null} + 0} + /> +
    + ) : ( +
    + {hasInstalled ? ( +
    +

    Installed

    +
    + {slackConnected ? ( + } + > + {isAdmin ? ( + <> + + Reconnect + + { + if (window.confirm("Disconnect Slack?")) { + void disconnectSlack() + } + }} + > + Disconnect + + + ) : ( + + Managed by workspace admins + + )} + + ) : null} + {installedApps.map((entry) => { + const userConnected = isConnected(entry.slug, false) + const orgConnected = isConnected(entry.slug, true) + return ( + + {isAdmin ? ( + <> + + userConnected + ? disconnect(entry, false) + : connect(entry, false) + } + > + {userConnected + ? "Disconnect my account" + : "Connect my account"} + + + orgConnected + ? disconnect(entry, true) + : connect(entry, true) + } + > + {orgConnected + ? "Disconnect workspace" + : "Connect for workspace"} + + + ) : userConnected ? ( + disconnect(entry, false)} + > + Disconnect + + ) : ( + + Managed by workspace admins + + )} + + ) + })} + {customRows.map((row) => ( + + + disconnect( + { + slug: row.serverSlug, + name: customConnectionName(row.serverSlug), + category: "Custom MCP", + authType: "oauth", + }, + false, + ) + } + > + Disconnect + + + ))} +
    +
    + ) : null} + + {!slackConnected ? ( +
    {slackCard}
    + ) : null} + {recommendedApps.map((entry) => ( +
    + {appCard(entry)} +
    + ))} + {recommendedDirectoryEntries.map((entry) => ( +
    + +
    + ))} +
    +
    +
    +

    Marketplace

    + {marketplaceEntries.length > 0 ? ( + + {marketplaceEntries.length.toLocaleString()} servers + + ) : null} +
    + {marketplaceCategories.length > 0 ? ( +
    + {["all", ...marketplaceCategories].map((category) => ( + + ))} +
    + ) : null} + +
    +
    + )} + {/* Reset on every close path so the API key never lingers in state. */} + onOpenChange={(open: boolean) => open ? setCustomOpen(true) : resetCustomForm() } > @@ -755,11 +1099,14 @@ export default function CompanyBrainConnections() {
    - Add custom connector + {directoryEntry + ? `Set up ${directoryEntry.name}` + : "Add custom connector"}

    - Connect your Brain to any remote MCP server. Signs in with OAuth - unless you add an API key below. + {directoryEntry?.availability === "tenant" + ? "Enter your workspace-specific MCP URL, then choose how this server authenticates." + : "Confirm the remote MCP URL, then choose how this server authenticates."}

    - + {(["oauth", "api-key"] as const) + .filter( + (method) => + !directoryEntry || + directoryEntry.authMethods.includes(method), + ) + .map((method) => ( + + ))} +
    - {customAdvancedOpen && ( -
    - setCustomToken(event.target.value)} - type="password" - placeholder="API key (optional)" - className={customInputClass} + {customAuthMethod === "api-key" && ( + setCustomToken(event.target.value)} + type="password" + placeholder="API key" + required + className={customInputClass} + /> + )} + + {customAuthMethod === "api-key" && ( + + )} + + {customAuthMethod === "api-key" && customAdvancedOpen && ( +
    setCustomHeaderName(event.target.value)} diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index ccd098d6..96cd382d 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -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 @@ -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 diff --git a/apps/web/components/settings/mcp-directory-browser.tsx b/apps/web/components/settings/mcp-directory-browser.tsx new file mode 100644 index 00000000..4fe323ee --- /dev/null +++ b/apps/web/components/settings/mcp-directory-browser.tsx @@ -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 + 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( + () => 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 ( + 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 ( + } + name={entry.name} + subtitle={entrySubtitle(entry)} + footerLeft={} + footerRight={ + canSetUp ? ( + onSetUp(entry)}>Set up + ) : 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 ( +
    +
    + +
    +
    +

    + {entry.name} +

    +

    + {entrySubtitle(entry)} +

    +
    + {connected ? ( + + + Connected + + ) : canSetUp ? ( + + ) : ( + + {entry.availability === "local" ? "Desktop only" : "Coming soon"} + + )} +
    + ) +} + +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 + 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 ( +
    + The MCP directory couldn't be loaded. Refresh to try again. +
    + ) + } + if (entries.length === 0) { + if (suppressEmpty) return null + return ( +
    + + Loading MCP directory +
    + ) + } + if (matches.length === 0) { + if (suppressEmpty) return null + return ( +
    + No integrations match “{query.trim()}”. +
    + ) + } + return ( +
    +
    + {matches.slice(0, visibleCount).map((entry) => ( + + ))} +
    + {visibleCount < matches.length ? ( + + ) : null} +
    + ) +} diff --git a/apps/web/components/slack-connect-card.tsx b/apps/web/components/slack-connect-card.tsx index 2fc6de2c..03cb8e29 100644 --- a/apps/web/components/slack-connect-card.tsx +++ b/apps/web/components/slack-connect-card.tsx @@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) { export function SlackConnectCard() { const isCompanyBrain = useHasCompanyBrain() const [status, setStatus] = useState(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() { Connected - ) : ( + ) : trialActive ? ( Add to Slack + ) : ( + + Finish setting up + )}
    ) diff --git a/apps/web/components/trial-setup-banner.tsx b/apps/web/components/trial-setup-banner.tsx new file mode 100644 index 00000000..b96e5c38 --- /dev/null +++ b/apps/web/components/trial-setup-banner.tsx @@ -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 ( +
    +
    + + + +
    +

    + {endedTrial + ? "Your Company Brain trial has ended" + : "Finish setting up Company Brain"} +

    +

    + {endedTrial + ? "Move to Max or Scale to switch the brain back on." + : "Add a card to start your 14-day trial. $0 today."} +

    +
    +
    + + {endedTrial ? "Upgrade" : "Add card"} + + +
    + ) +} diff --git a/apps/web/hooks/use-company-brain.ts b/apps/web/hooks/use-company-brain.ts index fb80f7be..fa2c9a2f 100644 --- a/apps/web/hooks/use-company-brain.ts +++ b/apps/web/hooks/use-company-brain.ts @@ -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 | 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) } diff --git a/apps/web/hooks/use-connector-access.ts b/apps/web/hooks/use-connector-access.ts index 9f2599c3..d597ee1a 100644 --- a/apps/web/hooks/use-connector-access.ts +++ b/apps/web/hooks/use-connector-access.ts @@ -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, diff --git a/apps/web/hooks/use-promo-code.ts b/apps/web/hooks/use-promo-code.ts new file mode 100644 index 00000000..1bc8ca1c --- /dev/null +++ b/apps/web/hooks/use-promo-code.ts @@ -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 +} diff --git a/apps/web/hooks/use-trial-status.ts b/apps/web/hooks/use-trial-status.ts new file mode 100644 index 00000000..78941199 --- /dev/null +++ b/apps/web/hooks/use-trial-status.ts @@ -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 => { + 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, + } +} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 5ffda827..da1264f1 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -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"), } diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index 3360e9c4..cf278998 100644 --- a/apps/web/lib/billing-utils.ts +++ b/apps/web/lib/billing-utils.ts @@ -112,6 +112,15 @@ export function getBrainMode( : null } +export function isCompanyBrainOrg( + metadataRaw: Record | 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() } diff --git a/apps/web/lib/chat-stream-error.ts b/apps/web/lib/chat-stream-error.ts index 2e17135d..f31dc53b 100644 --- a/apps/web/lib/chat-stream-error.ts +++ b/apps/web/lib/chat-stream-error.ts @@ -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 { diff --git a/apps/web/lib/company-brain-entry.ts b/apps/web/lib/company-brain-entry.ts index 94ff5b33..5e0d516e 100644 --- a/apps/web/lib/company-brain-entry.ts +++ b/apps/web/lib/company-brain-entry.ts @@ -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 { diff --git a/apps/web/lib/mcp-directory.ts b/apps/web/lib/mcp-directory.ts new file mode 100644 index 00000000..e1e3c3f5 --- /dev/null +++ b/apps/web/lib/mcp-directory.ts @@ -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"> +} diff --git a/apps/web/lib/mcp-icon-domains.json b/apps/web/lib/mcp-icon-domains.json new file mode 100644 index 00000000..2989996d --- /dev/null +++ b/apps/web/lib/mcp-icon-domains.json @@ -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" + ] +} diff --git a/apps/web/lib/models.tsx b/apps/web/lib/models.tsx index 5d3479ef..fe0e7584 100644 --- a/apps/web/lib/models.tsx +++ b/apps/web/lib/models.tsx @@ -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 = { - "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<{ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 8febfc81..89587227 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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() } diff --git a/apps/web/package.json b/apps/web/package.json index cddbcd0a..63c4acee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/bun.lock b/bun.lock index dc8a4324..c50078de 100644 --- a/bun.lock +++ b/bun.lock @@ -258,7 +258,7 @@ }, "packages/ai-sdk": { "name": "@supermemory/ai-sdk", - "version": "1.0.9", + "version": "2.0.0", "dependencies": { "@ai-sdk/openai": "^2.0.22", "@ai-sdk/provider": "^2.0.0", @@ -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", diff --git a/packages/agent-framework-python/README.md b/packages/agent-framework-python/README.md index 57b5d25b..3908692d 100644 --- a/packages/agent-framework-python/README.md +++ b/packages/agent-framework-python/README.md @@ -9,23 +9,13 @@ This package provides both **automatic memory injection middleware** and **manua Install using uv (recommended): ```bash -uv add --prerelease=allow supermemory-agent-framework +uv add supermemory-agent-framework ``` Or with pip: ```bash -pip install --pre supermemory-agent-framework -``` - -> **Note:** The `--prerelease=allow` / `--pre` flag is required because `agent-framework-core` depends on pre-release versions of Azure packages. - -For async HTTP support (recommended): - -```bash -uv add supermemory-agent-framework[async] -# or -pip install 'supermemory-agent-framework[async]' +pip install supermemory-agent-framework ``` ## Quick Start @@ -38,14 +28,19 @@ The easiest way to add memory capabilities is using the `SupermemoryChatMiddlewa import asyncio from agent_framework.openai import OpenAIResponsesClient from supermemory_agent_framework import ( + AgentSupermemory, SupermemoryChatMiddleware, SupermemoryMiddlewareOptions, ) async def main(): - # Create Supermemory middleware - middleware = SupermemoryChatMiddleware( + connection = AgentSupermemory( + api_key="your-supermemory-api-key", container_tag="user-123", + ) + + middleware = SupermemoryChatMiddleware( + connection, options=SupermemoryMiddlewareOptions( mode="full", # "profile", "query", or "full" verbose=True, # Enable logging @@ -77,13 +72,16 @@ The most idiomatic way to add memory in Agent Framework, using the same pattern import asyncio from agent_framework import AgentSession from agent_framework.openai import OpenAIResponsesClient -from supermemory_agent_framework import SupermemoryContextProvider +from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider async def main(): - # Create context provider - provider = SupermemoryContextProvider( - container_tag="user-123", + connection = AgentSupermemory( api_key="your-supermemory-api-key", + container_tag="user-123", + ) + + provider = SupermemoryContextProvider( + connection, mode="full", store_conversations=True, ) @@ -113,14 +111,14 @@ For explicit tool-based memory access: ```python import asyncio from agent_framework.openai import OpenAIResponsesClient -from supermemory_agent_framework import SupermemoryTools +from supermemory_agent_framework import AgentSupermemory, SupermemoryTools async def main(): - # Create memory tools - tools = SupermemoryTools( + connection = AgentSupermemory( api_key="your-supermemory-api-key", - config={"project_id": "my-project"}, + container_tag="user-123", ) + tools = SupermemoryTools(connection) # Create agent agent = OpenAIResponsesClient().as_agent( @@ -146,6 +144,7 @@ For maximum flexibility, use both middleware (automatic context injection) and t import asyncio from agent_framework.openai import OpenAIResponsesClient from supermemory_agent_framework import ( + AgentSupermemory, SupermemoryChatMiddleware, SupermemoryMiddlewareOptions, SupermemoryTools, @@ -153,14 +152,17 @@ from supermemory_agent_framework import ( async def main(): api_key = "your-supermemory-api-key" - - middleware = SupermemoryChatMiddleware( - container_tag="user-123", - options=SupermemoryMiddlewareOptions(mode="full"), + connection = AgentSupermemory( api_key=api_key, + container_tag="user-123", ) - tools = SupermemoryTools(api_key=api_key) + middleware = SupermemoryChatMiddleware( + connection, + options=SupermemoryMiddlewareOptions(mode="full"), + ) + + tools = SupermemoryTools(connection) agent = OpenAIResponsesClient().as_agent( name="MemoryAgent", @@ -217,11 +219,20 @@ SupermemoryMiddlewareOptions(add_memory="never") ### Complete Configuration ```python -SupermemoryMiddlewareOptions( - conversation_id="chat-session-456", # Group messages into conversations - verbose=True, # Enable detailed logging - mode="full", # Use both profile and query - add_memory="always" # Auto-save conversations +connection = AgentSupermemory( + api_key="your-supermemory-api-key", + container_tag="user-123", # Memory scope + conversation_id="chat-session-456", # Groups stored conversations + entity_context="User is on the pro plan", # Optional fixed context +) + +middleware = SupermemoryChatMiddleware( + connection, + options=SupermemoryMiddlewareOptions( + verbose=True, + mode="full", + add_memory="always", + ), ) ``` @@ -232,13 +243,11 @@ SupermemoryMiddlewareOptions( Memory tools that integrate with Agent Framework's tool system. ```python -tools = SupermemoryTools( +connection = AgentSupermemory( api_key="your-api-key", - config={ - "project_id": "my-project", # or use container_tags - "base_url": "https://custom.com", # optional - } + container_tag="user-123", ) +tools = SupermemoryTools(connection) # Get FunctionTool instances for Agent.run() agent_tools = tools.get_tools() @@ -249,26 +258,19 @@ result = await tools.add_memory("User prefers dark mode") result = await tools.get_profile() ``` +`search_memories` uses v4 hybrid search, so results can contain either a +structured memory or a source chunk. The old Python-only `include_full_docs` +argument is deprecated and ignored because v4 search does not return full +source documents; it is not exposed to the model as a tool parameter. + ### SupermemoryChatMiddleware Chat middleware for automatic memory injection. ```python middleware = SupermemoryChatMiddleware( - container_tag="user-123", # Memory scope identifier + connection, # Shared AgentSupermemory connection options=SupermemoryMiddlewareOptions(...), - api_key="your-api-key", # Or set SUPERMEMORY_API_KEY env var -) -``` - -### with_supermemory_middleware() - -Convenience function for creating middleware: - -```python -middleware = with_supermemory_middleware( - "user-123", - SupermemoryMiddlewareOptions(mode="full"), ) ``` @@ -278,11 +280,9 @@ Context provider for the Agent Framework session pipeline (like Mem0): ```python provider = SupermemoryContextProvider( - container_tag="user-123", - api_key="your-api-key", # Or set SUPERMEMORY_API_KEY env var + connection, # Shared AgentSupermemory connection mode="full", # "profile", "query", or "full" store_conversations=True, # Save conversations after each run - conversation_id="chat-456", # Optional grouping ID context_prompt="## Memories\n...", # Custom header for injected memories verbose=True, # Enable logging ) @@ -292,6 +292,7 @@ provider = SupermemoryContextProvider( ```python from supermemory_agent_framework import ( + AgentSupermemory, SupermemoryConfigurationError, SupermemoryAPIError, SupermemoryNetworkError, @@ -299,7 +300,7 @@ from supermemory_agent_framework import ( ) try: - middleware = SupermemoryChatMiddleware("user-123") + connection = AgentSupermemory(container_tag="user-123") except SupermemoryConfigurationError as e: print(f"Configuration issue: {e}") ``` @@ -322,11 +323,8 @@ except SupermemoryConfigurationError as e: ### Required - `agent-framework-core>=1.0.0rc3` - Microsoft Agent Framework -- `supermemory>=3.1.0` - Supermemory client -- `requests>=2.25.0` - HTTP requests (fallback) - -### Optional -- `aiohttp>=3.8.0` - Async HTTP requests (recommended) +- `supermemory>=3.16.0` - Supermemory client with v4 hybrid search support +- `typing-extensions>=4.0.0` - Typing compatibility helpers ## Development diff --git a/packages/agent-framework-python/pyproject.toml b/packages/agent-framework-python/pyproject.toml index deedba27..d308aa57 100644 --- a/packages/agent-framework-python/pyproject.toml +++ b/packages/agent-framework-python/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "agent-framework-core>=1.0.0rc3", - "supermemory>=3.1.0", + "supermemory>=3.16.0", "typing-extensions>=4.0.0", ] diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py b/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py index 5359426e..a1d7b161 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py @@ -7,10 +7,10 @@ This is the idiomatic way to integrate persistent memory in Agent Framework, following the same pattern as the built-in Mem0 integration. """ -from typing import Any, Literal, Optional +from typing import Any, Literal try: - from agent_framework import BaseContextProvider + from agent_framework import BaseContextProvider # type: ignore[attr-defined] except ImportError: # Renamed in agent-framework-core 1.0.0 stable; the interface is # unchanged (source_id __init__, before_run/after_run hooks with diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py index 780637a3..2b70bdd7 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py @@ -4,7 +4,8 @@ Provides FunctionTool-compatible tools that can be passed to Agent.run(tools=[.. """ import json -from typing import Annotated, Any, TypedDict +import warnings +from typing import Annotated, Any, Optional, TypedDict from agent_framework import FunctionTool, tool @@ -37,6 +38,25 @@ class ProfileResult(TypedDict, total=False): error: str | None +def _to_jsonable(value: Any) -> Any: + """Convert generated SDK models into JSON-compatible structures.""" + if isinstance(value, dict): + return {key: _to_jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_to_jsonable(item) for item in value] + + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + try: + return _to_jsonable(model_dump(mode="json")) + except TypeError: + # Compatibility with pydantic-like models whose model_dump does not + # accept Pydantic v2's ``mode`` argument. + return _to_jsonable(model_dump()) + + return value + + class SupermemoryTools: """Memory tools for Microsoft Agent Framework. @@ -64,19 +84,28 @@ class SupermemoryTools: async def search_memories( self, information_to_get: Annotated[ - str, "Terms to search for in the user's memories" + str, "Terms to search for in stored memories and source content" ], - include_full_docs: Annotated[ - bool, - "Whether to include full document content. Defaults to true for better AI context.", - ] = True, + include_full_docs: Optional[bool] = None, limit: Annotated[int, "Maximum number of results to return"] = 10, ) -> str: - """Search stored memories for facts, preferences, history, and context. Use proactively before answering whenever memory could help — not only when explicitly asked.""" + """Search stored memories and source chunks. + + ``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 = await self._client.search.memories( q=information_to_get, - container_tags=[self._connection.container_tag], + container_tag=self._connection.container_tag, limit=limit, threshold=0.6, search_mode="hybrid", @@ -84,7 +113,7 @@ class SupermemoryTools: results = response.results or [] result: MemorySearchResult = { "success": True, - "results": results, + "results": [_to_jsonable(item) for item in results], "count": len(results), } return json.dumps(result, default=str) @@ -108,7 +137,7 @@ class SupermemoryTools: ) result: MemoryAddResult = { "success": True, - "memory": response, + "memory": _to_jsonable(response), } return json.dumps(result, default=str) except Exception as error: @@ -131,9 +160,13 @@ class SupermemoryTools: response = await self._client.profile(**kwargs) result: dict[str, Any] = { "success": True, - "profile": response.profile if hasattr(response, "profile") else None, + "profile": ( + _to_jsonable(response.profile) + if hasattr(response, "profile") + else None + ), "search_results": ( - response.search_results + _to_jsonable(response.search_results) if hasattr(response, "search_results") else None ), @@ -153,11 +186,11 @@ class SupermemoryTools: tool( name="search_memories", description=( - "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 stored memories and source chunks for relevant facts, preferences, " + "history, and context. Use proactively whenever prior context could help; " + "hybrid results can contain either a memory or a source chunk." ), - )(self.search_memories), + )(self._search_memories_tool), tool( name="add_memory", description=( @@ -175,3 +208,16 @@ class SupermemoryTools: ), )(self.get_profile), ] + + async def _search_memories_tool( + self, + information_to_get: Annotated[ + str, "Terms to search for in stored memories and source content" + ], + limit: Annotated[int, "Maximum number of results to return"] = 10, + ) -> str: + """Model-facing search wrapper that omits deprecated arguments.""" + return await self.search_memories( + information_to_get=information_to_get, + limit=limit, + ) diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py index dac80c3c..1e4ee56a 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py @@ -1,6 +1,7 @@ """Utility functions for Supermemory Agent Framework integration.""" import json +import re from typing import Any, Optional, Protocol DEFAULT_CONTEXT_PROMPT = "The following are retrieved memories about the user." @@ -96,18 +97,27 @@ def deduplicate_memories( trimmed = item.strip() return trimmed if trimmed else None if isinstance(item, dict): - memory = item.get("memory") - if isinstance(memory, str): - trimmed = memory.strip() - return trimmed if trimmed else None + for field in ("memory", "chunk", "content"): + value = item.get(field) + if isinstance(value, str) and value.strip(): + return value.strip() return None # Stainless SDK returns pydantic models (attribute access, snake_case). - memory = getattr(item, "memory", None) - if isinstance(memory, str): - trimmed = memory.strip() - return trimmed if trimmed else None + for field in ("memory", "chunk", "content"): + value = getattr(item, field, None) + if isinstance(value, str) and value.strip(): + return value.strip() return None + def comparison_key(memory: str) -> str: + """Remove Mono's dynamic-profile date decoration for comparison only.""" + return re.sub( + r"^(?:\[Recent\]\s*)?\[\d{4}-\d{2}-\d{2}\]\s*", + "", + memory, + count=1, + ).strip() + static_memories: list[str] = [] seen_memories: set[str] = set() @@ -115,21 +125,21 @@ def deduplicate_memories( memory = extract_memory_text(item) if memory is not None: static_memories.append(memory) - seen_memories.add(memory) + seen_memories.add(comparison_key(memory)) dynamic_memories: list[str] = [] for item in dynamic_items: memory = extract_memory_text(item) - if memory is not None and memory not in seen_memories: + if memory is not None and comparison_key(memory) not in seen_memories: dynamic_memories.append(memory) - seen_memories.add(memory) + seen_memories.add(comparison_key(memory)) search_memories: list[str] = [] for item in search_items: memory = extract_memory_text(item) - if memory is not None and memory not in seen_memories: + if memory is not None and comparison_key(memory) not in seen_memories: search_memories.append(memory) - seen_memories.add(memory) + seen_memories.add(comparison_key(memory)) return DeduplicatedMemories( static=static_memories, diff --git a/packages/agent-framework-python/tests/test_utils.py b/packages/agent-framework-python/tests/test_utils.py index 5d0d9b8c..6b9362bb 100644 --- a/packages/agent-framework-python/tests/test_utils.py +++ b/packages/agent-framework-python/tests/test_utils.py @@ -56,20 +56,6 @@ class TestDeduplicateMemories: ) assert result.static == ["valid"] - def test_pydantic_like_search_results(self) -> None: - """SDK search results are pydantic models, not dicts (#1266).""" - from types import SimpleNamespace - - result = deduplicate_memories( - static=["User likes Python"], - search_results=[ - SimpleNamespace(memory="User prefers async", updated_at="2026-01-01T00:00:00Z"), - SimpleNamespace(memory="User likes Python", updated_at=None), - ], - ) - assert result.static == ["User likes Python"] - assert result.search_results == ["User prefers async"] - class TestConvertProfileToMarkdown: def test_empty_profile(self) -> None: diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 0927cfdf..fbaf0d25 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -19,7 +19,7 @@ yarn add @supermemory/ai-sdk Choose **one** of the following approaches (they cannot be used together): - **Infinite Chat Provider**: Connect to various LLM providers with unlimited context support -- **Memory Tools**: Search, add, and fetch memories from supermemory using AI agents +- **Memory Tools**: Search, add, inspect, and manage Supermemory data using AI agents ## Infinite Chat Provider @@ -27,6 +27,7 @@ The infinite chat provider allows you to connect to various LLM providers with s ```typescript import { generateText } from 'ai' +import { createOpenAI } from '@ai-sdk/openai' // Using a custom provider URL const supermemoryOpenai = createOpenAI({ @@ -50,6 +51,7 @@ const result = await generateText({ ```typescript import { generateText } from 'ai' +import { createOpenAI } from '@ai-sdk/openai' const supermemoryApiKey = process.env.SUPERMEMORY_API_KEY! const openaiApiKey = process.env.OPENAI_API_KEY! @@ -104,11 +106,12 @@ interface ConfigWithProviderUrl { ## Memory Tools -supermemory tools allow AI agents to interact with user memories for enhanced context and personalization. +Supermemory tools allow AI agents to search, add, inspect, and manage scoped Supermemory data. ```typescript import { supermemoryTools } from '@supermemory/ai-sdk' import { generateText } from 'ai' +import { openai } from '@ai-sdk/openai' const result = await generateText({ model: openai('gpt-5'), @@ -117,19 +120,15 @@ const result = await generateText({ ], tools: { ...supermemoryTools('your-supermemory-api-key', { - // Optional: specify a base URL for self-hosted instances - baseUrl: 'https://api.supermemory.com', - - // Use either projectId OR containerTags, not both - projectId: 'your-project-id', - // OR - containerTags: ['tag1', 'tag2'] -}), -// Your other tools go here + // Use either projectId OR containerTags, not both. + containerTags: ['user-123'] + }) } }) ``` +> **Important:** `supermemoryTools()` includes destructive operations: `documentDelete` permanently deletes a source document, while `memoryForget` soft-forgets an extracted profile memory. Do not expose the complete aggregate to an agent unless it should be allowed to perform those operations. + ### Complete Memory Tools Example ```typescript @@ -157,7 +156,6 @@ async function chatWithTools(userMessage: string) { containerTags: ['my-user-id'] }) }, - maxToolRoundtrips: 5 }) return result.text @@ -167,18 +165,32 @@ async function chatWithTools(userMessage: string) { ### Configuration ```typescript -interface SupermemoryConfig { - // Optional: Base URL for API calls (default: https://api.supermemory.com) +interface SupermemoryToolsConfig { + // Optional API base URL (default: https://api.supermemory.ai) baseUrl?: string - // Container tags for organizing memories (cannot be used with projectId) + // One or more non-empty scope tags (cannot be used with projectId) containerTags?: string[] - // Project ID for scoping memories (cannot be used with containerTags) + // Converted to sm_project_ (cannot be used with containerTags) projectId?: string + + // Enable the package's stricter provider-compatible input schemas + // (default: false) + strict?: boolean } ``` +`projectId` and `containerTags` are mutually exclusive and empty values are rejected. If neither is provided, v2 uses the explicit scope `sm_project_default`. With multiple `containerTags`, operations that support a union use all configured tags; single-profile operations default to the first tag. + +In strict mode, fields covered by a strict schema are required or defaulted. For example, `documentDelete.containerTag` must be a string or `null`; pass `null` to use the configured scope. + +### Migrating from v1 + +Version 1 returned only `searchMemories` and `addMemory` from `supermemoryTools()`. Version 2 returns all seven tools listed below, including deletion and forgetting, so review any code that spreads the aggregate directly into an agent. + +Version 1 also left `containerTags` undefined when no scope was configured. Version 2 sends `['sm_project_default']` instead. Before upgrading, choose an explicit `projectId` or `containerTags`, or migrate data that should live in the new default scope. + ### Self-Hosted supermemory If you're running a self-hosted supermemory instance: @@ -192,35 +204,17 @@ const tools = supermemoryTools('your-api-key', { ### Available Tools -##### Search Memories +| Aggregate key | Individual creator | Purpose | +| --- | --- | --- | +| `searchMemories` | `searchMemoriesTool` | Search stored source documents | +| `addMemory` | `addMemoryTool` | Add a short, atomic memory | +| `getProfile` | `getProfileTool` | Read static/dynamic profile text and optional query results | +| `documentList` | `documentListTool` | List paginated source-document metadata | +| `documentDelete` | `documentDeleteTool` | Permanently delete a source and soft-forget its extracted memories | +| `documentAdd` | `documentAddTool` | Ingest a source document for asynchronous processing | +| `memoryForget` | `memoryForgetTool` | Soft-forget one extracted profile memory | -Search through user memories using semantic matching. - -```typescript -const searchResult = await tools.searchMemories.execute({ - informationToGet: 'user preferences about coffee' -}) -``` - -##### Add Memory - -Add new memories to the user's memory store. - -```typescript -const addResult = await tools.addMemory.execute({ - memory: 'User prefers dark roast coffee in the morning' -}) -``` - -##### Fetch Memory - -Retrieve a specific memory by its ID. - -```typescript -const fetchResult = await tools.fetchMemory.execute({ - memoryId: 'memory-id-123' -}) -``` +There is no `fetchMemory` or `fetchMemoryTool`. Use `getProfile` for profile memories, `searchMemories` for relevant source content, and `documentList` for source-document IDs and metadata. ### Using Individual Tools @@ -230,7 +224,11 @@ For more flexibility, you can import and use individual tools: import { searchMemoriesTool, addMemoryTool, - fetchMemoryTool + getProfileTool, + documentListTool, + documentDeleteTool, + documentAddTool, + memoryForgetTool } from '@supermemory/ai-sdk' const searchTool = searchMemoriesTool('your-api-key', { @@ -247,6 +245,22 @@ const result = await generateText({ }) ``` +To expose a non-destructive subset, create the aggregate once and select only the tools the agent needs: + +```typescript +const allTools = supermemoryTools('your-api-key', { + containerTags: ['user-123'] +}) + +const safeTools = { + searchMemories: allTools.searchMemories, + addMemory: allTools.addMemory, + getProfile: allTools.getProfile, + documentList: allTools.documentList, + documentAdd: allTools.documentAdd +} +``` + ### Error Handling All tool executions return a result object with a `success` field: @@ -269,33 +283,24 @@ if (result.success) { ### Running Tests ```bash -# Run all tests -bun test +# From the repository root +bun run --cwd packages/ai-sdk test:unit -# Run tests in watch mode -bun test --watch +# Or from packages/ai-sdk +bun run test:unit ``` #### Environment Variables for Tests -All tests require API keys to run. Copy `.env.example` to `.env` and set the required values: +Local initialization and unit checks do not require API keys. Network integration checks run only when both of these are set; otherwise they are skipped: -```bash -cp .env.example .env -``` - -**Required:** -- `SUPERMEMORY_API_KEY`: Your Supermemory API key -- `PROVIDER_API_KEY`: Your AI provider API key (OpenAI, Anthropic, etc.) -- `OPENAI_API_KEY`: Your OpenAI API key for tool integration tests +- `SUPERMEMORY_API_KEY`: Supermemory API key +- `OPENAI_API_KEY`: OpenAI API key **Optional:** -- `SUPERMEMORY_BASE_URL`: Custom Supermemory base URL (defaults to `https://api.supermemory.ai`) -- `PROVIDER_NAME`: Provider name (defaults to `openai`) - one of: `openai`, `anthropic`, `openrouter`, `deepinfra`, `groq`, `google`, `cloudflare` -- `PROVIDER_URL`: Custom provider URL (use instead of `PROVIDER_NAME`) -- `MODEL_NAME`: Model to use in tests (defaults to `gpt-3.5-turbo`) -Tests will fail if required API keys are not provided. +- `SUPERMEMORY_BASE_URL`: Custom Supermemory base URL +- `MODEL_NAME`: OpenAI model used by integration checks (defaults to `gpt-5-nano`) ## License diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index d4923c5a..1d43283c 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -1,19 +1,19 @@ { "name": "@supermemory/ai-sdk", "type": "module", - "version": "1.0.9", + "version": "2.0.0", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest", - "test:unit": "vitest run src/tools.unit.test.ts", + "test:unit": "vitest run src/tools.test.ts", "test:watch": "vitest --watch" }, "dependencies": { "@ai-sdk/openai": "^2.0.22", "@ai-sdk/provider": "^2.0.0", - "@supermemory/tools": "workspace:*", + "@supermemory/tools": "^2.2.0", "ai": "^5.0.113", "supermemory": "^4.25.4" }, @@ -24,9 +24,12 @@ "typescript": "^5.9.2", "vitest": "^3.2.4" }, + "files": [ + "dist" + ], "main": "./dist/index.js", "module": "./dist/index.js", - "types": "./dist/index-B8qmWxBg.d.ts", + "types": "./dist/index.d.ts", "exports": { ".": "./dist/index.js", "./package.json": "./package.json" diff --git a/packages/ai-sdk/src/tools.test.ts b/packages/ai-sdk/src/tools.test.ts index 83e397ca..f6e8b41d 100644 --- a/packages/ai-sdk/src/tools.test.ts +++ b/packages/ai-sdk/src/tools.test.ts @@ -5,25 +5,15 @@ import { type SupermemoryToolsConfig, supermemoryTools } from "./tools" import "dotenv/config" -describe.skipIf( - !process.env.SUPERMEMORY_API_KEY || !process.env.OPENAI_API_KEY, -)("supermemoryTools", () => { - // Required API keys — suite is skipped in CI without them - const testApiKey = process.env.SUPERMEMORY_API_KEY as string - const testOpenAIKey = process.env.OPENAI_API_KEY as string - - // Optional configuration with defaults - const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined - const testModelName = process.env.MODEL_NAME || "gpt-5-nano" - - const testPrompts = [ - "What do you remember about my preferences?", - "Help me plan my day based on what you know about me", - "What are my current projects?", - "Remind me of my interests and hobbies", - "What should I focus on today?", - ] +const hasIntegrationKeys = Boolean( + process.env.SUPERMEMORY_API_KEY && process.env.OPENAI_API_KEY, +) +const testApiKey = process.env.SUPERMEMORY_API_KEY ?? "test-api-key" +const testOpenAIKey = process.env.OPENAI_API_KEY ?? "test-openai-key" +const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined +const testModelName = process.env.MODEL_NAME || "gpt-5-nano" +describe("supermemoryTools", () => { describe("client initialization", () => { it("should create tools with default configuration", () => { const config: SupermemoryToolsConfig = {} @@ -73,7 +63,7 @@ describe.skipIf( }) }) - describe("AI SDK integration", () => { + describe.skipIf(!hasIntegrationKeys)("AI SDK integration", () => { it("should work with AI SDK generateText", async () => { const openai = createOpenAI({ apiKey: testOpenAIKey, @@ -89,7 +79,7 @@ describe.skipIf( }, { role: "user", - content: testPrompts[0]!, + content: "What do you remember about my preferences?", }, ], tools: { diff --git a/packages/ai-sdk/src/tools.unit.test.ts b/packages/ai-sdk/src/tools.unit.test.ts deleted file mode 100644 index e4f58b7a..00000000 --- a/packages/ai-sdk/src/tools.unit.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest" -import { getContainerTags } from "./tools" - -describe("getContainerTags", () => { - it("defaults to the default project when no config is provided", () => { - expect(getContainerTags()).toEqual(["sm_project_default"]) - }) - - it("converts projectId into a project container tag", () => { - expect(getContainerTags({ projectId: "abc" })).toEqual(["sm_project_abc"]) - }) - - it("uses explicit container tags", () => { - expect(getContainerTags({ containerTags: ["tag-a", "tag-b"] })).toEqual([ - "tag-a", - "tag-b", - ]) - }) - - it("rejects config with both projectId and containerTags", () => { - expect(() => - getContainerTags({ - projectId: "abc", - containerTags: ["tag-a"], - }), - ).toThrow("either projectId or containerTags") - }) -}) diff --git a/packages/ai-sdk/tsdown.config.ts b/packages/ai-sdk/tsdown.config.ts index f587b211..79b3eff1 100644 --- a/packages/ai-sdk/tsdown.config.ts +++ b/packages/ai-sdk/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ target: "es2020", tsconfig: "./tsconfig.json", clean: true, + hash: false, minify: true, dts: { sourcemap: true, diff --git a/packages/cartesia-sdk-python/README.md b/packages/cartesia-sdk-python/README.md index bb446a7c..978ec23f 100644 --- a/packages/cartesia-sdk-python/README.md +++ b/packages/cartesia-sdk-python/README.md @@ -23,6 +23,7 @@ async def get_agent(env, call_request): # Create base LLM agent base_agent = LlmAgent( model="gemini/gemini-2.5-flash-preview-09-2025", + api_key=os.getenv("GEMINI_API_KEY"), config=LlmConfig( system_prompt="You are a helpful voice assistant with memory.", introduction="Hello! Great to talk with you again!" @@ -101,7 +102,7 @@ read_only_agent = SupermemoryCartesiaAgent( 1. **Intercepts events** - Listens for `UserTurnEnded` events from Cartesia Line 2. **Retrieves memories** - Queries Supermemory `/v4/profile` API with user's message -3. **Enriches context** - Adds memories to event history as system message +3. **Enriches context** - Passes memories as non-persistent context for the current turn 4. **Stores messages** - Sends conversation to Supermemory (background, non-blocking) 5. **Passes to agent** - Forwards enriched event to wrapped LlmAgent @@ -135,7 +136,7 @@ UserTurnEnded Event {content: "user message", history: [...]} │ 1. Intercept UserTurnEnded │ │ 2. Extract user message │ │ 3. Query Supermemory API │ -│ 4. Enrich event.history with memories │ +│ 4. Add memories as per-turn context │ │ 5. Pass to wrapped LlmAgent │ │ 6. Store conversation (async background) │ └──────────────────────────────────────────────┘ @@ -155,7 +156,7 @@ Audio Output | **Event Handling** | `process_frame()` method | `process()` method | | **Events** | `LLMContextFrame`, `LLMMessagesFrame` | `UserTurnEnded`, `CallStarted` | | **Context Object** | `LLMContext.get_messages()` | `event.history` | -| **Memory Injection** | Modify `context.add_message()` | Modify `event.history` | +| **Memory Injection** | Modify `context.add_message()` | Pass per-turn `context` | ## Full Example with Tools @@ -183,6 +184,7 @@ async def get_agent(env, call_request): # Create LLM agent with tools base_agent = LlmAgent( model="gemini/gemini-2.5-flash-preview-09-2025", + api_key=os.getenv("GEMINI_API_KEY"), tools=[weather_tool], config=LlmConfig( system_prompt="You are a personal assistant with memory and tools.", diff --git a/packages/cartesia-sdk-python/pyproject.toml b/packages/cartesia-sdk-python/pyproject.toml index 7cb93edc..0de043a4 100644 --- a/packages/cartesia-sdk-python/pyproject.toml +++ b/packages/cartesia-sdk-python/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] dependencies = [ "supermemory>=3.16.0", - "cartesia-line>=0.2.0", + "cartesia-line>=0.2.0,<0.3.0", "pydantic>=2.10.0", "loguru>=0.7.3", ] diff --git a/packages/cartesia-sdk-python/requirements.txt b/packages/cartesia-sdk-python/requirements.txt index dd4b8fe5..3cb93560 100644 --- a/packages/cartesia-sdk-python/requirements.txt +++ b/packages/cartesia-sdk-python/requirements.txt @@ -2,4 +2,4 @@ supermemory>=3.16.0 pydantic>=2.10.0 loguru>=0.7.3 -cartesia-line>=0.2.0 +cartesia-line>=0.2.0,<0.3.0 diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/__init__.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/__init__.py index b21511d9..c1d27e29 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/__init__.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/__init__.py @@ -5,12 +5,15 @@ enabling persistent memory and context enhancement for voice AI applications. Example: ```python + import os + from supermemory_cartesia import SupermemoryCartesiaAgent, MemoryConfig from line.llm_agent import LlmAgent, LlmConfig # Create base LLM agent base_agent = LlmAgent( model="gemini/gemini-2.5-flash-preview-09-2025", + api_key=os.getenv("GEMINI_API_KEY"), config=LlmConfig( system_prompt="You are a helpful assistant.", introduction="Hello!" @@ -22,6 +25,7 @@ Example: agent=base_agent, api_key=os.getenv("SUPERMEMORY_API_KEY"), container_tag="user-123", + custom_id="conversation-456", ) ``` """ @@ -46,7 +50,13 @@ from .utils import ( get_last_user_message, ) -__version__ = "0.1.0" +try: + from importlib.metadata import PackageNotFoundError, version + + __version__ = version("supermemory-cartesia") +except PackageNotFoundError: + # Source checkouts do not have installed distribution metadata. + __version__ = "0.1.2" __all__ = [ # Main agent diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py index d369af8e..27aba07b 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py @@ -5,6 +5,7 @@ Cartesia Line voice agents, adding persistent memory and context enrichment. """ import asyncio +import inspect import os import re from typing import Any, AsyncGenerator, Dict, List, Literal, Optional @@ -13,7 +14,7 @@ from loguru import logger from pydantic import BaseModel, Field from .exceptions import ConfigurationError, MemoryRetrievalError -from .utils import deduplicate_memories, format_memories_to_text +from .utils import _field, deduplicate_memories, format_memories_to_text try: import supermemory @@ -34,8 +35,7 @@ class SupermemoryCartesiaAgent: """Memory-enhanced wrapper for Cartesia Line agents. This wrapper intercepts UserTurnEnded events, retrieves relevant memories - from Supermemory, and enriches the conversation history before passing to - the wrapped agent. + from Supermemory, and passes them as per-turn context to the wrapped agent. Example: ```python @@ -44,6 +44,7 @@ class SupermemoryCartesiaAgent: base_agent = LlmAgent( model="anthropic/claude-haiku-4-5-20251001", + api_key=os.getenv("ANTHROPIC_API_KEY"), config=LlmConfig( system_prompt="You are a helpful assistant.", introduction="Hello! How can I help you today?" @@ -141,8 +142,8 @@ class SupermemoryCartesiaAgent: except Exception as e: logger.error(f"[Supermemory] Failed to initialize client: {e}") - self._messages_sent_count: int = 0 - self._last_query: Optional[str] = None + self._history_cursor: List[Dict[str, str]] = [] + self._last_retrieval_event: Optional[str] = None self._background_tasks: set = set() # Track background tasks to prevent GC async def _retrieve_memories(self, query: str) -> Dict[str, Any]: @@ -159,7 +160,6 @@ class SupermemoryCartesiaAgent: if self.config.mode != "profile" and query: kwargs["q"] = query kwargs["threshold"] = self.config.search_threshold - kwargs["extra_body"] = {"limit": self.config.search_limit} response = await asyncio.wait_for( self._supermemory_client.profile(**kwargs), @@ -169,17 +169,14 @@ class SupermemoryCartesiaAgent: # A user with no stored memories yet gets a null profile back, which # is a normal case, not an error. Guard against it so we return an # empty profile instead of raising AttributeError on response.profile. - profile = getattr(response, "profile", None) - profile_static = ( - profile.static if profile is not None and profile.static else [] - ) - profile_dynamic = ( - profile.dynamic if profile is not None and profile.dynamic else [] - ) + profile = _field(response, "profile") + profile_static = list(_field(profile, "static", default=[]) or []) + profile_dynamic = list(_field(profile, "dynamic", default=[]) or []) search_results: List[Any] = [] - if response.search_results and response.search_results.results: - search_results = list(response.search_results.results) + search_response = _field(response, "search_results", "searchResults") + raw_search_results = _field(search_response, "results", default=[]) or [] + search_results = list(raw_search_results)[: self.config.search_limit] logger.info( f"[Supermemory] Retrieved memories - static: {len(profile_static)}, " @@ -296,54 +293,73 @@ class SupermemoryCartesiaAgent: return str(content) def _extract_conversation_from_history(self, history: list) -> List[Dict[str, str]]: - """Extract messages from Cartesia event history.""" - messages = [] - seen = set() + """Extract messages, suppressing only adjacent duplicate representations.""" + messages: List[Dict[str, str]] = [] + + def append_message(role: str, content: Any) -> None: + if role not in ("user", "assistant") or not isinstance(content, str) or not content: + return + message = {"role": role, "content": content} + if not messages or messages[-1] != message: + messages.append(message) for item in history: if isinstance(item, dict): if item.get("role") in ("user", "assistant"): - content = item.get("content", "") - if content and content not in seen: - messages.append(item) - seen.add(content) + append_message(item["role"], item.get("content", "")) continue - event_type = getattr(item, 'type', None) or type(item).__name__ + event_type = getattr(item, "type", None) or type(item).__name__ - if event_type in ('user_turn_ended', 'UserTurnEnded'): - nested = getattr(item, 'content', []) + if event_type in ("user_turn_ended", "UserTurnEnded"): + nested = getattr(item, "content", []) if isinstance(nested, list): - for n in nested: - if hasattr(n, 'content') and isinstance(n.content, str): - if n.content not in seen: - messages.append({"role": "user", "content": n.content}) - seen.add(n.content) + for nested_item in nested: + if hasattr(nested_item, "content"): + append_message("user", nested_item.content) - elif event_type in ('agent_turn_ended', 'AgentTurnEnded'): - nested = getattr(item, 'content', []) + elif event_type in ("agent_turn_ended", "AgentTurnEnded"): + nested = getattr(item, "content", []) if isinstance(nested, list): - texts = [n.content for n in nested if hasattr(n, 'content') and isinstance(n.content, str)] + texts = [ + nested_item.content + for nested_item in nested + if hasattr(nested_item, "content") and isinstance(nested_item.content, str) + ] if texts: - content = " ".join(texts) - if content not in seen: - messages.append({"role": "assistant", "content": content}) - seen.add(content) + append_message("assistant", " ".join(texts)) - elif event_type in ('user_text_sent', 'UserTextSent'): - content = getattr(item, 'content', '') - if content and isinstance(content, str) and content not in seen: - messages.append({"role": "user", "content": content}) - seen.add(content) + elif event_type in ("user_text_sent", "UserTextSent"): + append_message("user", getattr(item, "content", "")) - elif event_type in ('agent_text_sent', 'AgentTextSent'): - content = getattr(item, 'content', '') - if content and isinstance(content, str) and content not in seen: - messages.append({"role": "assistant", "content": content}) - seen.add(content) + elif event_type in ("agent_text_sent", "AgentTextSent"): + append_message("assistant", getattr(item, "content", "")) return messages + def _new_messages_from_sequence( + self, + current_messages: List[Dict[str, str]], + ) -> List[Dict[str, str]]: + """Return the append after the longest previous-suffix/current-prefix overlap.""" + if current_messages and len(current_messages) <= len(self._history_cursor): + for start in range(len(self._history_cursor) - len(current_messages) + 1): + if self._history_cursor[start : start + len(current_messages)] == current_messages: + return [] + + overlap = 0 + for size in range(min(len(self._history_cursor), len(current_messages)), 0, -1): + if self._history_cursor[-size:] == current_messages[:size]: + overlap = size + break + + self._history_cursor = current_messages + return current_messages[overlap:] + + def _new_history_messages(self, history: list) -> List[Dict[str, str]]: + """Return only messages appended to a cumulative or front-truncated history.""" + return self._new_messages_from_sequence(self._extract_conversation_from_history(history)) + async def _enrich_event_with_memories(self, event: Any) -> tuple[Any, Optional[str]]: """Enrich event by retrieving memories. @@ -357,14 +373,16 @@ class SupermemoryCartesiaAgent: logger.warning("[Supermemory] Could not extract user message from event") return event, None - if user_message == self._last_query: + event_id = _field(event, "event_id", "eventId") + event_marker = f"event:{event_id}" if event_id else f"object:{id(event)}" + if event_marker == self._last_retrieval_event: return event, None - self._last_query = user_message logger.info(f"[Supermemory] Processing user message: {user_message[:50]}...") try: memories_data = await self._retrieve_memories(user_message) + self._last_retrieval_event = event_marker memory_context = self._build_memory_message(memories_data) if not memory_context: @@ -381,6 +399,69 @@ class SupermemoryCartesiaAgent: logger.error(f"[Supermemory] Error in memory enrichment: {e}") return event, None + def _agent_accepts_context(self) -> bool: + """Return whether the wrapped agent supports per-call context.""" + try: + parameters = inspect.signature(self.agent.process).parameters.values() + except (TypeError, ValueError): + return False + + return any( + parameter.name == "context" or parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters + ) + + @staticmethod + def _without_memory_context(prompt: str) -> str: + """Remove context previously injected by this wrapper.""" + return re.sub( + rf"{re.escape(MEMORY_TAG_START)}.*?{re.escape(MEMORY_TAG_END)}\s*", + "", + prompt, + flags=re.DOTALL, + ).strip() + + async def _process_agent( + self, + env: Any, + event: Event, + memory_context: Optional[str], + ) -> AsyncGenerator[Event, None]: + """Call modern Line agents with per-turn context, with a legacy fallback.""" + if self._agent_accepts_context(): + process_kwargs = {"context": memory_context} if memory_context else {} + async for output in self.agent.process(env, event, **process_kwargs): + yield output + return + + # Cartesia Line 0.2.0-0.2.2 exposed a mutable ``config`` property and + # did not yet support per-call context. Keep that narrow compatibility + # path while avoiding persistent memory text in the base prompt. + legacy_config = getattr(self.agent, "config", None) + if legacy_config is None: + if memory_context: + logger.warning( + "[Supermemory] Wrapped agent cannot accept memory context; " + "forwarding the event unchanged" + ) + async for output in self.agent.process(env, event): + yield output + return + + original_prompt = getattr(legacy_config, "system_prompt", "") or "" + clean_prompt = self._without_memory_context(str(original_prompt)) + prompt_for_call = ( + f"{memory_context}\n\n{clean_prompt}" + if memory_context and clean_prompt + else memory_context or clean_prompt + ) + legacy_config.system_prompt = prompt_for_call + try: + async for output in self.agent.process(env, event): + yield output + finally: + legacy_config.system_prompt = clean_prompt + async def process(self, env: Any, event: Event) -> AsyncGenerator[Event, None]: """Process events with memory enrichment. @@ -396,49 +477,31 @@ class SupermemoryCartesiaAgent: logger.info("[Supermemory] Processing UserTurnEnded event") event, memory_context = await self._enrich_event_with_memories(event) - # Clean up old memory context and inject new one if available - if hasattr(self.agent, 'config'): - original_prompt = getattr(self.agent.config, 'system_prompt', '') - # Always remove old memory context if present to prevent stale data - if MEMORY_TAG_START in original_prompt: - original_prompt = re.sub( - rf'{re.escape(MEMORY_TAG_START)}.*?{re.escape(MEMORY_TAG_END)}\s*', - '', - original_prompt, - flags=re.DOTALL - ) - logger.debug("[Supermemory] Removed old memory context from system prompt") - - # Inject new memory context if available - if memory_context: - self.agent.config.system_prompt = f"{memory_context}\n\n{original_prompt}" - logger.info("[Supermemory] Injected new memory context into system prompt") - else: - # No new memories, but we cleaned up old ones - self.agent.config.system_prompt = original_prompt - logger.debug("[Supermemory] No new memories to inject, using clean prompt") - # Store conversation in background if hasattr(event, 'history') and event.history: - messages = self._extract_conversation_from_history(event.history) - unsent = messages[self._messages_sent_count:] - if unsent: - logger.info(f"[Supermemory] Queuing {len(unsent)} messages for storage") - task = asyncio.create_task(self._store_messages(unsent)) + new_messages = self._new_history_messages(event.history) + if new_messages: + logger.info( + f"[Supermemory] Queuing {len(new_messages)} messages for storage" + ) + task = asyncio.create_task(self._store_messages(new_messages)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - self._messages_sent_count = len(messages) else: # No history yet, store just the current user message - user_content = self._extract_user_message(event) - if user_content: - logger.info(f"[Supermemory] No history, storing current user message: {user_content[:50]}...") - task = asyncio.create_task(self._store_messages([{"role": "user", "content": user_content}])) + current_messages = self._extract_conversation_from_history([event]) + if not current_messages: + user_content = self._extract_user_message(event) + if user_content: + current_messages = [{"role": "user", "content": user_content}] + new_messages = self._new_messages_from_sequence(current_messages) + if new_messages: + logger.info("[Supermemory] No history, storing current user message") + task = asyncio.create_task(self._store_messages(new_messages)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - self._messages_sent_count = 1 # CRITICAL: Increment counter to prevent duplicate storage - async for output in self.agent.process(env, event): + async for output in self._process_agent(env, event, memory_context): yield output else: async for output in self.agent.process(env, event): @@ -451,6 +514,6 @@ class SupermemoryCartesiaAgent: def reset_memory_tracking(self) -> None: """Reset memory tracking for a new conversation.""" - self._messages_sent_count = 0 - self._last_query = None + self._history_cursor = [] + self._last_retrieval_event = None logger.info("[Supermemory] Reset memory tracking state") diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py index 5cda9298..1e4a6e00 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py @@ -1,5 +1,6 @@ """Utility functions for Supermemory Cartesia integration.""" +import re from datetime import datetime, timezone from typing import Any, Dict, List, Union @@ -69,6 +70,18 @@ def _field(item: Any, *names: str, default: Any = None) -> Any: return default +_MEMORY_DATE_PREFIX = re.compile( + r"^\s*(?:\[recent\]\s*)?(?:\[\d{4}-\d{2}-\d{2}\]\s*)?", + re.IGNORECASE, +) + + +def _memory_key(memory: str) -> str: + """Normalize display-only profile prefixes for duplicate comparison.""" + without_prefix = _MEMORY_DATE_PREFIX.sub("", memory) + return " ".join(without_prefix.split()).casefold() + + def deduplicate_memories( static: List[str], dynamic: List[str], @@ -86,8 +99,11 @@ def deduplicate_memories( def unique_strings(memories: List[str]) -> List[str]: out = [] for m in memories: - if m not in seen: - seen.add(m) + if not isinstance(m, str): + continue + key = _memory_key(m) + if key and key not in seen: + seen.add(key) out.append(m) return out @@ -99,8 +115,9 @@ def deduplicate_memories( if not isinstance(memory, str): memory = "" memory = memory.strip() - if memory and memory not in seen: - seen.add(memory) + key = _memory_key(memory) + if key and key not in seen: + seen.add(key) out.append(r) return out diff --git a/packages/cartesia-sdk-python/tests/test_dedupe_utils.py b/packages/cartesia-sdk-python/tests/test_dedupe_utils.py deleted file mode 100644 index 81931fe1..00000000 --- a/packages/cartesia-sdk-python/tests/test_dedupe_utils.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Regression tests for pydantic/dict memory helpers (#1266).""" - -from __future__ import annotations - -import sys -import types -import unittest -from types import SimpleNamespace - - -def _install_test_stubs() -> None: - if "loguru" not in sys.modules: - loguru_module = types.ModuleType("loguru") - - class _Logger: - def info(self, *_args, **_kwargs): - return None - - def warning(self, *_args, **_kwargs): - return None - - def error(self, *_args, **_kwargs): - return None - - loguru_module.logger = _Logger() - sys.modules["loguru"] = loguru_module - - if "pydantic" not in sys.modules: - pydantic_module = types.ModuleType("pydantic") - - class BaseModel: - def __init__(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) - - def Field(*, default=None, **_kwargs): - return default - - pydantic_module.BaseModel = BaseModel - pydantic_module.Field = Field - sys.modules["pydantic"] = pydantic_module - - -_install_test_stubs() - -from supermemory_cartesia.utils import deduplicate_memories, format_memories_to_text - - -class TestDeduplicateMemories(unittest.TestCase): - def test_accepts_dict_search_results(self) -> None: - result = deduplicate_memories( - static=["User likes Python"], - dynamic=[], - search_results=[{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}], - ) - self.assertEqual(result["static"], ["User likes Python"]) - self.assertEqual(len(result["search_results"]), 1) - - def test_accepts_pydantic_like_search_results(self) -> None: - # Mirrors supermemory.types.search_memories_response.Result - model = SimpleNamespace( - id="mem_1", - similarity=0.9, - memory="User prefers async", - updated_at="2026-01-01T00:00:00Z", - ) - result = deduplicate_memories( - static=[], - dynamic=[], - search_results=[model], - ) - self.assertEqual(len(result["search_results"]), 1) - self.assertIs(result["search_results"][0], model) - - def test_dedupes_model_against_static_string(self) -> None: - model = SimpleNamespace(memory="User likes Python", updated_at=None) - result = deduplicate_memories( - static=["User likes Python"], - dynamic=[], - search_results=[model], - ) - self.assertEqual(result["search_results"], []) - - -class TestFormatMemoriesToText(unittest.TestCase): - def test_formats_pydantic_like_search_results(self) -> None: - text = format_memories_to_text( - { - "static": [], - "dynamic": [], - "search_results": [ - SimpleNamespace( - memory="User prefers async", - updated_at="2020-01-01T00:00:00Z", - ) - ], - } - ) - self.assertIn("User prefers async", text) - self.assertIn("Relevant Memories", text) - - def test_formats_search_execute_content_field(self) -> None: - text = format_memories_to_text( - { - "static": [], - "dynamic": [], - "search_results": [ - SimpleNamespace( - content="User owns a telescope", - updated_at="2020-01-01T00:00:00Z", - memory=None, - ) - ], - } - ) - self.assertIn("User owns a telescope", text) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cartesia-sdk-python/tests/test_empty_profile.py b/packages/cartesia-sdk-python/tests/test_empty_profile.py index a430007e..382e5e5f 100644 --- a/packages/cartesia-sdk-python/tests/test_empty_profile.py +++ b/packages/cartesia-sdk-python/tests/test_empty_profile.py @@ -71,10 +71,6 @@ class TestSupermemoryCartesiaNullProfile(unittest.IsolatedAsyncioTestCase): "search_results": [], }, ) - agent._supermemory_client.profile.assert_awaited_once() - kwargs = agent._supermemory_client.profile.await_args.kwargs - self.assertEqual(kwargs["container_tag"], "user-123") - self.assertEqual(kwargs["q"], "Hello world") if __name__ == "__main__": diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 63a7d0e5..780a171f 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,5 +1,8 @@ { "name": "@repo/hooks", "version": "0.0.0", - "private": true + "private": true, + "scripts": { + "check-types": "tsc --noEmit" + } } diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index ae44e6bd..af0e989b 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -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(), diff --git a/packages/lib/package.json b/packages/lib/package.json index 99b9d262..c83e27db 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + }, "exports": { "./*": "./*" }, diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts index 4f728f97..f98df074 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -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 }) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 9d0c2ef6..d165ac71 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -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(() => { diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 00a05d10..aed2d6f0 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -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 diff --git a/packages/openai-sdk-python/README.md b/packages/openai-sdk-python/README.md index 7e5b68aa..9b455b3b 100644 --- a/packages/openai-sdk-python/README.md +++ b/packages/openai-sdk-python/README.md @@ -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 diff --git a/packages/openai-sdk-python/pyproject.toml b/packages/openai-sdk-python/pyproject.toml index 3aa3d9d7..1d11cf85 100644 --- a/packages/openai-sdk-python/pyproject.toml +++ b/packages/openai-sdk-python/pyproject.toml @@ -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", ] diff --git a/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py b/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py deleted file mode 100644 index 4afc4899..00000000 --- a/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py +++ /dev/null @@ -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}" - ) diff --git a/packages/openai-sdk-python/src/supermemory_openai/middleware.py b/packages/openai-sdk-python/src/supermemory_openai/middleware.py index 1a079fa2..9cbad1b0 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/middleware.py +++ b/packages/openai-sdk-python/src/supermemory_openai/middleware.py @@ -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: diff --git a/packages/openai-sdk-python/src/supermemory_openai/tools.py b/packages/openai-sdk-python/src/supermemory_openai/tools.py index cdbd2ef7..bad75e8a 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/tools.py +++ b/packages/openai-sdk-python/src/supermemory_openai/tools.py @@ -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, diff --git a/packages/openai-sdk-python/tests/test_tools.py b/packages/openai-sdk-python/tests/test_tools.py index 3a5e34f9..4680a9a2 100644 --- a/packages/openai-sdk-python/tests/test_tools.py +++ b/packages/openai-sdk-python/tests/test_tools.py @@ -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" diff --git a/packages/openai-sdk-python/uv.lock b/packages/openai-sdk-python/uv.lock index 72db4dd2..05fcba22 100644 --- a/packages/openai-sdk-python/uv.lock +++ b/packages/openai-sdk-python/uv.lock @@ -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"] diff --git a/packages/pipecat-sdk-python/README.md b/packages/pipecat-sdk-python/README.md index bb8e26e1..f885ed71 100644 --- a/packages/pipecat-sdk-python/README.md +++ b/packages/pipecat-sdk-python/README.md @@ -13,7 +13,7 @@ pip install supermemory-pipecat ```python import os from pipecat.pipeline.pipeline import Pipeline -from pipecat.services.openai import OpenAILLMService, OpenAIUserContextAggregator +from pipecat.processors.aggregators.llm_context import LLMContext from supermemory_pipecat import SupermemoryPipecatService # Create memory service @@ -23,14 +23,19 @@ memory = SupermemoryPipecatService( session_id="conversation-456", # Optional: groups memories by session ) +# Use the universal LLM context supported by current Pipecat releases. +context = LLMContext([{"role": "system", "content": "You are a helpful assistant."}]) +context_aggregator = llm.create_context_aggregator(context) + # Create pipeline with memory pipeline = Pipeline([ transport.input(), stt, - user_context, + context_aggregator.user(), memory, # Automatically retrieves and injects relevant memories llm, transport.output(), + context_aggregator.assistant(), ]) ``` @@ -58,6 +63,7 @@ memory = SupermemoryPipecatService( search_limit=10, # Max memories to retrieve search_threshold=0.1, # Similarity threshold mode="full", # "profile", "query", or "full" + inject_mode="auto", # "auto", "system", or "user" system_prompt="Based on previous conversations, I recall:\n\n", ), ) @@ -73,25 +79,30 @@ memory = SupermemoryPipecatService( ## How It Works -1. **Intercepts context frames** - Listens for `LLMContextFrame` in the pipeline -2. **Tracks conversation** - Maintains clean conversation history (no injected memories) +1. **Intercepts context frames** - Listens for Pipecat's universal `LLMContextFrame` (and legacy 0.x frames) +2. **Tracks conversation** - Separates real conversation messages from tagged memory context 3. **Retrieves memories** - Queries `/v4/profile` API with user's message -4. **Injects memories** - Formats and adds to LLM context as system message -5. **Stores messages** - Sends last user message to Supermemory (background, non-blocking) +4. **Injects memories** - Uses a system message for audio/system mode and a tagged user message otherwise +5. **Stores messages** - Serializes newly observed user and assistant messages through a background queue that drains during cleanup ### What Gets Stored -Only the last user message is sent to Supermemory: +New user and assistant messages are stored as a JSON conversation segment. The +injected `` message is filtered out before storage and does not +advance the storage cursor. + +For example, this conversation segment: ``` User: What's the weather like today? +Assistant: It's sunny today. ``` -Stored as: +is sent to Supermemory as: ```json { - "content": "User: What's the weather like today?", + "content": "[{\"role\": \"user\", \"content\": \"What's the weather like today?\"}, {\"role\": \"assistant\", \"content\": \"It's sunny today.\"}]", "container_tags": ["user-123"], "custom_id": "conversation-456", "metadata": { "platform": "pipecat" } @@ -107,7 +118,7 @@ from fastapi import FastAPI, WebSocket from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.runner import PipelineRunner -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService from pipecat.transports.websocket.fastapi import ( FastAPIWebsocketTransport, @@ -132,7 +143,7 @@ async def websocket_endpoint(websocket: WebSocket): model="models/gemini-2.5-flash-native-audio-preview-12-2025", ) - context = OpenAILLMContext([{"role": "system", "content": "You are a helpful assistant."}]) + context = LLMContext([{"role": "system", "content": "You are a helpful assistant."}]) context_aggregator = llm.create_context_aggregator(context) # Supermemory memory service diff --git a/packages/pipecat-sdk-python/pyproject.toml b/packages/pipecat-sdk-python/pyproject.toml index 92825ccb..5f1f405e 100644 --- a/packages/pipecat-sdk-python/pyproject.toml +++ b/packages/pipecat-sdk-python/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ - "pipecat-ai>=0.0.98", + "pipecat-ai>=0.0.98,<2.0.0", "supermemory>=3.16.0", "pydantic>=2.10.0", "loguru>=0.7.3", diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/__init__.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/__init__.py index aeb190c9..234bfa7e 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/__init__.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/__init__.py @@ -25,6 +25,8 @@ Example: ``` """ +from importlib.metadata import PackageNotFoundError, version + from .exceptions import ( APIError, ConfigurationError, @@ -40,7 +42,11 @@ from .utils import ( get_last_user_message, ) -__version__ = "0.1.1" +try: + __version__ = version("supermemory-pipecat") +except PackageNotFoundError: + # Source-tree fallback; built wheels always use package metadata above. + __version__ = "0.1.2" __all__ = [ # Main service diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py index 04696076..d3b000b5 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py @@ -6,22 +6,37 @@ historical information. """ import asyncio +import copy import json import os import re +from collections import deque from typing import Any, Dict, List, Literal, Optional from loguru import logger from pydantic import BaseModel, Field -from pipecat.frames.frames import Frame, InputAudioRawFrame, LLMContextFrame, LLMMessagesFrame +from pipecat.frames.frames import Frame, InputAudioRawFrame, LLMContextFrame from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pydantic import BaseModel, Field -from .exceptions import ConfigurationError, MemoryRetrievalError -from .utils import deduplicate_memories, format_memories_to_text, get_last_user_message +from .exceptions import ConfigurationError, MemoryRetrievalError, MemoryStorageError +from .utils import _field, deduplicate_memories, format_memories_to_text + +# Pipecat 1.0 removed the legacy message and OpenAI-specific context frames. +# Keep them optional so the integration supports both the declared 0.0.98 +# minimum and the universal LLMContextFrame used by current Pipecat releases. +try: + from pipecat.frames.frames import LLMMessagesFrame as _LegacyLLMMessagesFrame +except ImportError: # Pipecat >= 1.0 + _LegacyLLMMessagesFrame = None # type: ignore[assignment] + +try: + from pipecat.processors.aggregators.openai_llm_context import ( + OpenAILLMContextFrame as _LegacyOpenAILLMContextFrame, + ) +except (ImportError, ModuleNotFoundError): # Pipecat >= 1.0 + _LegacyOpenAILLMContextFrame = None # type: ignore[assignment] try: import supermemory @@ -34,6 +49,72 @@ MEMORY_TAG_END = "" MEMORY_TAG_PATTERN = re.compile(r".*?", re.DOTALL) +def _is_legacy_openai_context_frame(frame: Frame) -> bool: + return _LegacyOpenAILLMContextFrame is not None and isinstance( + frame, _LegacyOpenAILLMContextFrame + ) + + +def _is_legacy_messages_frame(frame: Frame) -> bool: + return _LegacyLLMMessagesFrame is not None and isinstance(frame, _LegacyLLMMessagesFrame) + + +def _snapshot_storable_messages(messages: List[Any]) -> List[Dict[str, Any]]: + """Copy real conversation messages, excluding injected memory context.""" + storable: List[Dict[str, Any]] = [] + for message in messages: + if not isinstance(message, dict) or message.get("role") not in ("user", "assistant"): + continue + + if _is_injected_user_memory(message): + continue + + storable.append(copy.deepcopy(message)) + + return storable + + +def _is_injected_user_memory(message: Any) -> bool: + """Return whether a message is the wrapper's standalone memory message.""" + return _is_standalone_memory_message(message, roles=("user",)) + + +def _is_standalone_memory_message(message: Any, *, roles: tuple[str, ...]) -> bool: + if not isinstance(message, dict) or message.get("role") not in roles: + return False + content = message.get("content") + return isinstance(content, str) and MEMORY_TAG_PATTERN.fullmatch(content.strip()) is not None + + +def _messages_after_overlap( + previous: List[Dict[str, Any]], + current: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Return messages after the largest previous-suffix/current-prefix overlap. + + Comparing ordered occurrences instead of only list lengths handles appended, + replaced, and front-truncated contexts. Identical repeated messages remain + distinct because overlap is positional rather than set-based. + """ + max_overlap = min(len(previous), len(current)) + for overlap in range(max_overlap, 0, -1): + if previous[-overlap:] == current[:overlap]: + return copy.deepcopy(current[overlap:]) + return copy.deepcopy(current) + + +def _latest_user_occurrence( + messages: List[Dict[str, Any]], +) -> tuple[Optional[str], Optional[List[Dict[str, Any]]]]: + """Return the last text user query and its ordered conversation prefix.""" + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + content = message.get("content") + if message.get("role") == "user" and isinstance(content, str): + return content, copy.deepcopy(messages[: index + 1]) + return None, None + + class SupermemoryPipecatService(FrameProcessor): """Memory service that integrates Supermemory with Pipecat pipelines. @@ -115,9 +196,12 @@ class SupermemoryPipecatService(FrameProcessor): except Exception as e: logger.warning(f"Failed to initialize Supermemory client: {e}") - self._messages_sent_count: int = 0 self._last_query: Optional[str] = None + self._last_recalled_user_prefix: Optional[List[Dict[str, Any]]] = None self._audio_frames_detected: bool = False + self._latest_storable_context: List[Dict[str, Any]] = [] + self._storage_queue: deque[List[Dict[str, Any]]] = deque() + self._storage_worker_task: Optional[asyncio.Task[None]] = None async def _retrieve_memories(self, query: str) -> Dict[str, Any]: """Retrieve relevant memories from Supermemory. @@ -143,21 +227,19 @@ class SupermemoryPipecatService(FrameProcessor): if self.params.mode != "profile" and query: kwargs["q"] = query kwargs["threshold"] = self.params.search_threshold - kwargs["extra_body"] = {"limit": self.params.search_limit} response = await self._supermemory_client.profile(**kwargs) - profile = getattr(response, "profile", None) - search_results_response = getattr(response, "search_results", None) + profile = _field(response, "profile") + search_results_response = _field(response, "search_results", "searchResults") - search_results: List[Any] = [] - if search_results_response and search_results_response.results: - search_results = list(search_results_response.results) + raw_search_results = _field(search_results_response, "results", default=[]) or [] + search_results = list(raw_search_results)[: self.params.search_limit] return { "profile": { - "static": profile.static if profile is not None else [], - "dynamic": profile.dynamic if profile is not None else [], + "static": list(_field(profile, "static", default=[]) or []), + "dynamic": list(_field(profile, "dynamic", default=[]) or []), }, "search_results": search_results, } @@ -167,10 +249,13 @@ class SupermemoryPipecatService(FrameProcessor): raise MemoryRetrievalError("Failed to retrieve memories", e) async def _store_messages(self, messages: List[Dict[str, Any]]) -> None: - """Store messages in Supermemory (non-blocking, fire-and-forget).""" - if self._supermemory_client is None or not messages: + """Store one ordered message batch in Supermemory.""" + if not messages: return + if self._supermemory_client is None: + raise MemoryStorageError("Supermemory client is not initialized") + try: add_params: Dict[str, Any] = { "content": json.dumps(messages), @@ -183,7 +268,79 @@ class SupermemoryPipecatService(FrameProcessor): await self._supermemory_client.add(**add_params) except Exception as e: - logger.error(f"Error storing messages: {e}") + raise MemoryStorageError("Failed to store messages", e) from e + + def _queue_context_for_storage(self, messages: List[Dict[str, Any]]) -> None: + """Queue only newly observed message occurrences, preserving order.""" + current = copy.deepcopy(messages) + new_messages = _messages_after_overlap(self._latest_storable_context, current) + self._latest_storable_context = current + + if new_messages: + self._storage_queue.append(new_messages) + + # A new frame also retries a previously failed head batch. + self._start_storage_worker() + + def _start_storage_worker(self) -> None: + """Start the single serial storage worker when work is pending.""" + if not self._storage_queue: + return + if self._storage_worker_task is not None and not self._storage_worker_task.done(): + return + self._storage_worker_task = asyncio.create_task(self._run_storage_queue()) + + async def _run_storage_queue(self) -> None: + """Write queued batches serially, retaining the head batch on failure.""" + while self._storage_queue: + messages = self._storage_queue[0] + try: + await self._store_messages(messages) + except Exception as e: + logger.error(f"Error storing messages; batch remains queued for retry: {e}") + return + self._storage_queue.popleft() + + async def _drain_storage_queue(self) -> None: + """Wait for queued writes and retry a failed head batch once at teardown.""" + task = self._storage_worker_task + if task is not None and not task.done(): + await task + + if self._storage_queue: + self._start_storage_worker() + retry_task = self._storage_worker_task + if retry_task is not None: + await retry_task + + if self._storage_queue: + logger.error( + f"Unable to drain {len(self._storage_queue)} Supermemory storage batch(es)" + ) + + @staticmethod + def _clear_injected_memories(context: LLMContext) -> None: + """Remove memory tags owned by this wrapper while preserving other entries.""" + messages = context.get_messages() + cleaned_messages: List[Any] = [] + + for message in messages: + if _is_standalone_memory_message(message, roles=("system", "user")): + continue + if not isinstance(message, dict): + cleaned_messages.append(message) + continue + + role = message.get("role") + content = message.get("content") + if role in ("system", "user") and isinstance(content, str): + cleaned_content = MEMORY_TAG_PATTERN.sub("", content) + if cleaned_content != content: + message["content"] = cleaned_content.strip() + + cleaned_messages.append(message) + + messages[:] = cleaned_messages def _enhance_context_with_memories( self, @@ -201,11 +358,6 @@ class SupermemoryPipecatService(FrameProcessor): query: The query used for retrieval. memories_data: Memory data from Supermemory API. """ - if self._last_query == query: - return - - self._last_query = query - profile = memories_data["profile"] deduplicated = deduplicate_memories( static=profile["static"], @@ -247,7 +399,11 @@ class SupermemoryPipecatService(FrameProcessor): if inject_to_system: system_idx = None for i, msg in enumerate(messages): - if msg.get("role") == "system": + if ( + isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + ): system_idx = i break @@ -265,7 +421,7 @@ class SupermemoryPipecatService(FrameProcessor): # Remove previous memory message if exists for i in range(len(messages) - 1, -1, -1): msg = messages[i] - if msg.get("role") == "user" and MEMORY_TAG_START in msg.get("content", ""): + if _is_injected_user_memory(msg): messages.pop(i) break @@ -283,51 +439,66 @@ class SupermemoryPipecatService(FrameProcessor): return context = None - messages = None - if isinstance(frame, (LLMContextFrame, OpenAILLMContextFrame)): + legacy_messages_frame = False + + if isinstance(frame, LLMContextFrame) or _is_legacy_openai_context_frame(frame): context = frame.context - elif isinstance(frame, LLMMessagesFrame): - messages = frame.messages - context = LLMContext(messages) + elif _is_legacy_messages_frame(frame): + legacy_messages_frame = True + context = LLMContext(frame.messages) - if context: + if context is not None: try: context_messages = context.get_messages() - latest_user_message = get_last_user_message(context_messages) + # Snapshot the real conversation before adding memory context. + # Injected messages must never be persisted or + # included in the sent-message cursor. + storable_messages = _snapshot_storable_messages(context_messages) + latest_user_message, user_prefix = _latest_user_occurrence(storable_messages) - if latest_user_message: + if ( + latest_user_message + and user_prefix is not None + and user_prefix != self._last_recalled_user_prefix + ): + # Clear stale recall before a new lookup. If retrieval + # fails or returns no memories, old context cannot leak + # into the new turn. + self._clear_injected_memories(context) try: memories_data = await self._retrieve_memories(latest_user_message) self._enhance_context_with_memories( context, latest_user_message, memories_data ) + # Mark only successful recalls (including empty ones). + # Failures stay retryable on the next repeated frame. + self._last_query = latest_user_message + self._last_recalled_user_prefix = user_prefix except MemoryRetrievalError as e: logger.warning(f"Memory retrieval failed: {e}") - # Store unsent messages (user and assistant only) - storable_messages = [ - msg for msg in context_messages if msg["role"] in ("user", "assistant") - ] - unsent_messages = storable_messages[self._messages_sent_count :] + self._queue_context_for_storage(storable_messages) - if unsent_messages: - asyncio.create_task(self._store_messages(unsent_messages)) - self._messages_sent_count = len(storable_messages) - - if messages is not None: - await self.push_frame(LLMMessagesFrame(context.get_messages())) + if legacy_messages_frame: + await self.push_frame(frame.__class__(context.get_messages()), direction) else: - await self.push_frame(frame) + await self.push_frame(frame, direction) except Exception as e: logger.error(f"Error processing frame: {e}") - await self.push_frame(frame) + await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) + async def cleanup(self) -> None: + """Drain pending Supermemory writes before Pipecat tears down the processor.""" + await self._drain_storage_queue() + await super().cleanup() + def reset_memory_tracking(self) -> None: """Reset memory tracking state for a new conversation.""" - self._messages_sent_count = 0 + self._latest_storable_context = [] self._last_query = None + self._last_recalled_user_prefix = None self._audio_frames_detected = False diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py index 3b74509f..743bb4c0 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py @@ -1,14 +1,19 @@ """Utility functions for Supermemory Pipecat integration.""" +import re from datetime import datetime, timezone from typing import Any, Dict, List, Union -def get_last_user_message(messages: List[Dict[str, str]]) -> str | None: +_DYNAMIC_DATE_PREFIX = re.compile(r"^\s*(?:\[Recent\]\s*)?\[\d{4}-\d{2}-\d{2}\]\s*") + + +def get_last_user_message(messages: List[Dict[str, Any]]) -> str | None: """Extract the last user message content from a list of messages.""" for msg in reversed(messages): - if msg["role"] == "user": - return msg["content"] + content = msg.get("content") + if msg.get("role") == "user" and isinstance(content, str): + return content return None @@ -81,26 +86,37 @@ def deduplicate_memories( dynamic: List of dynamic memory strings. search_results: Search result dicts or pydantic models with a memory field. """ - seen = set() + seen: set[str] = set() + + def comparison_key(memory: str) -> str: + # Dynamic profile entries are date-labelled by the API while search + # results contain the same memory without that presentation prefix. + return _DYNAMIC_DATE_PREFIX.sub("", memory.strip()) def unique_strings(memories: List[str]) -> List[str]: - out = [] + out: List[str] = [] for m in memories: - if m not in seen: - seen.add(m) + if not isinstance(m, str): + continue + key = comparison_key(m) + if key and key not in seen: + seen.add(key) out.append(m) return out def unique_search(results: List[Any]) -> List[Any]: - out = [] + out: List[Any] = [] for r in results: # v4 search.memories/hybrid uses `memory` or `chunk`. - memory = _field(r, "memory", "chunk", "content", default="") + memory = ( + r if isinstance(r, str) else _field(r, "memory", "chunk", "content", default="") + ) if not isinstance(memory, str): memory = "" memory = memory.strip() - if memory and memory not in seen: - seen.add(memory) + key = comparison_key(memory) + if key and key not in seen: + seen.add(key) out.append(r) return out diff --git a/packages/pipecat-sdk-python/tests/test_dedupe_utils.py b/packages/pipecat-sdk-python/tests/test_dedupe_utils.py deleted file mode 100644 index 94abeea2..00000000 --- a/packages/pipecat-sdk-python/tests/test_dedupe_utils.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Regression tests for pydantic/dict memory helpers (#1266).""" - -from __future__ import annotations - -import sys -import types -import unittest -from types import SimpleNamespace -from unittest.mock import AsyncMock - - -def _install_test_stubs() -> None: - if "loguru" not in sys.modules: - loguru_module = types.ModuleType("loguru") - - class _Logger: - def warning(self, *_args, **_kwargs): - return None - - def error(self, *_args, **_kwargs): - return None - - def info(self, *_args, **_kwargs): - return None - - loguru_module.logger = _Logger() - sys.modules["loguru"] = loguru_module - - if "pydantic" not in sys.modules: - pydantic_module = types.ModuleType("pydantic") - - class BaseModel: - def __init__(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) - - def Field(*, default=None, **_kwargs): - return default - - pydantic_module.BaseModel = BaseModel - pydantic_module.Field = Field - sys.modules["pydantic"] = pydantic_module - - if "pipecat" not in sys.modules: - pipecat_module = types.ModuleType("pipecat") - sys.modules["pipecat"] = pipecat_module - - frames_module = types.ModuleType("pipecat.frames.frames") - - class Frame: - pass - - class InputAudioRawFrame: - pass - - class LLMContextFrame: - pass - - class LLMMessagesFrame: - pass - - frames_module.Frame = Frame - frames_module.InputAudioRawFrame = InputAudioRawFrame - frames_module.LLMContextFrame = LLMContextFrame - frames_module.LLMMessagesFrame = LLMMessagesFrame - - llm_context_module = types.ModuleType( - "pipecat.processors.aggregators.llm_context" - ) - - class LLMContext: - pass - - llm_context_module.LLMContext = LLMContext - - openai_context_module = types.ModuleType( - "pipecat.processors.aggregators.openai_llm_context" - ) - - class OpenAILLMContextFrame: - pass - - openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame - - frame_processor_module = types.ModuleType("pipecat.processors.frame_processor") - - class FrameDirection: - pass - - class FrameProcessor: - def __init__(self, *args, **kwargs): - return None - - frame_processor_module.FrameDirection = FrameDirection - frame_processor_module.FrameProcessor = FrameProcessor - - sys.modules["pipecat.frames.frames"] = frames_module - sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module - sys.modules[ - "pipecat.processors.aggregators.openai_llm_context" - ] = openai_context_module - sys.modules["pipecat.processors.frame_processor"] = frame_processor_module - - -_install_test_stubs() - -from supermemory_pipecat.service import SupermemoryPipecatService -from supermemory_pipecat.utils import deduplicate_memories, format_memories_to_text - - -class TestDeduplicateMemories(unittest.TestCase): - def test_accepts_dict_search_results(self) -> None: - result = deduplicate_memories( - static=["User likes Python"], - dynamic=[], - search_results=[{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}], - ) - self.assertEqual(result["static"], ["User likes Python"]) - self.assertEqual(len(result["search_results"]), 1) - - def test_accepts_pydantic_like_search_results(self) -> None: - model = SimpleNamespace( - id="mem_1", - similarity=0.9, - memory="User prefers async", - updated_at="2026-01-01T00:00:00Z", - ) - result = deduplicate_memories( - static=[], - dynamic=[], - search_results=[model], - ) - self.assertEqual(len(result["search_results"]), 1) - self.assertIs(result["search_results"][0], model) - - def test_dedupes_model_against_static_string(self) -> None: - model = SimpleNamespace(memory="User likes Python", updated_at=None) - result = deduplicate_memories( - static=["User likes Python"], - dynamic=[], - search_results=[model], - ) - self.assertEqual(result["search_results"], []) - - -class TestFormatMemoriesToText(unittest.TestCase): - def test_formats_pydantic_like_search_results(self) -> None: - text = format_memories_to_text( - { - "static": [], - "dynamic": [], - "search_results": [ - SimpleNamespace( - memory="User prefers async", - updated_at="2020-01-01T00:00:00Z", - ) - ], - } - ) - self.assertIn("User prefers async", text) - self.assertIn("Relevant Memories", text) - - -class TestStoreMessagesUsesClientAdd(unittest.IsolatedAsyncioTestCase): - async def test_store_messages_calls_client_add(self) -> None: - service = SupermemoryPipecatService(api_key="mock_key", user_id="user-123") - service._supermemory_client = SimpleNamespace(add=AsyncMock()) - - await service._store_messages( - [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}] - ) - - service._supermemory_client.add.assert_awaited_once() - kwargs = service._supermemory_client.add.await_args.kwargs - self.assertIn("hello", kwargs["content"]) - self.assertEqual(kwargs["container_tags"], ["user-123"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py index aea9011c..ec3ccd26 100644 --- a/packages/pipecat-sdk-python/tests/test_empty_profile.py +++ b/packages/pipecat-sdk-python/tests/test_empty_profile.py @@ -120,8 +120,4 @@ class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase): "profile": {"static": [], "dynamic": []}, "search_results": [], }, - ) - service._supermemory_client.profile.assert_awaited_once() - kwargs = service._supermemory_client.profile.await_args.kwargs - self.assertEqual(kwargs["container_tag"], "new_user_123") - self.assertEqual(kwargs["q"], "Hello world") \ No newline at end of file + ) \ No newline at end of file diff --git a/packages/tools/package.json b/packages/tools/package.json index a4d0034f..74f2f5bc 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -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": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index b7e437db..1b710808 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -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, diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7d16493f..7337441a 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -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`) + }) }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index ad05c108..867c4d82 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -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 + +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 { 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() + 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 { 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 + + 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 { + 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 */ diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 49b5c88a..22257727 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -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>["results"] + results?: Awaited>["results"] count?: number error?: string } @@ -31,7 +32,7 @@ export interface ProfileResult { static: string[] dynamic: string[] } - searchResults?: Awaited> + searchResults?: Awaited>["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 { 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 { 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 { try { - await client.documents.delete(documentId) + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags + await deleteDocumentByIdentifier(client, documentId, scopeTags) return { success: true, diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts index 50a3a529..8691c92a 100644 --- a/packages/tools/src/shared/forget-memory.ts +++ b/packages/tools/src/shared/forget-memory.ts @@ -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 { 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) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 22d3bc87..c6152021 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -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) { } 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] }) }) }) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index cbb1c1a7..c0a3b540 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -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 { + 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 { + const directMatch = await getDocumentIfFound(client, documentIdentifier) + if ( + directMatch?.id === documentIdentifier && + hasContainerTagOverlap(directMatch.containerTags, containerTags) + ) { + await deleteDocumentById(client, directMatch.id) + return + } + + const candidateIds = new Set() + 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 } /** diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index bf771726..7e788c3d 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -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 } | { AND: Array } + 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 } | { AND: Array } - 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 - } - 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 - }>, + 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 { diff --git a/packages/tools/src/voltagent/types.ts b/packages/tools/src/voltagent/types.ts index cc5350ea..e6524ebc 100644 --- a/packages/tools/src/voltagent/types.ts +++ b/packages/tools/src/voltagent/types.ts @@ -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 } | { AND: Array } +export type SearchFilters = NonNullable /** * Options for including additional data in search results diff --git a/packages/ui/other/anonymous-auth.tsx b/packages/ui/other/anonymous-auth.tsx deleted file mode 100644 index 009902f9..00000000 --- a/packages/ui/other/anonymous-auth.tsx +++ /dev/null @@ -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 -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a504670..8234997a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + }, "exports": { "./*": "./*" }, diff --git a/packages/validation/api.test.ts b/packages/validation/api.test.ts index 05c18934..e186af88 100644 --- a/packages/validation/api.test.ts +++ b/packages/validation/api.test.ts @@ -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) + }) +}) diff --git a/packages/validation/api.ts b/packages/validation/api.ts index f689dbf7..f066bfcd 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -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, }), diff --git a/packages/validation/package.json b/packages/validation/package.json index ea9f5fc1..8ed19114 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -2,5 +2,8 @@ "name": "@repo/validation", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + } }