mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
chore: merge latest stacked changes
This commit is contained in:
commit
8a1fae3bb7
101 changed files with 4967 additions and 1627 deletions
208
.github/workflows/ci-python.yml
vendored
208
.github/workflows/ci-python.yml
vendored
|
|
@ -9,97 +9,231 @@ on:
|
|||
- "packages/pipecat-sdk-python/**"
|
||||
- ".github/workflows/ci-python.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
|
||||
jobs:
|
||||
agent-framework-python:
|
||||
name: agent-framework-python
|
||||
name: agent-framework-python (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.13"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/agent-framework-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/agent-framework-python/pyproject.toml
|
||||
|
||||
- name: Install package and test dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio
|
||||
- name: Install build and test tools
|
||||
run: python -m pip install build pytest pytest-asyncio
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel and runtime dependencies
|
||||
run: python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: python -m pip check
|
||||
|
||||
- name: Verify installed wheel
|
||||
run: >-
|
||||
python -c "from pathlib import Path; import supermemory_agent_framework;
|
||||
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
run: python -m pytest
|
||||
|
||||
openai-sdk-python:
|
||||
name: openai-sdk-python
|
||||
name: openai-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.9"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-version: "3.50.0"
|
||||
expected-supermemory-version: "3.50.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: locked
|
||||
supermemory-version: ""
|
||||
expected-supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/openai-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install package and test dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio python-dotenv
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: "0.12.5"
|
||||
enable-cache: true
|
||||
working-directory: packages/openai-sdk-python
|
||||
cache-dependency-glob: uv.lock
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
- name: Install locked dependencies
|
||||
run: uv sync --locked --python "${{ matrix.python-version }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: uv build --wheel --out-dir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install built wheel
|
||||
run: >-
|
||||
uv pip install --python .venv/bin/python --reinstall --no-deps
|
||||
"$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Install minimum Supermemory SDK
|
||||
if: matrix.supermemory-version != ''
|
||||
run: >-
|
||||
uv pip install --python .venv/bin/python
|
||||
"supermemory==${{ matrix.supermemory-version }}"
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: uv pip check --python .venv/bin/python
|
||||
|
||||
- name: Verify installed wheel and SDK version
|
||||
run: >-
|
||||
.venv/bin/python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import supermemory_openai;
|
||||
assert version('supermemory') == '${{ matrix.expected-supermemory-version }}';
|
||||
assert 'site-packages' in Path(supermemory_openai.__file__).parts"
|
||||
|
||||
- name: Run tests without changing the verified environment
|
||||
run: .venv/bin/python -m pytest
|
||||
|
||||
cartesia-sdk-python:
|
||||
name: cartesia-sdk-python
|
||||
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-spec: "supermemory==3.16.0"
|
||||
supermemory-version: "3.16.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-supermemory
|
||||
supermemory-spec: "supermemory==3.59.0"
|
||||
supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/cartesia-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
|
||||
|
||||
# Suite stubs cartesia-line / loguru / pydantic at import time, so it
|
||||
# runs against sources with nothing installed.
|
||||
- name: Run tests
|
||||
run: PYTHONPATH=src python -m unittest discover -s tests -v
|
||||
- name: Install build tools and lightweight test dependencies
|
||||
run: >-
|
||||
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
|
||||
"${{ matrix.supermemory-spec }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel without the voice framework
|
||||
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Run tests against the installed wheel and real lightweight dependencies
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
import supermemory_cartesia;
|
||||
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
|
||||
raise SystemExit(result)"
|
||||
|
||||
pipecat-sdk-python:
|
||||
name: pipecat-sdk-python
|
||||
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-spec: "supermemory==3.16.0"
|
||||
supermemory-version: "3.16.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-supermemory
|
||||
supermemory-spec: "supermemory==3.59.0"
|
||||
supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/pipecat-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
|
||||
|
||||
# Suite stubs pipecat-ai / loguru / pydantic at import time, so it
|
||||
# runs against sources with nothing installed.
|
||||
- name: Run tests
|
||||
run: PYTHONPATH=src python -m unittest discover -s tests -v
|
||||
- name: Install build tools and lightweight test dependencies
|
||||
run: >-
|
||||
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
|
||||
"${{ matrix.supermemory-spec }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel without the voice framework
|
||||
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Run tests against the installed wheel and real lightweight dependencies
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
import supermemory_pipecat;
|
||||
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
|
||||
raise SystemExit(result)"
|
||||
|
|
|
|||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -29,5 +29,22 @@ jobs:
|
|||
- name: Run TypeScript type checking
|
||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
||||
|
||||
- name: Detect Tools package changes
|
||||
id: tools-changes
|
||||
run: |
|
||||
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run Tools unit tests
|
||||
if: steps.tools-changes.outputs.changed == 'true'
|
||||
run: bun run --cwd packages/tools test:unit
|
||||
|
||||
- name: Build Tools package
|
||||
if: steps.tools-changes.outputs.changed == 'true'
|
||||
run: bun run --cwd packages/tools build
|
||||
|
||||
- name: Run Biome CI (format & lint on changed files)
|
||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@
|
|||
{
|
||||
"group": "Other resources",
|
||||
"pages": [
|
||||
{
|
||||
"group": "General",
|
||||
"icon": "book-open",
|
||||
"pages": ["ingestion/batch-ingest-historical-data"]
|
||||
},
|
||||
{
|
||||
"group": "Benchmarking",
|
||||
"icon": "flask-conical",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal file
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "next dev --port ${PORT:-3004}",
|
||||
"build": "next build",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
apps/web/app/api/mcp-icon/route.ts
Normal file
58
apps/web/app/api/mcp-icon/route.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[]>([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
82
apps/web/components/directory/connector-card.tsx
Normal file
82
apps/web/components/directory/connector-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/web/components/directory/section-rail.tsx
Normal file
117
apps/web/components/directory/section-rail.tsx
Normal 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)]"
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'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) => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal file
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
41
apps/web/components/trial-setup-banner.tsx
Normal file
41
apps/web/components/trial-setup-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
82
apps/web/hooks/use-promo-code.ts
Normal file
82
apps/web/hooks/use-promo-code.ts
Normal 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
|
||||
}
|
||||
34
apps/web/hooks/use-trial-status.ts
Normal file
34
apps/web/hooks/use-trial-status.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
21
apps/web/lib/mcp-directory.ts
Normal file
21
apps/web/lib/mcp-directory.ts
Normal 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">
|
||||
}
|
||||
518
apps/web/lib/mcp-icon-domains.json
Normal file
518
apps/web/lib/mcp-icon-domains.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"name": "@repo/hooks",
|
||||
"version": "0.0.0",
|
||||
"private": true
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -467,10 +467,10 @@ export function MemoryGraph({
|
|||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
graphFitHeight,
|
||||
)
|
||||
},
|
||||
[nodes, containerSize.width, containerSize.height],
|
||||
[nodes, containerSize.width, graphFitHeight],
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function getMemoryBorderColor(
|
|||
if (mem.isForgotten) return colors.memBorderForgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return colors.memBorderRecent
|
||||
|
|
|
|||
|
|
@ -229,6 +229,17 @@ openai_with_memory = with_supermemory(
|
|||
|
||||
## Manual Memory Tools
|
||||
|
||||
`SupermemoryTools` exposes seven OpenAI function-calling tools:
|
||||
|
||||
- `search_memories` and `add_memory`
|
||||
- `get_profile`
|
||||
- `document_list`, `document_add`, and `document_delete`
|
||||
- `memory_forget`
|
||||
|
||||
The configured `project_id` or `container_tags` define the trusted scope. The
|
||||
primary tag is used for profile, list, search, and forget operations, and the
|
||||
model cannot select a different tag.
|
||||
|
||||
### SupermemoryTools Class
|
||||
|
||||
```python
|
||||
|
|
@ -245,8 +256,7 @@ tools = SupermemoryTools(
|
|||
# Search memories
|
||||
result = await tools.search_memories(
|
||||
information_to_get="user preferences",
|
||||
limit=10,
|
||||
include_full_docs=True
|
||||
limit=10
|
||||
)
|
||||
|
||||
# Add memory
|
||||
|
|
@ -254,24 +264,48 @@ result = await tools.add_memory(
|
|||
memory="User prefers tea over coffee"
|
||||
)
|
||||
|
||||
# Fetch specific memory
|
||||
result = await tools.fetch_memory(
|
||||
memory_id="memory-id-here"
|
||||
# Get the configured user's profile
|
||||
result = await tools.get_profile(query="favorite drinks")
|
||||
|
||||
# List, add, or delete source documents
|
||||
documents = await tools.document_list(limit=10, page=1)
|
||||
document = await tools.document_add(
|
||||
content="Meeting notes...",
|
||||
title="Weekly meeting"
|
||||
)
|
||||
deleted = await tools.document_delete(document_id="document-id-here")
|
||||
|
||||
# Soft-forget one extracted memory
|
||||
forgotten = await tools.memory_forget(
|
||||
memory_id="memory-entry-id-here",
|
||||
reason="outdated"
|
||||
)
|
||||
```
|
||||
|
||||
`include_full_docs` is retained as a deprecated Python argument for compatibility,
|
||||
but v4 search returns relevant memories and chunks instead of full source documents.
|
||||
It is no longer exposed in the OpenAI tool schema.
|
||||
|
||||
### Individual Tools
|
||||
|
||||
```python
|
||||
from supermemory_openai import (
|
||||
create_search_memories_tool,
|
||||
create_add_memory_tool,
|
||||
create_fetch_memory_tool
|
||||
create_get_profile_tool,
|
||||
create_document_list_tool,
|
||||
create_document_delete_tool,
|
||||
create_document_add_tool,
|
||||
create_memory_forget_tool,
|
||||
)
|
||||
|
||||
search_tool = create_search_memories_tool("your-api-key")
|
||||
add_tool = create_add_memory_tool("your-api-key")
|
||||
fetch_tool = create_fetch_memory_tool("your-api-key")
|
||||
profile_tool = create_get_profile_tool("your-api-key")
|
||||
list_tool = create_document_list_tool("your-api-key")
|
||||
delete_tool = create_document_delete_tool("your-api-key")
|
||||
document_add_tool = create_document_add_tool("your-api-key")
|
||||
forget_tool = create_memory_forget_tool("your-api-key")
|
||||
```
|
||||
|
||||
### Function Calling Integration
|
||||
|
|
@ -343,6 +377,11 @@ SupermemoryTools(
|
|||
- `get_tool_definitions()` - Get OpenAI function definitions
|
||||
- `search_memories()` - Search user memories
|
||||
- `add_memory()` - Add new memory
|
||||
- `get_profile()` - Get the configured user's profile
|
||||
- `document_list()` - List source document metadata
|
||||
- `document_add()` - Queue a source document for processing
|
||||
- `document_delete()` - Delete an in-scope source document
|
||||
- `memory_forget()` - Soft-forget one extracted memory
|
||||
- `execute_tool_call()` - Execute individual tool call
|
||||
|
||||
## Error Handling
|
||||
|
|
@ -408,7 +447,7 @@ Optional for testing:
|
|||
|
||||
### Required
|
||||
- `openai>=1.102.0` - Official OpenAI Python SDK
|
||||
- `supermemory>=3.1.0` - Supermemory client
|
||||
- `supermemory>=3.50.0` - Supermemory client
|
||||
- `requests>=2.25.0` - HTTP requests (fallback)
|
||||
|
||||
### Optional
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "supermemory-openai-sdk"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
description = "Memory tools for OpenAI function calling with supermemory"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
|
@ -25,7 +25,7 @@ classifiers = [
|
|||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"openai>=1.102.0",
|
||||
"supermemory>=3.16.0",
|
||||
"supermemory>=3.50.0",
|
||||
"typing-extensions>=4.0.0",
|
||||
"requests>=2.25.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
"""Forget memory via DELETE /v4/memories (not exposed on supermemory SDK v3)."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
|
||||
async def forget_memory_request(
|
||||
api_key: str,
|
||||
container_tag: str,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
"""Mark a memory as forgotten via the v4 memories endpoint."""
|
||||
payload: dict[str, str] = {"containerTag": container_tag}
|
||||
if memory_id:
|
||||
payload["id"] = memory_id
|
||||
if memory_content:
|
||||
payload["content"] = memory_content
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.delete(
|
||||
f"{base_url}/v4/memories",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
json=payload,
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise RuntimeError(
|
||||
f"Supermemory forget memory failed: {response.status} "
|
||||
f"{response.reason}. {error_text}"
|
||||
)
|
||||
except ImportError:
|
||||
import requests
|
||||
|
||||
response = requests.delete(
|
||||
f"{base_url}/v4/memories",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Supermemory forget memory failed: {response.status_code} "
|
||||
f"{response.reason}. {response.text}"
|
||||
)
|
||||
|
|
@ -222,15 +222,15 @@ async def add_memory_tool(
|
|||
) -> None:
|
||||
"""Add a new memory to the SuperMemory system."""
|
||||
try:
|
||||
add_params = {
|
||||
"content": content,
|
||||
"container_tag": container_tag,
|
||||
}
|
||||
if custom_id is not None:
|
||||
add_params["custom_id"] = custom_id
|
||||
|
||||
# Handle both sync and async supermemory clients
|
||||
result = client.add(**add_params)
|
||||
if custom_id is None:
|
||||
result = client.add(content=content, container_tag=container_tag)
|
||||
else:
|
||||
result = client.add(
|
||||
content=content,
|
||||
container_tag=container_tag,
|
||||
custom_id=custom_id,
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
response = await result
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Supermemory tools for OpenAI function calling."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
import supermemory
|
||||
from openai.types.chat import (
|
||||
|
|
@ -10,28 +11,17 @@ from openai.types.chat import (
|
|||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionToolParam,
|
||||
)
|
||||
from supermemory.types import (
|
||||
AddResponse,
|
||||
DocumentGetResponse,
|
||||
SearchMemoriesResponse,
|
||||
)
|
||||
from supermemory.types.search_memories_response import Result
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
from supermemory.types import AddResponse, SearchMemoriesResponse
|
||||
|
||||
from .exceptions import (
|
||||
SupermemoryConfigurationError,
|
||||
SupermemoryMemoryOperationError,
|
||||
SupermemoryNetworkError,
|
||||
)
|
||||
from .forget_memory import DEFAULT_BASE_URL, forget_memory_request
|
||||
from .exceptions import SupermemoryConfigurationError
|
||||
|
||||
TOOL_DESCRIPTIONS = {
|
||||
"search_memories": (
|
||||
"Search (recall) stored memories for facts, preferences, history, and context about the user "
|
||||
"or any topic. Use proactively before answering whenever memory could help — do not wait for "
|
||||
"the user to explicitly ask you to search or recall. Search when the question touches personal "
|
||||
"context, past conversations, preferences, projects, people, plans, or anything you may have "
|
||||
"learned before. Results include memory/chunk IDs — use those IDs with memory_forget to remove "
|
||||
"a specific learned fact."
|
||||
"Search stored memories and source chunks for relevant facts, preferences, "
|
||||
"history, and context. Use proactively whenever prior context could help. "
|
||||
"Hybrid results may contain either a memory or a source chunk; only an ID "
|
||||
"from a result containing a memory can be passed to memory_forget."
|
||||
),
|
||||
"add_memory": (
|
||||
"Add (remember) memories/details/information about the user or other facts or entities. "
|
||||
|
|
@ -40,19 +30,19 @@ TOOL_DESCRIPTIONS = {
|
|||
),
|
||||
"get_profile": (
|
||||
"Get user profile containing static memories (permanent facts) and dynamic memories "
|
||||
"(recent context). Optionally include search results by providing a query. "
|
||||
"Profile and search result entries may include memory IDs useful for memory_forget."
|
||||
"(recent context). Optionally include query-relevant search results. Static and dynamic "
|
||||
"profile entries are text; only memory entries in search results have forgettable IDs."
|
||||
),
|
||||
"document_list": (
|
||||
"List stored source documents (conversations, URLs, files, pasted text) with pagination. "
|
||||
"Returns document IDs for document_delete — not memory IDs for memory_forget. "
|
||||
"Use to browse raw stored content before permanently removing a source."
|
||||
"Returns document metadata and summaries, including IDs for document_delete. "
|
||||
"It does not return full source content or memory IDs."
|
||||
),
|
||||
"document_delete": (
|
||||
"Permanently delete a stored document and ALL memories extracted from it (hard delete). "
|
||||
"Use document IDs from document_list. Use when the user wants to remove an entire "
|
||||
"conversation, file, URL, or other source — not when correcting a single learned fact "
|
||||
"(use memory_forget for that)."
|
||||
"Permanently delete a stored source document and soft-forget memories extracted from it. "
|
||||
"Use a document ID from document_list when the user wants to remove an entire source. "
|
||||
"Deletion is refused for documents outside the configured scope, shared with another "
|
||||
"scope, or still processing. Use memory_forget to remove one learned fact."
|
||||
),
|
||||
"document_add": (
|
||||
"Store a source document for asynchronous processing and automatic memory extraction. "
|
||||
|
|
@ -67,10 +57,9 @@ TOOL_DESCRIPTIONS = {
|
|||
),
|
||||
"memory_forget": (
|
||||
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in "
|
||||
"profile or search. Does NOT delete source documents. Provide memory_id (preferred — from "
|
||||
"search_memories or get_profile) OR memory_content for an exact text match. Use when the "
|
||||
"user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). "
|
||||
"To remove an entire conversation or file, use document_delete instead."
|
||||
"profile or search. This does not delete source documents. Provide a memory_id from a "
|
||||
"search result containing a memory, or memory_content for an exact text match. Use "
|
||||
"document_delete to remove an entire source."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -79,19 +68,14 @@ PARAMETER_DESCRIPTIONS = {
|
|||
"What to look up in memory — keywords from the user's message, topic, entity names, or "
|
||||
"question phrasing. Search even when the user did not explicitly ask you to recall."
|
||||
),
|
||||
"include_full_docs": (
|
||||
"Whether to include the full document content in the response. "
|
||||
"Defaults to true for better AI context."
|
||||
),
|
||||
"limit": "Maximum number of results to return",
|
||||
"memory": (
|
||||
"The text content of the memory to add. This should be a single sentence or a short paragraph."
|
||||
),
|
||||
"container_tag": "Tag to filter/scope the operation (e.g., user ID, project ID)",
|
||||
"query": "Optional search query to include relevant search results",
|
||||
"page": "Page number to fetch, 1-based (default: 1)",
|
||||
"document_id": (
|
||||
"Document ID from document_list — permanently deletes the source document and all "
|
||||
"Document ID from document_list. Permanently deletes the source and soft-forgets its "
|
||||
"extracted memories. Not a profile memory ID."
|
||||
),
|
||||
"content": (
|
||||
|
|
@ -102,8 +86,8 @@ PARAMETER_DESCRIPTIONS = {
|
|||
"title": "Optional title for the document",
|
||||
"description": "Optional description for the document",
|
||||
"memory_id": (
|
||||
"Profile memory ID from search_memories or get_profile — soft-deletes one learned fact. "
|
||||
"Not a document ID."
|
||||
"Memory entry ID from a search_memories result containing a memory. Chunk and document "
|
||||
"IDs are invalid."
|
||||
),
|
||||
"memory_content": (
|
||||
"Exact text of the profile memory to forget (alternative to memory_id). Must match "
|
||||
|
|
@ -114,7 +98,6 @@ PARAMETER_DESCRIPTIONS = {
|
|||
|
||||
DEFAULT_LIMIT = 10
|
||||
DEFAULT_CHUNK_THRESHOLD = 0.6
|
||||
DEFAULT_INCLUDE_FULL_DOCS = True
|
||||
|
||||
ALL_TOOL_NAMES = (
|
||||
"search_memories",
|
||||
|
|
@ -131,6 +114,8 @@ class SupermemoryToolsConfig(TypedDict, total=False):
|
|||
"""Configuration for Supermemory tools.
|
||||
|
||||
Only one of `project_id` or `container_tags` can be provided.
|
||||
The first container tag is used for single-space operations. All configured
|
||||
tags are applied to additions and define the allowed document-delete scope.
|
||||
"""
|
||||
|
||||
base_url: Optional[str]
|
||||
|
|
@ -138,15 +123,15 @@ class SupermemoryToolsConfig(TypedDict, total=False):
|
|||
project_id: Optional[str]
|
||||
|
||||
|
||||
# Type aliases using inferred types from supermemory package
|
||||
MemoryObject = Union[DocumentGetResponse, AddResponse]
|
||||
# Type alias retained for compatibility with earlier releases.
|
||||
MemoryObject = AddResponse
|
||||
|
||||
|
||||
class MemorySearchResult(TypedDict, total=False):
|
||||
"""Result type for memory search operations."""
|
||||
|
||||
success: bool
|
||||
results: Optional[List[Result]]
|
||||
results: Optional[List[Dict[str, object]]]
|
||||
count: Optional[int]
|
||||
error: Optional[str]
|
||||
|
||||
|
|
@ -163,8 +148,8 @@ class ProfileResult(TypedDict, total=False):
|
|||
"""Result type for profile operations."""
|
||||
|
||||
success: bool
|
||||
profile: Optional[Dict[str, Any]]
|
||||
search_results: Optional[Any]
|
||||
profile: Optional[Dict[str, object]]
|
||||
search_results: Optional[Dict[str, object]]
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
|
|
@ -172,8 +157,8 @@ class DocumentListResult(TypedDict, total=False):
|
|||
"""Result type for document list operations."""
|
||||
|
||||
success: bool
|
||||
documents: Optional[List[Any]]
|
||||
pagination: Optional[Any]
|
||||
documents: Optional[List[Dict[str, object]]]
|
||||
pagination: Optional[Dict[str, object]]
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
|
|
@ -202,7 +187,7 @@ class MemoryForgetResult(TypedDict, total=False):
|
|||
|
||||
|
||||
# Function schemas for OpenAI function calling
|
||||
MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
||||
MEMORY_TOOL_SCHEMAS: Dict[str, FunctionDefinition] = {
|
||||
"search_memories": {
|
||||
"name": "search_memories",
|
||||
"description": TOOL_DESCRIPTIONS["search_memories"],
|
||||
|
|
@ -213,18 +198,16 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["information_to_get"],
|
||||
},
|
||||
"include_full_docs": {
|
||||
"type": "boolean",
|
||||
"description": PARAMETER_DESCRIPTIONS["include_full_docs"],
|
||||
"default": DEFAULT_INCLUDE_FULL_DOCS,
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["limit"],
|
||||
"default": DEFAULT_LIMIT,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["information_to_get"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"add_memory": {
|
||||
|
|
@ -239,6 +222,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["memory"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"get_profile": {
|
||||
|
|
@ -247,16 +231,13 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["query"],
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_list": {
|
||||
|
|
@ -265,21 +246,22 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["limit"],
|
||||
"default": DEFAULT_LIMIT,
|
||||
"minimum": 1,
|
||||
"maximum": 1100,
|
||||
},
|
||||
"page": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["page"],
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_delete": {
|
||||
|
|
@ -294,6 +276,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["document_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_add": {
|
||||
|
|
@ -316,6 +299,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"memory_forget": {
|
||||
|
|
@ -324,10 +308,6 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["memory_id"],
|
||||
|
|
@ -342,20 +322,28 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_container_tags(config: SupermemoryToolsConfig) -> List[str]:
|
||||
if config.get("project_id") is not None and config.get("container_tags") is not None:
|
||||
project_id = config.get("project_id")
|
||||
configured_tags = config.get("container_tags")
|
||||
|
||||
if project_id is not None and configured_tags is not None:
|
||||
raise SupermemoryConfigurationError(
|
||||
"Supermemory tools config accepts either project_id or container_tags, not both."
|
||||
)
|
||||
if config.get("project_id"):
|
||||
return [f"sm_project_{config['project_id']}"]
|
||||
if config.get("container_tags"):
|
||||
return config["container_tags"]
|
||||
if project_id:
|
||||
return [f"sm_project_{project_id}"]
|
||||
if configured_tags is not None:
|
||||
if not configured_tags or any(not tag for tag in configured_tags):
|
||||
raise SupermemoryConfigurationError(
|
||||
"container_tags must contain at least one non-empty tag."
|
||||
)
|
||||
return list(configured_tags)
|
||||
return ["sm_project_default"]
|
||||
|
||||
|
||||
|
|
@ -363,8 +351,25 @@ def _tool_definition(name: str) -> ChatCompletionToolParam:
|
|||
return {"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
|
||||
|
||||
|
||||
def _model_to_dict(value: Any) -> Dict[str, object]:
|
||||
"""Normalize generated SDK models and already-plain response values."""
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
dumped = model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
return dumped
|
||||
|
||||
raise TypeError(f"Unsupported SDK response type: {type(value).__name__}")
|
||||
|
||||
|
||||
def _all_tool_definitions() -> List[ChatCompletionFunctionToolParam]:
|
||||
return [{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} for name in ALL_TOOL_NAMES]
|
||||
return [
|
||||
{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
|
||||
for name in ALL_TOOL_NAMES
|
||||
]
|
||||
|
||||
|
||||
class SupermemoryTools:
|
||||
|
|
@ -378,18 +383,18 @@ class SupermemoryTools:
|
|||
config: Optional configuration
|
||||
"""
|
||||
config = config or {}
|
||||
self.api_key = api_key
|
||||
self.base_url = config.get("base_url") or DEFAULT_BASE_URL
|
||||
|
||||
client_kwargs = {"api_key": api_key}
|
||||
if config.get("base_url"):
|
||||
client_kwargs["base_url"] = config["base_url"]
|
||||
|
||||
self.client = supermemory.AsyncSupermemory(**client_kwargs)
|
||||
base_url = config.get("base_url")
|
||||
if base_url:
|
||||
self.client = supermemory.AsyncSupermemory(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
else:
|
||||
self.client = supermemory.AsyncSupermemory(api_key=api_key)
|
||||
self.container_tags = _resolve_container_tags(config)
|
||||
|
||||
def _primary_container_tag(self, container_tag: Optional[str] = None) -> str:
|
||||
return container_tag or self.container_tags[0]
|
||||
def _primary_container_tag(self) -> str:
|
||||
return self.container_tags[0]
|
||||
|
||||
def get_tool_definitions(self) -> List[ChatCompletionFunctionToolParam]:
|
||||
"""Get OpenAI function definitions for all memory tools."""
|
||||
|
|
@ -398,9 +403,7 @@ class SupermemoryTools:
|
|||
async def execute_tool_call(self, tool_call: ChatCompletionMessageToolCall) -> str:
|
||||
"""Execute a tool call based on the function name and arguments."""
|
||||
function_name = tool_call.function.name
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
|
||||
handlers = {
|
||||
handlers: Dict[str, Any] = {
|
||||
"search_memories": self.search_memories,
|
||||
"add_memory": self.add_memory,
|
||||
"get_profile": self.get_profile,
|
||||
|
|
@ -417,6 +420,24 @@ class SupermemoryTools:
|
|||
"error": f"Unknown function: {function_name}",
|
||||
}
|
||||
else:
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
if not isinstance(args, dict):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
parameters = MEMORY_TOOL_SCHEMAS[function_name]["parameters"] or {}
|
||||
properties = parameters.get("properties", {})
|
||||
required = parameters.get("required", [])
|
||||
if not isinstance(properties, dict) or not isinstance(required, list):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
required_names = {name for name in required if isinstance(name, str)}
|
||||
if set(args) - set(properties) or required_names - set(args):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
result = await handler(**args)
|
||||
|
||||
return json.dumps(result)
|
||||
|
|
@ -424,14 +445,26 @@ class SupermemoryTools:
|
|||
async def search_memories(
|
||||
self,
|
||||
information_to_get: str,
|
||||
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
|
||||
include_full_docs: Optional[bool] = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> MemorySearchResult:
|
||||
"""Search memories."""
|
||||
"""Search memories.
|
||||
|
||||
``include_full_docs`` remains a deprecated Python-only argument for
|
||||
source compatibility. V4 search cannot return full source documents.
|
||||
"""
|
||||
if include_full_docs is not None:
|
||||
warnings.warn(
|
||||
"include_full_docs is deprecated and ignored because v4 search "
|
||||
"does not return full source documents",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
try:
|
||||
response: SearchMemoriesResponse = await self.client.search.memories(
|
||||
q=information_to_get,
|
||||
container_tags=self.container_tags,
|
||||
container_tag=self._primary_container_tag(),
|
||||
limit=limit,
|
||||
threshold=DEFAULT_CHUNK_THRESHOLD,
|
||||
search_mode="hybrid",
|
||||
|
|
@ -479,27 +512,28 @@ class SupermemoryTools:
|
|||
|
||||
async def get_profile(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> ProfileResult:
|
||||
"""Get user profile with optional query-scoped search results."""
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tag": self._primary_container_tag(container_tag),
|
||||
}
|
||||
if query:
|
||||
kwargs["q"] = query
|
||||
|
||||
response = await self.client.profile(**kwargs)
|
||||
profile = response.profile if hasattr(response, "profile") else None
|
||||
search_results = (
|
||||
response.search_results if hasattr(response, "search_results") else None
|
||||
)
|
||||
response = await self.client.profile(
|
||||
container_tag=self._primary_container_tag(),
|
||||
q=query,
|
||||
)
|
||||
else:
|
||||
response = await self.client.profile(
|
||||
container_tag=self._primary_container_tag(),
|
||||
)
|
||||
|
||||
return ProfileResult(
|
||||
success=True,
|
||||
profile=profile if isinstance(profile, dict) else None,
|
||||
search_results=search_results,
|
||||
profile=_model_to_dict(response.profile),
|
||||
search_results=(
|
||||
_model_to_dict(response.search_results)
|
||||
if response.search_results is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except (OSError, ConnectionError) as network_error:
|
||||
return ProfileResult(
|
||||
|
|
@ -514,27 +548,24 @@ class SupermemoryTools:
|
|||
|
||||
async def document_list(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
page: Optional[int] = None,
|
||||
) -> DocumentListResult:
|
||||
"""List stored documents."""
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tags": [self._primary_container_tag(container_tag)],
|
||||
"limit": limit or DEFAULT_LIMIT,
|
||||
"container_tags": [self._primary_container_tag()],
|
||||
"limit": DEFAULT_LIMIT if limit is None else limit,
|
||||
}
|
||||
if page is not None:
|
||||
kwargs["page"] = page
|
||||
|
||||
response = await self.client.documents.list(**kwargs)
|
||||
documents = response.memories if hasattr(response, "memories") else []
|
||||
pagination = response.pagination if hasattr(response, "pagination") else None
|
||||
|
||||
return DocumentListResult(
|
||||
success=True,
|
||||
documents=documents,
|
||||
pagination=pagination,
|
||||
documents=[_model_to_dict(document) for document in response.memories],
|
||||
pagination=_model_to_dict(response.pagination),
|
||||
)
|
||||
except (OSError, ConnectionError) as network_error:
|
||||
return DocumentListResult(
|
||||
|
|
@ -550,7 +581,19 @@ class SupermemoryTools:
|
|||
async def document_delete(self, document_id: str) -> DocumentDeleteResult:
|
||||
"""Delete a document by ID."""
|
||||
try:
|
||||
await self.client.documents.delete(document_id)
|
||||
# The delete endpoint has no container-tag argument. Resolve custom IDs
|
||||
# first and refuse documents whose complete tag set is not configured.
|
||||
document = await self.client.documents.get(document_id)
|
||||
document_tags = set(document.container_tags or [])
|
||||
configured_tags = set(self.container_tags)
|
||||
|
||||
if not document_tags or not document_tags.issubset(configured_tags):
|
||||
return DocumentDeleteResult(
|
||||
success=False,
|
||||
error="Document is outside configured scope",
|
||||
)
|
||||
|
||||
await self.client.documents.delete(document.id)
|
||||
return DocumentDeleteResult(
|
||||
success=True,
|
||||
message=f"Document {document_id} deleted successfully",
|
||||
|
|
@ -605,7 +648,6 @@ class SupermemoryTools:
|
|||
|
||||
async def memory_forget(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
|
|
@ -618,14 +660,17 @@ class SupermemoryTools:
|
|||
)
|
||||
|
||||
try:
|
||||
await forget_memory_request(
|
||||
api_key=self.api_key,
|
||||
container_tag=self._primary_container_tag(container_tag),
|
||||
memory_id=memory_id,
|
||||
memory_content=memory_content,
|
||||
reason=reason,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tag": self._primary_container_tag(),
|
||||
}
|
||||
if memory_id:
|
||||
kwargs["id"] = memory_id
|
||||
if memory_content:
|
||||
kwargs["content"] = memory_content
|
||||
if reason:
|
||||
kwargs["reason"] = reason
|
||||
|
||||
await self.client.memories.forget(**kwargs)
|
||||
return MemoryForgetResult(
|
||||
success=True,
|
||||
message="Memory forgotten successfully",
|
||||
|
|
@ -691,7 +736,7 @@ class SearchMemoriesTool:
|
|||
async def execute(
|
||||
self,
|
||||
information_to_get: str,
|
||||
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
|
||||
include_full_docs: Optional[bool] = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> MemorySearchResult:
|
||||
"""Execute search memories."""
|
||||
|
|
@ -723,11 +768,10 @@ class GetProfileTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> ProfileResult:
|
||||
"""Execute get profile."""
|
||||
return await self.tools.get_profile(container_tag=container_tag, query=query)
|
||||
return await self.tools.get_profile(query=query)
|
||||
|
||||
|
||||
class DocumentListTool:
|
||||
|
|
@ -739,13 +783,11 @@ class DocumentListTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
page: Optional[int] = None,
|
||||
) -> DocumentListResult:
|
||||
"""Execute document list."""
|
||||
return await self.tools.document_list(
|
||||
container_tag=container_tag,
|
||||
limit=limit,
|
||||
page=page,
|
||||
)
|
||||
|
|
@ -793,14 +835,12 @@ class MemoryForgetTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> MemoryForgetResult:
|
||||
"""Execute memory forget."""
|
||||
return await self.tools.memory_forget(
|
||||
container_tag=container_tag,
|
||||
memory_id=memory_id,
|
||||
memory_content=memory_content,
|
||||
reason=reason,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ class TestToolDefinitions:
|
|||
assert search_tool is not None
|
||||
assert search_tool["type"] == "function"
|
||||
assert "information_to_get" in search_tool["function"]["parameters"]["required"]
|
||||
assert (
|
||||
"include_full_docs"
|
||||
not in search_tool["function"]["parameters"]["properties"]
|
||||
)
|
||||
|
||||
# Check addMemory
|
||||
add_tool = next(
|
||||
|
|
@ -200,25 +204,32 @@ class TestMemoryOperationsUnit:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_uses_search_memories_hybrid(self):
|
||||
"""search_memories must call client.search.memories with hybrid mode."""
|
||||
"""V4 search must use the primary singular tag and hybrid mode."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
tools = SupermemoryTools("test-key", {"container_tags": ["unit-tag"]})
|
||||
tools = SupermemoryTools(
|
||||
"test-key", {"container_tags": ["primary-tag", "secondary-tag"]}
|
||||
)
|
||||
tools.client.search.memories = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
results=[SimpleNamespace(model_dump=lambda: {"memory": "likes tea"})]
|
||||
)
|
||||
)
|
||||
|
||||
result = await tools.search_memories("tea", limit=3)
|
||||
with pytest.warns(DeprecationWarning, match="include_full_docs"):
|
||||
result = await tools.search_memories(
|
||||
"tea", include_full_docs=False, limit=3
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
tools.client.search.memories.assert_awaited_once()
|
||||
kwargs = tools.client.search.memories.await_args.kwargs
|
||||
assert kwargs["q"] == "tea"
|
||||
assert kwargs["container_tags"] == ["unit-tag"]
|
||||
assert kwargs["container_tag"] == "primary-tag"
|
||||
assert "container_tags" not in kwargs
|
||||
assert "include_full_docs" not in kwargs
|
||||
assert kwargs["limit"] == 3
|
||||
assert kwargs["search_mode"] == "hybrid"
|
||||
|
||||
|
|
|
|||
16
packages/openai-sdk-python/uv.lock
generated
16
packages/openai-sdk-python/uv.lock
generated
|
|
@ -377,7 +377,7 @@ resolution-markers = [
|
|||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
wheels = [
|
||||
|
|
@ -392,7 +392,7 @@ resolution-markers = [
|
|||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
|
|
@ -422,7 +422,7 @@ name = "exceptiongroup"
|
|||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
|
|
@ -1355,7 +1355,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "supermemory"
|
||||
version = "3.56.0"
|
||||
version = "3.59.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1365,14 +1365,14 @@ dependencies = [
|
|||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/43/3a7619a697554555d37254bd1e63ff2bf1262bae8ab569eba57f823af3e4/supermemory-3.56.0.tar.gz", hash = "sha256:3cceb35465e79762c2213a56d2d17b38c554924ba19fd29e24ce09701f4b377d", size = 175386, upload-time = "2026-07-24T16:29:16.695Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/83/7db28873de639f4c4ac469266a2142d1f9def545a0cac2a023011df23601/supermemory-3.59.0.tar.gz", hash = "sha256:5efd5a5a087d552b0e00e739e4eeac21379d087bdfaa1c9f5995d11272b35bb3", size = 154326, upload-time = "2026-08-14T21:28:39.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/2a/0d8ac89c73f540caa38ced4df3b824abae11481243c60f6ef750103c973d/supermemory-3.56.0-py3-none-any.whl", hash = "sha256:334e5cd1a8743ed9b90aa25ad0e63266f6376b4d44d5bdfc5cc4f15fc19d72f9", size = 156297, upload-time = "2026-07-24T16:29:15.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/73/0cf59b31317baf66b0bb9d1fa6506a54013e49fb35ffc9c12c3f3a189c5c/supermemory-3.59.0-py3-none-any.whl", hash = "sha256:3c66ae0fcb082241d8a600b4f8aaebe68159aee7cd24c2ce1aae533ea404bb2d", size = 142020, upload-time = "2026-08-14T21:28:38.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supermemory-openai-sdk"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openai" },
|
||||
|
|
@ -1402,7 +1402,7 @@ requires-dist = [
|
|||
{ name = "aiohttp", marker = "extra == 'async'", specifier = ">=3.8.0" },
|
||||
{ name = "openai", specifier = ">=1.102.0" },
|
||||
{ name = "requests", specifier = ">=2.25.0" },
|
||||
{ name = "supermemory", specifier = ">=3.16.0" },
|
||||
{ name = "supermemory", specifier = ">=3.50.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.0.0" },
|
||||
]
|
||||
provides-extras = ["async"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch --ignore-watch .turbo",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest --testTimeout 100000",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
|
||||
"test:watch": "vitest --watch --testTimeout 100000"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
DEFAULT_VALUES,
|
||||
PARAMETER_DESCRIPTIONS,
|
||||
TOOL_DESCRIPTIONS,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||
|
|
@ -56,12 +57,12 @@ export const searchMemoriesTool = (
|
|||
limit = DEFAULT_VALUES.limit,
|
||||
}) => {
|
||||
try {
|
||||
const response = await client.search({
|
||||
const response = await client.search.documents({
|
||||
q: informationToGet,
|
||||
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
|
||||
containerTags,
|
||||
limit,
|
||||
threshold: DEFAULT_VALUES.chunkThreshold,
|
||||
searchMode: "hybrid",
|
||||
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
|
||||
includeFullDocs,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -196,10 +197,12 @@ export const documentListTool = (
|
|||
}),
|
||||
execute: async ({ containerTag, limit, page }) => {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
: containerTags
|
||||
|
||||
const response = await client.documents.list({
|
||||
containerTags: [tag],
|
||||
containerTags: scopeTags,
|
||||
limit: limit || DEFAULT_VALUES.limit,
|
||||
...(page !== undefined && { page }),
|
||||
})
|
||||
|
|
@ -227,15 +230,29 @@ export const documentDeleteTool = (
|
|||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const containerTags = getContainerTags(config)
|
||||
const strict = config?.strict ?? false
|
||||
|
||||
return tool({
|
||||
description: TOOL_DESCRIPTIONS.documentDelete,
|
||||
inputSchema: z.object({
|
||||
documentId: z.string().describe(PARAMETER_DESCRIPTIONS.documentId),
|
||||
containerTag: strict
|
||||
? z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag)
|
||||
: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag),
|
||||
}),
|
||||
execute: async ({ documentId }) => {
|
||||
execute: async ({ documentId, containerTag }) => {
|
||||
try {
|
||||
await client.documents.delete(documentId)
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
: containerTags
|
||||
await deleteDocumentByIdentifier(client, documentId, scopeTags)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path
|
||||
// can be exercised deterministically without any network access. We only need
|
||||
// `client.search()` to return a single document with known multi-line content.
|
||||
const searchMock = vi.fn()
|
||||
// Mock the Supermemory SDK so the Claude memory tool's document-backed file
|
||||
// operations can be exercised deterministically without any network access.
|
||||
const documentsListMock = vi.fn()
|
||||
const documentsGetMock = vi.fn()
|
||||
const documentsDeleteBulkMock = vi.fn()
|
||||
const addMock = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = searchMock
|
||||
add = addMock
|
||||
memories = { forget: vi.fn() }
|
||||
documents = { delete: vi.fn() }
|
||||
documents = {
|
||||
list: documentsListMock,
|
||||
get: documentsGetMock,
|
||||
deleteBulk: documentsDeleteBulkMock,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -22,20 +26,58 @@ import { ClaudeMemoryTool } from "./claude-memory"
|
|||
const FILE_PATH = "/memories/notes.txt"
|
||||
// 5 distinct lines so an off-by-one at either end is observable.
|
||||
const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5"
|
||||
const FILE_DOCUMENT = {
|
||||
id: "document-notes",
|
||||
customId: "memories_notes_txt",
|
||||
filePath: FILE_PATH,
|
||||
content: FILE_CONTENT,
|
||||
}
|
||||
const NEIGHBOUR_DOCUMENT = {
|
||||
id: "document-notes-backup",
|
||||
customId: "memories_notes_backup_txt",
|
||||
filePath: "/memories/notes.backup.txt",
|
||||
content: "backup stuff",
|
||||
}
|
||||
|
||||
function mockDocuments(documents: (typeof FILE_DOCUMENT)[]) {
|
||||
documentsListMock.mockResolvedValue({
|
||||
memories: documents.map((document) => ({
|
||||
id: document.id,
|
||||
customId: document.customId,
|
||||
containerTags: ["claude_memory"],
|
||||
metadata: {
|
||||
claude_memory_type: "file",
|
||||
file_path: document.filePath,
|
||||
},
|
||||
})),
|
||||
pagination: { totalPages: 1 },
|
||||
})
|
||||
documentsGetMock.mockImplementation(async (id: string) => {
|
||||
const document = documents.find((candidate) => candidate.id === id)
|
||||
if (!document) throw new Error(`Document not found: ${id}`)
|
||||
return {
|
||||
id: document.id,
|
||||
customId: document.customId,
|
||||
containerTags: ["sm_project_default", "claude_memory"],
|
||||
metadata: {
|
||||
claude_memory_type: "file",
|
||||
file_path: document.filePath,
|
||||
},
|
||||
content: document.content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mockDocument(content: string) {
|
||||
// `readFile` matches by `id === normalizePathToCustomId(path)`.
|
||||
// normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
|
||||
searchMock.mockResolvedValue({
|
||||
results: [{ id: "memories_notes_txt", chunk: content }],
|
||||
})
|
||||
mockDocuments([{ ...FILE_DOCUMENT, content }])
|
||||
}
|
||||
|
||||
describe("ClaudeMemoryTool view_range", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchMock.mockReset()
|
||||
documentsListMock.mockReset()
|
||||
documentsGetMock.mockReset()
|
||||
mockDocument(FILE_CONTENT)
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
|
@ -90,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchMock.mockReset()
|
||||
documentsListMock.mockReset()
|
||||
documentsGetMock.mockReset()
|
||||
addMock.mockReset()
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it("view finds the exact file even when a neighbour ranks first", async () => {
|
||||
searchMock.mockResolvedValue({
|
||||
results: [
|
||||
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
|
||||
{ id: "memories_notes_txt", chunk: FILE_CONTENT },
|
||||
],
|
||||
})
|
||||
it("view finds the exact file even when a neighbour is listed first", async () => {
|
||||
mockDocuments([NEIGHBOUR_DOCUMENT, FILE_DOCUMENT])
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
|
|
@ -114,13 +152,9 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
})
|
||||
|
||||
it("view reports not-found instead of returning a different file", async () => {
|
||||
// Semantic search can surface a similarly-named file; that must not
|
||||
// The document list can contain a similarly-named file; that must not
|
||||
// be served as the requested one.
|
||||
searchMock.mockResolvedValue({
|
||||
results: [
|
||||
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
|
||||
],
|
||||
})
|
||||
mockDocuments([NEIGHBOUR_DOCUMENT])
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
|
|
@ -132,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
})
|
||||
|
||||
it("str_replace refuses to modify a different file than requested", async () => {
|
||||
searchMock.mockResolvedValue({
|
||||
results: [
|
||||
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
|
||||
],
|
||||
})
|
||||
mockDocuments([NEIGHBOUR_DOCUMENT])
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
|
|
@ -154,28 +184,29 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchMock.mockReset()
|
||||
documentsListMock.mockReset()
|
||||
documentsGetMock.mockReset()
|
||||
addMock.mockReset()
|
||||
searchMock.mockResolvedValue({
|
||||
results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }],
|
||||
})
|
||||
mockDocument(FILE_CONTENT)
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it.each(["$&", "$'", "$`", "$$"])(
|
||||
"stores %s literally instead of expanding it as a replacement pattern",
|
||||
async (dollarSequence) => {
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "line3",
|
||||
new_str: `price is ${dollarSequence} today`,
|
||||
})
|
||||
it.each([
|
||||
"$&",
|
||||
"$'",
|
||||
"$`",
|
||||
"$$",
|
||||
])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => {
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "line3",
|
||||
new_str: `price is ${dollarSequence} today`,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(addMock).toHaveBeenCalledTimes(1)
|
||||
const stored = addMock.mock.calls[0]?.[0]?.content as string
|
||||
expect(stored).toContain(`price is ${dollarSequence} today`)
|
||||
},
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
expect(addMock).toHaveBeenCalledTimes(1)
|
||||
const stored = addMock.mock.calls[0]?.[0]?.content as string
|
||||
expect(stored).toContain(`price is ${dollarSequence} today`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Supermemory from "supermemory"
|
||||
import { getContainerTags } from "./tools-shared"
|
||||
import { deleteDocumentById, getContainerTags } from "./tools-shared"
|
||||
import type { SupermemoryToolsConfig } from "./types"
|
||||
|
||||
// Claude Memory Tool Types
|
||||
|
|
@ -37,6 +37,14 @@ export interface MemoryToolResult {
|
|||
is_error: boolean
|
||||
}
|
||||
|
||||
type ClaudeFileMetadata = Record<string, string | number | boolean | string[]>
|
||||
|
||||
interface ClaudeFileDocument {
|
||||
documentId: string
|
||||
content: string
|
||||
metadata: ClaudeFileMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Memory Tool - Client-side implementation
|
||||
* Maps Claude's memory tool commands to supermemory document operations
|
||||
|
|
@ -44,6 +52,7 @@ export interface MemoryToolResult {
|
|||
export class ClaudeMemoryTool {
|
||||
private client: Supermemory
|
||||
private containerTags: string[]
|
||||
private scopeContainerTags: [string, ...string[]]
|
||||
private memoryContainerPrefix: string
|
||||
|
||||
/**
|
||||
|
|
@ -68,6 +77,7 @@ export class ClaudeMemoryTool {
|
|||
|
||||
// Get base container tags and add memory-specific tag
|
||||
const baseContainerTags = getContainerTags(config)
|
||||
this.scopeContainerTags = baseContainerTags
|
||||
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +150,7 @@ export class ClaudeMemoryTool {
|
|||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown command: ${(command as any).command}`,
|
||||
error: `Unknown command: ${(command as { command: string }).command}`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -193,44 +203,89 @@ export class ClaudeMemoryTool {
|
|||
*/
|
||||
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
|
||||
try {
|
||||
// Search for all memory files
|
||||
const response = await this.client.search({
|
||||
q: "*", // Search for all
|
||||
...(this.containerTags[0]
|
||||
? { containerTag: this.containerTags[0] }
|
||||
: {}),
|
||||
limit: 100, // Get many files (max allowed)
|
||||
searchMode: "hybrid",
|
||||
})
|
||||
// Document search returns ranked chunks, not a complete inventory. Walk
|
||||
// every page of the document-list endpoint so files cannot disappear
|
||||
// from a directory merely because they did not rank in a search page.
|
||||
const documents: Supermemory.DocumentListResponse.Memory[] = []
|
||||
let page = 1
|
||||
|
||||
if (!response.results) {
|
||||
return {
|
||||
success: true,
|
||||
content: `Directory: ${dirPath}\n(empty)`,
|
||||
}
|
||||
while (true) {
|
||||
const response = await this.client.documents.list({
|
||||
containerTags: this.scopeContainerTags,
|
||||
filters: {
|
||||
AND: [
|
||||
{ key: "claude_memory_type", value: "file" },
|
||||
{
|
||||
key: "file_path",
|
||||
value: dirPath,
|
||||
filterType: "string_contains",
|
||||
},
|
||||
],
|
||||
},
|
||||
includeContent: false,
|
||||
limit: 100,
|
||||
page,
|
||||
})
|
||||
|
||||
documents.push(...response.memories)
|
||||
|
||||
if (page >= response.pagination.totalPages) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
// Filter files that match the directory path and extract relative paths
|
||||
const files: string[] = []
|
||||
const dirs = new Set<string>()
|
||||
const candidates: Array<{
|
||||
document: Supermemory.DocumentListResponse.Memory
|
||||
filePath: string
|
||||
}> = []
|
||||
|
||||
for (const result of response.results) {
|
||||
// Get the file path from metadata (since customId is normalized)
|
||||
const filePath = result.metadata?.file_path as string
|
||||
if (!filePath || !filePath.startsWith(dirPath)) continue
|
||||
for (const document of documents) {
|
||||
if (!this.isDocumentInConfiguredScope(document)) continue
|
||||
|
||||
// Get relative path from directory
|
||||
const relativePath = filePath.substring(dirPath.length)
|
||||
if (!relativePath) continue
|
||||
const filePath = this.getDocumentFilePath(document)
|
||||
if (!filePath || !filePath.startsWith(dirPath)) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ document, filePath })
|
||||
}
|
||||
|
||||
// If path contains /, it's in a subdirectory
|
||||
const slashIndex = relativePath.indexOf("/")
|
||||
if (slashIndex > 0) {
|
||||
// It's a subdirectory
|
||||
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
|
||||
} else if (relativePath !== "") {
|
||||
// It's a file in this directory
|
||||
files.push(relativePath)
|
||||
// Full GETs are required to verify hidden project tags. Keep them bounded
|
||||
// so large directories do not become a long serial chain or a burst of
|
||||
// unbounded requests.
|
||||
const verificationBatchSize = 8
|
||||
for (
|
||||
let index = 0;
|
||||
index < candidates.length;
|
||||
index += verificationBatchSize
|
||||
) {
|
||||
const batch = candidates.slice(index, index + verificationBatchSize)
|
||||
const verified = await Promise.all(
|
||||
batch.map(async (candidate) =>
|
||||
(await this.isDirectoryDocumentInExactScope(candidate.document))
|
||||
? candidate
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
|
||||
for (const candidate of verified) {
|
||||
if (!candidate) continue
|
||||
const { filePath } = candidate
|
||||
|
||||
// Get relative path from directory
|
||||
const relativePath = filePath.substring(dirPath.length)
|
||||
if (!relativePath) continue
|
||||
|
||||
// If path contains /, it's in a subdirectory
|
||||
const slashIndex = relativePath.indexOf("/")
|
||||
if (slashIndex > 0) {
|
||||
// It's a subdirectory
|
||||
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
|
||||
} else if (relativePath !== "") {
|
||||
// It's a file in this directory
|
||||
files.push(relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,10 +319,8 @@ export class ClaudeMemoryTool {
|
|||
viewRange?: [number, number],
|
||||
): Promise<MemoryResponse> {
|
||||
try {
|
||||
// Same lookup as every mutating command: limit 5 so the exact
|
||||
// customId match is findable among semantic near-neighbours.
|
||||
// With the old limit of 1, a similarly-named file ranking first
|
||||
// made this return the wrong file's contents as a success.
|
||||
// Resolve the exact document inside the configured scope so reads and
|
||||
// mutations use the complete stored file, not one ranked search chunk.
|
||||
const readResult = await this.getFileDocument(filePath)
|
||||
if (!readResult.success || !readResult.document) {
|
||||
return {
|
||||
|
|
@ -278,7 +331,7 @@ export class ClaudeMemoryTool {
|
|||
|
||||
const document = readResult.document
|
||||
|
||||
let content: string = document.raw || document.content || ""
|
||||
let content = document.content
|
||||
|
||||
// Apply line range if specified
|
||||
if (viewRange) {
|
||||
|
|
@ -376,8 +429,7 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
const originalContent =
|
||||
readResult.document.raw || readResult.document.content || ""
|
||||
const originalContent = readResult.document.content
|
||||
|
||||
// Check if old_str exists in the content
|
||||
if (!originalContent.includes(oldStr)) {
|
||||
|
|
@ -435,8 +487,7 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
const originalContent =
|
||||
readResult.document.raw || readResult.document.content || ""
|
||||
const originalContent = readResult.document.content
|
||||
const lines = originalContent.split("\n")
|
||||
|
||||
// Validate line number
|
||||
|
|
@ -490,9 +541,7 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
const documentId =
|
||||
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
|
||||
await this.client.documents.delete(documentId)
|
||||
await deleteDocumentById(this.client, readResult.document.documentId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -531,8 +580,7 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
const originalContent =
|
||||
readResult.document.raw || readResult.document.content || ""
|
||||
const originalContent = readResult.document.content
|
||||
const newNormalizedId = this.normalizePathToCustomId(newPath)
|
||||
|
||||
// Create new document with new path
|
||||
|
|
@ -552,8 +600,7 @@ export class ClaudeMemoryTool {
|
|||
// customId — the add above already replaced the content.
|
||||
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
|
||||
if (oldNormalizedId !== newNormalizedId) {
|
||||
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
|
||||
await this.client.documents.delete(oldDocumentId)
|
||||
await deleteDocumentById(this.client, readResult.document.documentId)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -573,48 +620,124 @@ export class ClaudeMemoryTool {
|
|||
*/
|
||||
private async getFileDocument(filePath: string): Promise<{
|
||||
success: boolean
|
||||
document?: any
|
||||
document?: ClaudeFileDocument
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
let page = 1
|
||||
const candidates = new Map<
|
||||
string,
|
||||
Supermemory.DocumentListResponse.Memory
|
||||
>()
|
||||
|
||||
const response = await this.client.search({
|
||||
q: normalizedId,
|
||||
...(this.containerTags[0]
|
||||
? { containerTag: this.containerTags[0] }
|
||||
: {}),
|
||||
limit: 5,
|
||||
searchMode: "hybrid",
|
||||
})
|
||||
// customId values are only unique within an exact container-tag set in
|
||||
// Mono. Resolve the matching document inside this tool's configured
|
||||
// scope before fetching by internal ID; a direct get(customId) can pick
|
||||
// another project/user's same-named file.
|
||||
while (true) {
|
||||
const response = await this.client.documents.list({
|
||||
containerTags: this.scopeContainerTags,
|
||||
filters: {
|
||||
AND: [
|
||||
{ key: "claude_memory_type", value: "file" },
|
||||
{ key: "file_path", value: filePath },
|
||||
],
|
||||
},
|
||||
includeContent: false,
|
||||
limit: 100,
|
||||
page,
|
||||
})
|
||||
|
||||
// Only accept the exact customId match. Falling back to the top
|
||||
// semantic hit would let callers read — and worse, modify or
|
||||
// delete — a different file than the one they asked for.
|
||||
const match = response.results?.find(
|
||||
(r) =>
|
||||
r.id === normalizedId ||
|
||||
r.documents?.some((d) => d.id === normalizedId),
|
||||
)
|
||||
for (const document of response.memories) {
|
||||
if (
|
||||
document.customId === normalizedId &&
|
||||
this.getDocumentFilePath(document) === filePath &&
|
||||
this.isDocumentInConfiguredScope(document)
|
||||
) {
|
||||
candidates.set(document.id, document)
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
if (page >= response.pagination.totalPages) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
const exactMatches: Array<{
|
||||
candidate: Supermemory.DocumentListResponse.Memory
|
||||
document: Supermemory.DocumentGetResponse
|
||||
}> = []
|
||||
let hasUnverifiedCandidate = false
|
||||
for (const candidate of candidates.values()) {
|
||||
let document: Supermemory.DocumentGetResponse
|
||||
try {
|
||||
document = await this.client.documents.get(candidate.id)
|
||||
} catch (error) {
|
||||
if (error instanceof Supermemory.NotFoundError) continue
|
||||
throw error
|
||||
}
|
||||
|
||||
if (document.id !== candidate.id) {
|
||||
hasUnverifiedCandidate = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
document.customId !== normalizedId ||
|
||||
this.getDocumentFilePath(document) !== filePath ||
|
||||
!this.hasExactContainerTags(document.containerTags)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
exactMatches.push({ candidate, document })
|
||||
}
|
||||
|
||||
if (exactMatches.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
}
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File path is ambiguous in the configured container scope: ${filePath}`,
|
||||
}
|
||||
}
|
||||
if (hasUnverifiedCandidate) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File path could not be resolved unambiguously in the configured container scope: ${filePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
const content = match.chunk || match.memory || ""
|
||||
const documentId = match.documents?.[0]?.id ?? match.id
|
||||
const match = exactMatches[0]
|
||||
if (!match) {
|
||||
return { success: false, error: `File not found: ${filePath}` }
|
||||
}
|
||||
const { candidate, document } = match
|
||||
const content =
|
||||
typeof document.content === "string"
|
||||
? document.content
|
||||
: typeof document.raw === "string"
|
||||
? document.raw
|
||||
: undefined
|
||||
if (content === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File content unavailable: ${filePath}`,
|
||||
}
|
||||
}
|
||||
const metadata =
|
||||
document.metadata &&
|
||||
typeof document.metadata === "object" &&
|
||||
!Array.isArray(document.metadata)
|
||||
? (document.metadata as ClaudeFileMetadata)
|
||||
: {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
document: {
|
||||
documentId,
|
||||
content,
|
||||
raw: content,
|
||||
metadata: match.metadata,
|
||||
},
|
||||
document: { documentId: candidate.id, content, metadata },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -624,6 +747,60 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
private getDocumentFilePath(document: {
|
||||
metadata: unknown
|
||||
}): string | undefined {
|
||||
const metadata = document.metadata
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const metadataRecord = metadata as Record<string, unknown>
|
||||
|
||||
return typeof metadataRecord.file_path === "string"
|
||||
? metadataRecord.file_path
|
||||
: undefined
|
||||
}
|
||||
|
||||
private isDocumentInConfiguredScope(
|
||||
document: Supermemory.DocumentListResponse.Memory,
|
||||
): boolean {
|
||||
const documentTags = document.containerTags ?? []
|
||||
const expectedTags = this.containerTags.filter(
|
||||
(tag) => !tag.startsWith("sm_project_"),
|
||||
)
|
||||
|
||||
return (
|
||||
documentTags.length === expectedTags.length &&
|
||||
documentTags.every((tag, index) => tag === expectedTags[index])
|
||||
)
|
||||
}
|
||||
|
||||
private async isDirectoryDocumentInExactScope(
|
||||
document: Supermemory.DocumentListResponse.Memory,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Mono strips internal project tags from every list response, so only a
|
||||
// full get can prove that no hidden tags change this document's scope.
|
||||
const fullDocument = await this.client.documents.get(document.id)
|
||||
return (
|
||||
fullDocument.id === document.id &&
|
||||
this.hasExactContainerTags(fullDocument.containerTags)
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Supermemory.NotFoundError)) throw error
|
||||
// A document can disappear between list and get. Skip stale entries
|
||||
// instead of failing the entire directory view.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private hasExactContainerTags(containerTags?: string[]): boolean {
|
||||
return (
|
||||
containerTags?.length === this.containerTags.length &&
|
||||
containerTags.every((tag, index) => tag === this.containerTags[index])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that path starts with /memories for security
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
DEFAULT_VALUES,
|
||||
PARAMETER_DESCRIPTIONS,
|
||||
TOOL_DESCRIPTIONS,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
} from "../tools-shared"
|
||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||
|
|
@ -14,7 +15,7 @@ import type { SupermemoryToolsConfig } from "../types"
|
|||
*/
|
||||
export interface MemorySearchResult {
|
||||
success: boolean
|
||||
results?: Awaited<ReturnType<Supermemory["search"]>>["results"]
|
||||
results?: Awaited<ReturnType<Supermemory["search"]["documents"]>>["results"]
|
||||
count?: number
|
||||
error?: string
|
||||
}
|
||||
|
|
@ -31,7 +32,7 @@ export interface ProfileResult {
|
|||
static: string[]
|
||||
dynamic: string[]
|
||||
}
|
||||
searchResults?: Awaited<ReturnType<Supermemory["search"]>>
|
||||
searchResults?: Awaited<ReturnType<Supermemory["profile"]>>["searchResults"]
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +160,10 @@ export const memoryToolSchemas = {
|
|||
type: "string",
|
||||
description: PARAMETER_DESCRIPTIONS.documentId,
|
||||
},
|
||||
containerTag: {
|
||||
type: "string",
|
||||
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
|
||||
},
|
||||
},
|
||||
required: ["documentId"],
|
||||
},
|
||||
|
|
@ -248,12 +253,12 @@ export function createSearchMemoriesFunction(
|
|||
limit?: number
|
||||
}): Promise<MemorySearchResult> {
|
||||
try {
|
||||
const response = await client.search({
|
||||
const response = await client.search.documents({
|
||||
q: informationToGet,
|
||||
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
|
||||
containerTags,
|
||||
limit,
|
||||
threshold: DEFAULT_VALUES.chunkThreshold,
|
||||
searchMode: "hybrid",
|
||||
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
|
||||
includeFullDocs,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -363,10 +368,12 @@ export function createDocumentListFunction(
|
|||
page?: number
|
||||
}): Promise<DocumentListResult> {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
: containerTags
|
||||
|
||||
const response = await client.documents.list({
|
||||
containerTags: [tag],
|
||||
containerTags: scopeTags,
|
||||
limit: limit || DEFAULT_VALUES.limit,
|
||||
...(page !== undefined && { page }),
|
||||
})
|
||||
|
|
@ -392,15 +399,20 @@ export function createDocumentDeleteFunction(
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) {
|
||||
const { client } = createClient(apiKey, config)
|
||||
const { client, containerTags } = createClient(apiKey, config)
|
||||
|
||||
return async function documentDelete({
|
||||
documentId,
|
||||
containerTag,
|
||||
}: {
|
||||
documentId: string
|
||||
containerTag?: string
|
||||
}): Promise<DocumentDeleteResult> {
|
||||
try {
|
||||
await client.documents.delete(documentId)
|
||||
const scopeTags: [string, ...string[]] = containerTag
|
||||
? [containerTag]
|
||||
: containerTags
|
||||
await deleteDocumentByIdentifier(client, documentId, scopeTags)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export interface ForgetMemoryParams {
|
||||
containerTag: string
|
||||
|
|
@ -7,6 +8,10 @@ export interface ForgetMemoryParams {
|
|||
reason?: string
|
||||
}
|
||||
|
||||
export interface ForgetMemoryRequestOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a memory as forgotten via `DELETE /v4/memories`.
|
||||
*
|
||||
|
|
@ -19,6 +24,7 @@ export async function forgetMemoryRequest(
|
|||
apiKey: string,
|
||||
params: ForgetMemoryParams,
|
||||
baseUrl: string = DEFAULT_BASE_URL,
|
||||
options?: ForgetMemoryRequestOptions,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/v4/memories`, {
|
||||
method: "DELETE",
|
||||
|
|
@ -27,6 +33,7 @@ export async function forgetMemoryRequest(
|
|||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
|
||||
// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool
|
||||
// executions can be verified deterministically without network access.
|
||||
const documentsDelete = vi.fn()
|
||||
const documentsDeleteBulk = vi.fn()
|
||||
const documentsGet = vi.fn()
|
||||
const documentsList = vi.fn()
|
||||
const searchMock = vi.fn()
|
||||
const clientAdd = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = searchMock
|
||||
add = clientAdd
|
||||
documents = {
|
||||
delete: documentsDelete,
|
||||
deleteBulk: documentsDeleteBulk,
|
||||
get: documentsGet,
|
||||
list: documentsList,
|
||||
add: vi.fn(),
|
||||
}
|
||||
|
|
@ -35,12 +35,20 @@ function executeTool(tool: unknown, args: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
documentsDelete.mockReset().mockResolvedValue(undefined)
|
||||
documentsDeleteBulk.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
deletedCount: 1,
|
||||
errors: [],
|
||||
})
|
||||
documentsGet.mockReset().mockResolvedValue({
|
||||
id: "doc_123",
|
||||
customId: "doc_123",
|
||||
containerTags: ["sm_project_default"],
|
||||
})
|
||||
documentsList.mockReset().mockResolvedValue({
|
||||
memories: [{ id: "doc_1", title: "Doc one" }],
|
||||
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
|
||||
})
|
||||
searchMock.mockReset()
|
||||
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
|
@ -53,7 +61,8 @@ describe("documentDelete", () => {
|
|||
}
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsDelete).toHaveBeenCalledWith("doc_123")
|
||||
expect(documentsGet).toHaveBeenCalledWith("doc_123")
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -109,6 +118,22 @@ describe("memoryForget", () => {
|
|||
id: "mem_1",
|
||||
reason: "outdated",
|
||||
})
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it("uses a caller-provided signal instead of creating a timeout", async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const controller = new AbortController()
|
||||
|
||||
await forgetMemoryRequest(
|
||||
API_KEY,
|
||||
{ containerTag: "user_1", id: "mem_1" },
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(init.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it("throws a descriptive error on non-2xx responses", async () => {
|
||||
|
|
@ -161,16 +186,30 @@ describe("memoryForget", () => {
|
|||
describe("ClaudeMemoryTool", () => {
|
||||
const FILE_PATH = "/memories/prefs.txt"
|
||||
const CUSTOM_ID = "memories_prefs_txt"
|
||||
const DOCUMENT_ID = "doc_file_1"
|
||||
|
||||
function mockFileDocument(content: string) {
|
||||
searchMock.mockResolvedValue({
|
||||
results: [
|
||||
const metadata = {
|
||||
claude_memory_type: "file",
|
||||
file_path: FILE_PATH,
|
||||
}
|
||||
documentsList.mockResolvedValue({
|
||||
memories: [
|
||||
{
|
||||
id: CUSTOM_ID,
|
||||
chunk: content,
|
||||
metadata: { file_path: FILE_PATH },
|
||||
id: DOCUMENT_ID,
|
||||
customId: CUSTOM_ID,
|
||||
containerTags: ["claude_memory"],
|
||||
metadata,
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
|
||||
})
|
||||
documentsGet.mockResolvedValue({
|
||||
id: DOCUMENT_ID,
|
||||
customId: CUSTOM_ID,
|
||||
containerTags: ["sm_project_default", "claude_memory"],
|
||||
content,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +270,7 @@ describe("ClaudeMemoryTool", () => {
|
|||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
|
||||
})
|
||||
|
||||
it("rename removes the old document after creating the new one", async () => {
|
||||
|
|
@ -248,6 +287,6 @@ describe("ClaudeMemoryTool", () => {
|
|||
expect(clientAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customId: "memories_renamed_txt" }),
|
||||
)
|
||||
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,49 +2,53 @@
|
|||
* Shared constants and descriptions for Supermemory tools
|
||||
*/
|
||||
|
||||
import type Supermemory from "supermemory"
|
||||
import type { MemoryMode } from "./shared/types"
|
||||
|
||||
// Tool descriptions
|
||||
export const TOOL_DESCRIPTIONS = {
|
||||
searchMemories:
|
||||
"Search (recall) stored memories for facts, preferences, history, and context about the user or any topic. Use proactively before answering whenever memory could help — do not wait for the user to explicitly ask you to search or recall. Search when the question touches personal context, past conversations, preferences, projects, people, plans, or anything you may have learned before. Results include memory/chunk IDs — use those IDs with memoryForget to remove a specific learned fact.",
|
||||
"Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.",
|
||||
addMemory:
|
||||
"Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.",
|
||||
getProfile:
|
||||
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query. Profile and search result entries may include memory IDs useful for memoryForget.",
|
||||
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.",
|
||||
documentList:
|
||||
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.",
|
||||
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.",
|
||||
documentDelete:
|
||||
"Permanently delete a stored document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).",
|
||||
"Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.",
|
||||
documentAdd:
|
||||
"Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.",
|
||||
memoryForget:
|
||||
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId (preferred — from searchMemories or getProfile) OR memoryContent for an exact text match. Use when the user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). To remove an entire conversation or file, use documentDelete instead.",
|
||||
"Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.",
|
||||
} as const
|
||||
|
||||
// Parameter descriptions
|
||||
export const PARAMETER_DESCRIPTIONS = {
|
||||
informationToGet:
|
||||
"What to look up in memory — keywords from the user's message, topic, entity names, or question phrasing. Search even when the user did not explicitly ask you to recall.",
|
||||
"What to look up in stored context — keywords from the user's message, topic, entity names, or question phrasing.",
|
||||
includeFullDocs:
|
||||
"Whether to include the full document content in the response. Defaults to true for better AI context.",
|
||||
limit: "Maximum number of results to return",
|
||||
memory:
|
||||
"The text content of the memory to add. This should be a single sentence or a short paragraph.",
|
||||
containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)",
|
||||
documentContainerTag:
|
||||
"Optional one-tag scope override. When deleting a document returned by documentList with a containerTag override, pass the same value here. In strict mode, pass null to use the configured union.",
|
||||
query: "Optional search query to include relevant search results",
|
||||
page: "Page number to fetch, 1-based (default: 1)",
|
||||
documentId:
|
||||
"Document ID from documentList — permanently deletes the source document and all extracted memories. Not a profile memory ID.",
|
||||
"Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.",
|
||||
content:
|
||||
"Document body to store — plain text, a conversation transcript, a long pasted blob, or a URL to a webpage/PDF/image/video. Content is queued and memories are extracted automatically after background processing; do not split into addMemory calls.",
|
||||
title: "Optional title for the document",
|
||||
description: "Optional description for the document",
|
||||
memoryId:
|
||||
"Profile memory ID from searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.",
|
||||
"Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.",
|
||||
memoryContent:
|
||||
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, search first and use memoryId.",
|
||||
reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)",
|
||||
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.",
|
||||
reason:
|
||||
"Optional reason recorded when forgetting (e.g. outdated, user correction)",
|
||||
} as const
|
||||
|
||||
// Default values
|
||||
|
|
@ -57,7 +61,7 @@ export const DEFAULT_VALUES = {
|
|||
// Container tag constants
|
||||
export const CONTAINER_TAG_CONSTANTS = {
|
||||
projectPrefix: "sm_project_",
|
||||
defaultTags: ["sm_project_default"] as string[],
|
||||
defaultTags: ["sm_project_default"] as const,
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
|
@ -66,16 +70,171 @@ export const CONTAINER_TAG_CONSTANTS = {
|
|||
export function getContainerTags(config?: {
|
||||
projectId?: string
|
||||
containerTags?: string[]
|
||||
}): string[] {
|
||||
}): [string, ...string[]] {
|
||||
if (config?.projectId !== undefined && config.containerTags !== undefined) {
|
||||
throw new Error(
|
||||
"Supermemory tools config accepts either projectId or containerTags, not both.",
|
||||
)
|
||||
}
|
||||
if (config?.projectId) {
|
||||
if (config?.projectId !== undefined) {
|
||||
if (config.projectId.trim() === "") {
|
||||
throw new Error(
|
||||
"Supermemory tools config requires a non-empty projectId.",
|
||||
)
|
||||
}
|
||||
return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`]
|
||||
}
|
||||
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
|
||||
if (config?.containerTags !== undefined) {
|
||||
const [firstTag, ...remainingTags] = config.containerTags
|
||||
if (
|
||||
firstTag === undefined ||
|
||||
config.containerTags.some((tag) => tag.trim() === "")
|
||||
) {
|
||||
throw new Error(
|
||||
"Supermemory tools config requires at least one non-empty containerTag.",
|
||||
)
|
||||
}
|
||||
return [firstTag, ...remainingTags]
|
||||
}
|
||||
return [...CONTAINER_TAG_CONSTANTS.defaultTags]
|
||||
}
|
||||
|
||||
/** Delete exactly one document by its internal ID. */
|
||||
export async function deleteDocumentById(
|
||||
client: Supermemory,
|
||||
documentId: string,
|
||||
): Promise<void> {
|
||||
const response = await client.documents.deleteBulk({ ids: [documentId] })
|
||||
if (response.success && response.deletedCount === 1) return
|
||||
|
||||
const detail = response.errors?.find(
|
||||
(error) => error.id === documentId,
|
||||
)?.error
|
||||
throw new Error(
|
||||
detail
|
||||
? `Failed to delete document ${documentId}: ${detail}`
|
||||
: `Failed to delete document ${documentId}: expected one deletion, received ${response.deletedCount}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an internal ID or customId inside the effective container-tag union,
|
||||
* then delete the exact internal document ID. Internal IDs take precedence over
|
||||
* customId matches.
|
||||
*/
|
||||
export async function deleteDocumentByIdentifier(
|
||||
client: Supermemory,
|
||||
documentIdentifier: string,
|
||||
containerTags: readonly [string, ...string[]],
|
||||
): Promise<void> {
|
||||
const directMatch = await getDocumentIfFound(client, documentIdentifier)
|
||||
if (
|
||||
directMatch?.id === documentIdentifier &&
|
||||
hasContainerTagOverlap(directMatch.containerTags, containerTags)
|
||||
) {
|
||||
await deleteDocumentById(client, directMatch.id)
|
||||
return
|
||||
}
|
||||
|
||||
const candidateIds = new Set<string>()
|
||||
let hasInternalIdCandidate = false
|
||||
let page = 1
|
||||
while (true) {
|
||||
const response = await client.documents.list({
|
||||
containerTags: [...containerTags],
|
||||
includeContent: false,
|
||||
limit: 100,
|
||||
page,
|
||||
})
|
||||
for (const document of response.memories) {
|
||||
if (document.id === documentIdentifier) {
|
||||
hasInternalIdCandidate = true
|
||||
}
|
||||
if (
|
||||
document.id === documentIdentifier ||
|
||||
document.customId === documentIdentifier
|
||||
) {
|
||||
candidateIds.add(document.id)
|
||||
}
|
||||
}
|
||||
if (page >= response.pagination.totalPages) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
let exactIdMatch: string | undefined
|
||||
let hasUnverifiedCandidate = false
|
||||
const customIdMatches: string[] = []
|
||||
for (const candidateId of candidateIds) {
|
||||
const document = await getDocumentIfFound(client, candidateId)
|
||||
if (document?.id !== candidateId) {
|
||||
hasUnverifiedCandidate = true
|
||||
continue
|
||||
}
|
||||
if (!hasContainerTagOverlap(document.containerTags, containerTags)) {
|
||||
continue
|
||||
}
|
||||
if (document.id === documentIdentifier) {
|
||||
exactIdMatch = document.id
|
||||
break
|
||||
}
|
||||
if (document.customId === documentIdentifier) {
|
||||
customIdMatches.push(document.id)
|
||||
} else {
|
||||
hasUnverifiedCandidate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (exactIdMatch) {
|
||||
await deleteDocumentById(client, exactIdMatch)
|
||||
return
|
||||
}
|
||||
if (hasInternalIdCandidate) {
|
||||
throw new Error(
|
||||
`Document ID ${documentIdentifier} could not be verified safely in the configured container scope.`,
|
||||
)
|
||||
}
|
||||
if (hasUnverifiedCandidate) {
|
||||
throw new Error(
|
||||
`Document identifier ${documentIdentifier} could not be resolved unambiguously in the configured container scope.`,
|
||||
)
|
||||
}
|
||||
if (customIdMatches.length === 1) {
|
||||
await deleteDocumentById(client, customIdMatches[0] as string)
|
||||
return
|
||||
}
|
||||
if (customIdMatches.length > 1) {
|
||||
throw new Error(
|
||||
`Document customId ${documentIdentifier} is ambiguous in the configured container scope.`,
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Document ${documentIdentifier} was not found in the configured container scope.`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getDocumentIfFound(client: Supermemory, documentId: string) {
|
||||
try {
|
||||
return await client.documents.get(documentId)
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 404
|
||||
)
|
||||
}
|
||||
|
||||
function hasContainerTagOverlap(
|
||||
actual: string[] | undefined,
|
||||
expected: readonly string[],
|
||||
): boolean {
|
||||
return actual?.some((tag) => expected.includes(tag)) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import {
|
|||
type Logger,
|
||||
type MemoryMode,
|
||||
} from "../shared"
|
||||
import type { SupermemoryVoltAgent, VoltAgentMessage } from "./types"
|
||||
import type {
|
||||
SearchFilters,
|
||||
SupermemoryVoltAgent,
|
||||
VoltAgentMessage,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Context for Supermemory middleware operations.
|
||||
|
|
@ -47,7 +51,7 @@ export interface SupermemoryMiddlewareContext {
|
|||
limit?: number
|
||||
rerank?: boolean
|
||||
rewriteQuery?: boolean
|
||||
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
|
||||
filters?: SearchFilters
|
||||
include?: {
|
||||
chunks?: boolean
|
||||
documents?: boolean
|
||||
|
|
@ -258,23 +262,7 @@ export const enhanceMessagesWithMemories = async (
|
|||
if (useAdvancedSearch && ctx.mode !== "profile") {
|
||||
ctx.logger.info("Using advanced search with custom parameters")
|
||||
|
||||
const searchParams: {
|
||||
q: string
|
||||
containerTag: string
|
||||
threshold?: number
|
||||
limit?: number
|
||||
rerank?: boolean
|
||||
rewriteQuery?: boolean
|
||||
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
|
||||
include?: {
|
||||
chunks?: boolean
|
||||
documents?: boolean
|
||||
forgottenMemories?: boolean
|
||||
relatedMemories?: boolean
|
||||
summaries?: boolean
|
||||
}
|
||||
searchMode?: "memories" | "documents" | "hybrid"
|
||||
} = {
|
||||
const searchParams: Supermemory.SearchParams = {
|
||||
q: queryText,
|
||||
containerTag: ctx.containerTag,
|
||||
}
|
||||
|
|
@ -288,31 +276,32 @@ export const enhanceMessagesWithMemories = async (
|
|||
if (ctx.include !== undefined) searchParams.include = ctx.include
|
||||
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
|
||||
|
||||
const response = await ctx.client.search.memories(searchParams)
|
||||
const response = await ctx.client.search(searchParams)
|
||||
|
||||
// Hybrid search returns both memory entries (`memory` field) and
|
||||
// document chunks (`chunk` field). Handle both.
|
||||
type SearchResult = {
|
||||
memory?: string
|
||||
chunk?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
const formattedMemories = response.results
|
||||
.map((result: SearchResult) => {
|
||||
const text = result.memory || result.chunk
|
||||
return text ? `- ${text}` : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
// document chunks (`chunk` field). Normalize both for prompt templates.
|
||||
const searchResults = response.results.flatMap((result) => {
|
||||
const memory = result.memory ?? result.chunk
|
||||
if (!memory) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
memory,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
const formattedMemories = searchResults
|
||||
.map((result) => `- ${result.memory}`)
|
||||
.join("\n")
|
||||
|
||||
memories = ctx.promptTemplate
|
||||
? ctx.promptTemplate({
|
||||
userMemories: "",
|
||||
generalSearchMemories: formattedMemories,
|
||||
searchResults: response.results as Array<{
|
||||
memory: string
|
||||
metadata?: Record<string, unknown>
|
||||
}>,
|
||||
searchResults,
|
||||
})
|
||||
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* Supermemory by providing hooks that inject memories before LLM calls.
|
||||
*/
|
||||
|
||||
import type Supermemory from "supermemory"
|
||||
import type {
|
||||
PromptTemplate,
|
||||
MemoryMode,
|
||||
|
|
@ -58,7 +59,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
|
|||
|
||||
/**
|
||||
* Advanced filters to apply to the search using AND/OR logic.
|
||||
* Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] }
|
||||
* Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] }
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
|
|
@ -99,7 +100,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
|
|||
/**
|
||||
* Advanced search filters using AND/OR logic
|
||||
*/
|
||||
export type SearchFilters = { OR: Array<unknown> } | { AND: Array<unknown> }
|
||||
export type SearchFilters = NonNullable<Supermemory.SearchParams["filters"]>
|
||||
|
||||
/**
|
||||
* Options for including additional data in search results
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export const AnonymousAuth = ({
|
||||
dashboardPath = "/dashboard",
|
||||
loginPath = "/login",
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const createAnonymousSession = async () => {
|
||||
const session = await authClient.getSession()
|
||||
|
||||
if (!session?.session) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No session found, creating anonymous session...",
|
||||
)
|
||||
|
||||
try {
|
||||
// Create anonymous session
|
||||
console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
|
||||
const res = await authClient.signIn.anonymous()
|
||||
|
||||
if (!res.token) {
|
||||
throw new Error("Failed to get anonymous token")
|
||||
}
|
||||
|
||||
// Get the new session
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Getting new session with anonymous token...",
|
||||
)
|
||||
const newSession = await authClient.getSession()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
|
||||
|
||||
if (!newSession?.session || !newSession?.user) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
|
||||
)
|
||||
throw new Error("Failed to create anonymous session")
|
||||
}
|
||||
|
||||
// Get the user's organization
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
const org = orgs?.[0]
|
||||
if (!org) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] No organization found for anonymous user",
|
||||
)
|
||||
throw new Error("Failed to get organization for anonymous user")
|
||||
}
|
||||
|
||||
// Redirect to the organization dashboard
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Anonymous session creation error:",
|
||||
error,
|
||||
)
|
||||
console.error("[ANONYMOUS_AUTH] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
router.push(loginPath)
|
||||
}
|
||||
} else if (session.session) {
|
||||
// Session exists, handle organization routing
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Session exists, checking organization...",
|
||||
)
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
if (orgs?.[0]) {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
|
||||
)
|
||||
await authClient.organization.setActive({
|
||||
organizationId: orgs[0].id,
|
||||
})
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
|
||||
)
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching full organization details...",
|
||||
)
|
||||
const org = await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
},
|
||||
})
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
})
|
||||
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAnonymousSession()
|
||||
}, [router.push])
|
||||
|
||||
// Return null as this component only handles the redirect logic
|
||||
return null
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
|
||||
import {
|
||||
DocumentsWithMemoriesQuerySchema,
|
||||
ListMemoriesQuerySchema,
|
||||
SearchRequestSchema,
|
||||
Searchv4RequestSchema,
|
||||
} from "./api"
|
||||
|
||||
describe("search threshold schemas", () => {
|
||||
it("do not contain redundant number transforms or unreachable range guards", () => {
|
||||
|
|
@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination query schemas", () => {
|
||||
it("preserve page/limit defaults", () => {
|
||||
const listed = ListMemoriesQuerySchema.parse({})
|
||||
expect(listed.page).toBe(1)
|
||||
expect(listed.limit).toBe(10)
|
||||
|
||||
const docs = DocumentsWithMemoriesQuerySchema.parse({})
|
||||
expect(docs.page).toBe(1)
|
||||
expect(docs.limit).toBe(10)
|
||||
})
|
||||
|
||||
it.each([
|
||||
1, 50, 1100,
|
||||
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
|
||||
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
|
||||
expect(parsed.page).toBe(3)
|
||||
expect(parsed.limit).toBe(25)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -5, 2.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 1.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema still caps limit at 1100", () => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -10, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
|
||||
const parsed = DocumentsWithMemoriesQuerySchema.parse({
|
||||
page: 2,
|
||||
limit: 50,
|
||||
})
|
||||
expect(parsed.page).toBe(2)
|
||||
expect(parsed.limit).toBe(50)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Limit must be a positive integer",
|
||||
})
|
||||
.refine((value) => value <= 1100, {
|
||||
message: "Limit cannot be greater than 1100",
|
||||
})
|
||||
|
|
@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Page must be a positive integer",
|
||||
})
|
||||
.default("1")
|
||||
.openapi({ description: "Page number to fetch", example: "1" }),
|
||||
sort: z
|
||||
|
|
@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z
|
|||
|
||||
export const DocumentsWithMemoriesQuerySchema = z
|
||||
.object({
|
||||
page: z.number().default(1).openapi({
|
||||
page: z.number().int().min(1).default(1).openapi({
|
||||
description: "Page number to fetch",
|
||||
example: 1,
|
||||
}),
|
||||
limit: z.number().default(10).openapi({
|
||||
limit: z.number().int().min(1).default(10).openapi({
|
||||
description: "Number of items per page",
|
||||
example: 10,
|
||||
}),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue