fix(python-sdks): harden v4 integrations

This commit is contained in:
ved015 2026-08-23 19:15:44 +05:30
commit 903d78e2ed
131 changed files with 5763 additions and 2343 deletions

View file

@ -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)"

View file

@ -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

View file

@ -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/

View file

@ -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

View file

@ -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/

View file

@ -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/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -52,6 +52,51 @@ export async function fetchSession(
return result.data
}
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
// instead of JWT verification. Successful lookups are cached per isolate so a
// busy MCP session doesn't re-validate on every JSON-RPC message.
const API_KEY_PATTERN = /^sm_\S{17,}$/
const API_KEY_CACHE_TTL_MS = 60_000
const API_KEY_CACHE_MAX_ENTRIES = 1000
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
export function isApiKey(token: string): boolean {
return API_KEY_PATTERN.test(token)
}
export async function validateApiKey(
token: string,
apiUrl: string,
): Promise<AuthUser | null> {
if (!isApiKey(token)) return null
const cached = apiKeyCache.get(token)
if (cached && cached.expiresAt > Date.now()) return cached.user
try {
const session = await fetchSession(token, apiUrl)
const organizationId = session.org?.id
if (!organizationId) return null
const user: AuthUser = {
userId: session.user.id,
organizationId,
bearerToken: token,
scopes: [],
}
if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear()
apiKeyCache.set(token, {
user,
expiresAt: Date.now() + API_KEY_CACHE_TTL_MS,
})
return user
} catch (error) {
console.error("API key validation error:", error)
return null
}
}
export async function validateOAuthToken(
token: string,
apiUrl: string,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,239 @@
"use client"
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
import { GradientLogo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { ArrowRight, Loader2, ShieldCheck } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { SlackMark } from "@/components/brain-connector-icons"
import { analytics } from "@/lib/analytics"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export const CHECKOUT_RETURN_PARAM = "brainTrial"
const TRIAL_DAYS = 14
/** The only reminder that lands before the charge; 15 and 17 are post-trial. */
const REMINDER_DAY = 12
const MONTHLY_PRICE = "$100"
function checkoutReturnUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete")
return url.toString()
}
function dayOffset(days: number): string {
const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
const ORBIT = [
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
{ key: "notion", r: 74, deg: 236, node: <Notion className="size-4" /> },
{ key: "drive", r: 112, deg: 58, node: <GoogleDrive className="size-4" /> },
{ key: "granola", r: 112, deg: 172, node: <Granola className="size-4" /> },
{ key: "mcp", r: 112, deg: 296, node: <MCPIcon className="size-4" /> },
]
const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]"
const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]"
function BrainPanel() {
return (
<div className="relative hidden aspect-[3/2] w-[56%] shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#0B0E13] ring-1 ring-white/[0.06] md:flex">
<span
aria-hidden="true"
className="pointer-events-none absolute size-44 rounded-full bg-[#4BA0FA]/15 blur-3xl"
/>
<div aria-hidden="true" className="relative size-[248px]">
<span className="absolute left-1/2 top-1/2 size-[148px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.07]" />
<span className="absolute left-1/2 top-1/2 size-[224px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.05]" />
<div className={cn("absolute inset-0", SPIN)}>
{ORBIT.map(({ key, r, deg, node }) => (
<span
key={key}
style={{
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${r}px)`,
}}
className="absolute left-1/2 top-1/2 flex size-8 items-center justify-center rounded-full bg-[#161B22] ring-1 ring-white/10"
>
<span
className={cn("flex", SPIN_BACK)}
style={{ rotate: `${-deg}deg` }}
>
{node}
</span>
</span>
))}
</div>
<GradientLogo className="absolute left-1/2 top-1/2 h-auto w-[68px] -translate-x-1/2 -translate-y-1/2" />
</div>
<p className="absolute inset-x-0 bottom-5 text-center text-[13px] font-medium text-[#8b8b8b]">
Meet <span className="text-[#4BA0FA]">@supermemory</span>
</p>
</div>
)
}
function TimelineRow({
date,
title,
value,
current,
}: {
date: string
title: string
value?: string
current?: boolean
}) {
return (
<li className="relative flex items-start gap-3 pl-[18px]">
<span
aria-hidden="true"
className={cn(
"absolute left-0 top-[5px] size-[7px] rounded-full",
current
? "bg-[#fafafa] ring-4 ring-[#fafafa]/10"
: "bg-[#2b3138] ring-1 ring-white/15",
)}
/>
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-[13px] font-medium text-[#fafafa]">{date}</span>
<span className="text-[12px] leading-snug text-[#8b8b8b]">
{title}
</span>
</div>
{value ? (
<span className="shrink-0 text-[14px] font-medium text-[#fafafa] tabular-nums">
{value}
</span>
) : null}
</div>
</li>
)
}
export function StepTrial({ onActive }: { onActive: () => void }) {
const [starting, setStarting] = useState(false)
const start = async () => {
if (starting) return
setStarting(true)
analytics.brainTrialCheckoutStarted()
try {
const res = await fetch(`${BACKEND}/brain/trial/start`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ successUrl: checkoutReturnUrl() }),
})
const data = (await res.json()) as {
checkoutUrl?: string | null
status?: string
error?: string
}
if (res.status === 409 || data.error === "trial_unavailable") {
throw new Error(
"This workspace has already used its free trial. Upgrade from billing to continue.",
)
}
if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.")
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl
return
}
if (data.status === "already_active" || data.status === "attached") {
onActive()
return
}
throw new Error("Couldn't start the trial.")
} catch (error) {
console.error("Failed to start trial:", error)
toast.error(
error instanceof Error ? error.message : "Couldn't start the trial.",
)
setStarting(false)
}
}
return (
<div className="flex gap-6">
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
className={cn(
dmSans125ClassName(),
"text-[22px] leading-tight font-medium text-[#fafafa]",
)}
>
Start your {TRIAL_DAYS}-day free trial
</h2>
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
Add a payment method to start. You will not be charged today. We
will email you before your first payment.
</p>
</div>
<ol className="relative flex flex-col gap-5 py-1">
<span
aria-hidden="true"
className="absolute left-[3px] top-2.5 bottom-[22px] w-px bg-white/10"
/>
<TimelineRow
date="Today"
title="Full access to Company Brain"
value="$0"
current
/>
<TimelineRow
date={dayOffset(REMINDER_DAY)}
title="We email you before the charge"
/>
<TimelineRow
date={dayOffset(TRIAL_DAYS)}
title="Trial ends"
value={`${MONTHLY_PRICE}/mo`}
/>
</ol>
<div className="flex flex-col items-center gap-3">
<Button
variant="insideOut"
onClick={start}
disabled={starting}
className="w-full justify-center rounded-full px-5 py-[11px] text-[13px] font-medium text-[#fafafa]"
>
{starting ? (
<>
Opening checkout
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Start free trial
<ArrowRight className="size-3.5" />
</>
)}
</Button>
<p className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<ShieldCheck className="size-3.5" />
Secured by Stripe · Cancel in one click
</p>
</div>
</div>
<BrainPanel />
</div>
)
}

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) {
export function SlackConnectCard() {
const isCompanyBrain = useHasCompanyBrain()
const [status, setStatus] = useState<SlackStatus | null>(null)
const [trialActive, setTrialActive] = useState(true)
const [loading, setLoading] = useState(true)
useEffect(() => {
@ -58,10 +59,16 @@ export function SlackConnectCard() {
let active = true
;(async () => {
try {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
const [slackRes, trialRes] = await Promise.all([
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }),
])
if (!active) return
if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus)
if (trialRes.ok) {
const trial = (await trialRes.json()) as { active?: boolean }
setTrialActive(Boolean(trial.active))
}
} finally {
if (active) setLoading(false)
}
@ -92,7 +99,7 @@ export function SlackConnectCard() {
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
Connected
</span>
) : (
) : trialActive ? (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
@ -100,6 +107,13 @@ export function SlackConnectCard() {
<SlackMark className="size-4" />
Add to Slack
</a>
) : (
<a
href="/onboarding"
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white/10 px-3.5 py-2 text-[13px] font-semibold text-fg-primary ring-1 ring-surface-border transition-colors hover:bg-white/15"
>
Finish setting up
</a>
)}
</div>
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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

View file

@ -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",
]

View file

@ -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

View file

@ -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,
)

View file

@ -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,

View file

@ -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:

View file

@ -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_<projectId> (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

View file

@ -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"

View file

@ -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: {

View file

@ -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")
})
})

View file

@ -7,6 +7,7 @@ export default defineConfig({
target: "es2020",
tsconfig: "./tsconfig.json",
clean: true,
hash: false,
minify: true,
dts: {
sourcemap: true,

View file

@ -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.",

View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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__":

View file

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

View file

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

View file

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

View file

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

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