mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
feat(tools): 7-tool parity and description refresh (#1432)
## Summary - Refresh canonical tool descriptions in `tools-shared.ts` - Align OpenAI and AI SDK tool bindings with 7-tool surface - Export `TOOL_DESCRIPTIONS` / `PARAMETER_DESCRIPTIONS` from package index Stacked on #1431 ## Test plan - [ ] `bun run test:unit` in `packages/tools` Made with [Cursor](https://cursor.com)
This commit is contained in:
parent
5fee2f2872
commit
de3bbb3ce9
26 changed files with 1577 additions and 656 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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent),
|
|||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @supermemory/tools @voltagent/core
|
||||
npm install @supermemory/tools @voltagent/core ai@^6 @ai-sdk/openai@^3
|
||||
```
|
||||
|
||||
Set up your API key as an environment variable:
|
||||
|
|
@ -52,9 +52,7 @@ const configWithMemory = withSupermemory({
|
|||
const agent = new Agent(configWithMemory)
|
||||
|
||||
// Memories are automatically injected and saved
|
||||
const result = await agent.generateText({
|
||||
messages: [{ role: "user", content: "What's my name?" }],
|
||||
})
|
||||
const result = await agent.generateText("What's my name?")
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -131,14 +129,13 @@ const configWithMemory = withSupermemory({
|
|||
|
||||
// Search tuning
|
||||
searchMode: "hybrid", // "memories" | "documents" | "hybrid"
|
||||
threshold: 0.1, // 0.0-1.0 (higher = more accurate)
|
||||
limit: 10, // Max results to return
|
||||
threshold: 0.6, // 0.0-1.0 (higher = more accurate)
|
||||
limit: 10, // Integer from 1 to 100
|
||||
rerank: true, // Rerank for best relevance
|
||||
rewriteQuery: false, // AI-rewrite query (+400ms latency)
|
||||
|
||||
// Context
|
||||
entityContext: "This is John, a software engineer", // Guides memory extraction (max 1500 chars)
|
||||
metadata: { source: "voltagent" }, // Attached to saved conversations
|
||||
metadata: { source: "voltagent" }, // Attached to saved conversations
|
||||
|
||||
// API
|
||||
apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var
|
||||
|
|
@ -154,14 +151,16 @@ const configWithMemory = withSupermemory({
|
|||
| `addMemory` | string | `"always"` | Whether to save conversations after each response |
|
||||
| `customId` | string | **required** | Custom ID to group messages into a conversation |
|
||||
| `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` |
|
||||
| `threshold` | number | `0.1` | Similarity threshold (0 = more results, 1 = more accurate) |
|
||||
| `limit` | number | `10` | Maximum number of memory results |
|
||||
| `threshold` | number | — | Similarity threshold (0 = more results, 1 = more accurate) |
|
||||
| `limit` | number | — | Maximum number of memory results (integer from 1 to 100) |
|
||||
| `rerank` | boolean | `false` | Rerank results for relevance |
|
||||
| `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) |
|
||||
| `entityContext` | string | — | Context for memory extraction (max 1500 chars) |
|
||||
| `entityContext` | string | — | Deprecated and ignored. [Configure it on the container tag instead](/concepts/customization#entity-context). |
|
||||
| `metadata` | object | — | Custom metadata attached to saved conversations |
|
||||
| `promptTemplate` | function | — | Custom function to format memory data into prompt |
|
||||
|
||||
When `threshold` or `limit` is omitted, the selected Supermemory backend route applies its own default. Set them explicitly when you need consistent search tuning across modes.
|
||||
|
||||
## Search Modes
|
||||
|
||||
The `searchMode` option controls what type of results are searched:
|
||||
|
|
@ -171,4 +170,3 @@ The `searchMode` option controls what type of results are searched:
|
|||
| `"memories"` | Search only memory entries (atomic facts about the user) |
|
||||
| `"documents"` | Search only document chunks |
|
||||
| `"hybrid"` | Search both memories AND document chunks (recommended) |
|
||||
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -342,7 +342,7 @@
|
|||
"ai": "^5.0.29",
|
||||
"lru-cache": "^11.2.6",
|
||||
"openai": "^4.104.0",
|
||||
"supermemory": "^3.0.0-alpha.26",
|
||||
"supermemory": "^4.25.4",
|
||||
"zod": "^4.1.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -5535,9 +5535,11 @@
|
|||
|
||||
"@supermemory/tools/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.65.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw=="],
|
||||
|
||||
"@supermemory/tools/supermemory": ["supermemory@4.25.4", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-97ME3rlmu7OmsXJTb9OgXOD+3VUv4Wej0ZX9xezG+LKkMwrzi4xeeAZaOJFcr0oI/QQjcHG2WOzm+und1e7MFA=="],
|
||||
|
||||
"@supermemory/tools/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"@supermemory/tools/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"@supermemory/tools/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ The package provides three submodule imports:
|
|||
```typescript
|
||||
import { supermemoryTools, searchMemoriesTool, addMemoryTool } from "@supermemory/tools/ai-sdk"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { generateText } from "ai"
|
||||
import { generateText, stepCountIs } from "ai"
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
|
|
@ -43,6 +43,7 @@ const result = await generateText({
|
|||
},
|
||||
],
|
||||
tools,
|
||||
stopWhen: stepCountIs(5),
|
||||
})
|
||||
|
||||
// Or create individual tools
|
||||
|
|
@ -271,6 +272,8 @@ import { withSupermemory } from "@supermemory/tools/openai"
|
|||
const openaiWithSupermemory = withSupermemory(openai, {
|
||||
containerTag: "user-123", // Required: identifies the user/container
|
||||
customId: "conversation-456", // Required: groups messages into the same document
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY, // Optional env fallback
|
||||
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||
mode: "full",
|
||||
addMemory: "always", // Default: "always"
|
||||
verbose: true,
|
||||
|
|
@ -295,6 +298,8 @@ The middleware supports the same configuration options as the AI SDK version:
|
|||
const openaiWithSupermemory = withSupermemory(openai, {
|
||||
containerTag: "user-123", // Required: identifies the user/container
|
||||
customId: "conversation-456", // Required: groups messages for contextual memory
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY, // Optional; captured per client
|
||||
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||
mode: "full", // "profile" | "query" | "full"
|
||||
addMemory: "always", // "always" (default) | "never"
|
||||
verbose: true, // Enable detailed logging
|
||||
|
|
@ -319,6 +324,8 @@ export async function POST(req: Request) {
|
|||
const openaiWithSupermemory = withSupermemory(openai, {
|
||||
containerTag: "user-123",
|
||||
customId: conversationId,
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY,
|
||||
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||
mode: "full",
|
||||
addMemory: "always",
|
||||
verbose: true,
|
||||
|
|
@ -606,7 +613,7 @@ interface SupermemoryToolsConfig {
|
|||
```
|
||||
|
||||
- **baseUrl**: Custom base URL for the supermemory API
|
||||
- **containerTags**: Array of custom container tags (mutually exclusive with projectId)
|
||||
- **containerTags**: Non-empty array of custom container tags (mutually exclusive with `projectId`). `searchMemories`, `getProfile`, and `memoryForget` use the first tag because v4 memory APIs are single-space. Add operations attach every configured tag, while `documentList` and `documentDelete` use the configured tags as their supported union scope. `documentDelete` still refuses a document with any tag outside that scope or a nonterminal processing status.
|
||||
- **projectId**: Project ID which gets converted to container tag format (mutually exclusive with containerTags)
|
||||
- **strict**: Enable strict schema mode for OpenAI strict validation. When `true`, all schema properties are required (satisfies OpenAI strict mode). When `false` (default), optional fields remain optional for maximum compatibility with all models.
|
||||
|
||||
|
|
@ -670,11 +677,11 @@ interface WithSupermemoryOptions {
|
|||
## Available Tools
|
||||
|
||||
### Search Memories
|
||||
Searches through stored memories based on a query string.
|
||||
Runs v4 hybrid search in the primary (first) configured container tag. Results can contain learned memories (`memory`) and source chunks (`chunk`). Only IDs on results containing `memory` can be passed to `memoryForget`; chunk-result IDs cannot.
|
||||
|
||||
**Parameters:**
|
||||
- `informationToGet` (string): Terms to search for
|
||||
- `includeFullDocs` (boolean, optional): Whether to include full document content (default: true)
|
||||
- `includeFullDocs` (boolean, optional): Deprecated compatibility input; ignored by v4 hybrid search
|
||||
- `limit` (number, optional): Maximum number of results (default: 10)
|
||||
|
||||
### Add Memory
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"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 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": {
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
"ai": "^5.0.29",
|
||||
"lru-cache": "^11.2.6",
|
||||
"openai": "^4.104.0",
|
||||
"supermemory": "^3.0.0-alpha.26",
|
||||
"supermemory": "^4.25.4",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,23 +2,32 @@ import Supermemory from "supermemory"
|
|||
import { tool } from "ai"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
CLIENT_OPTIONS,
|
||||
DEFAULT_VALUES,
|
||||
PARAMETER_DESCRIPTIONS,
|
||||
SEARCH_LIMIT_BOUNDS,
|
||||
TOOL_DESCRIPTIONS,
|
||||
clampSearchLimit,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||
import type { SupermemoryToolsConfig } from "./types"
|
||||
|
||||
function createClient(apiKey: string, config?: SupermemoryToolsConfig) {
|
||||
return new Supermemory({
|
||||
apiKey,
|
||||
...CLIENT_OPTIONS,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Export individual tool creators
|
||||
export const searchMemoriesTool = (
|
||||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
const strict = config?.strict ?? false
|
||||
|
|
@ -42,26 +51,28 @@ export const searchMemoriesTool = (
|
|||
limit: strict
|
||||
? z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(SEARCH_LIMIT_BOUNDS.min)
|
||||
.max(SEARCH_LIMIT_BOUNDS.max)
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.searchLimit)
|
||||
: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(SEARCH_LIMIT_BOUNDS.min)
|
||||
.max(SEARCH_LIMIT_BOUNDS.max)
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit),
|
||||
.describe(PARAMETER_DESCRIPTIONS.searchLimit),
|
||||
}),
|
||||
execute: async ({
|
||||
informationToGet,
|
||||
includeFullDocs = DEFAULT_VALUES.includeFullDocs,
|
||||
limit = DEFAULT_VALUES.limit,
|
||||
}) => {
|
||||
execute: async ({ informationToGet, limit = DEFAULT_VALUES.limit }) => {
|
||||
try {
|
||||
const response = await client.search.execute({
|
||||
const response = await client.search({
|
||||
q: informationToGet,
|
||||
containerTags,
|
||||
limit,
|
||||
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
|
||||
includeFullDocs,
|
||||
containerTag: containerTags[0],
|
||||
limit: clampSearchLimit(limit),
|
||||
threshold: DEFAULT_VALUES.searchThreshold,
|
||||
searchMode: "hybrid",
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -83,10 +94,7 @@ export const addMemoryTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
|
||||
|
|
@ -123,10 +131,7 @@ export const getProfileTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
const strict = config?.strict ?? false
|
||||
|
|
@ -167,10 +172,7 @@ export const documentListTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
const strict = config?.strict ?? false
|
||||
|
|
@ -196,10 +198,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 }),
|
||||
})
|
||||
|
|
@ -223,19 +227,30 @@ export const documentDeleteTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
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,
|
||||
|
|
@ -255,10 +270,7 @@ export const documentAddTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
const client = createClient(apiKey, config)
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
|
||||
|
|
@ -379,3 +391,4 @@ export {
|
|||
type PromptTemplate,
|
||||
type MemoryPromptData,
|
||||
} from "./vercel"
|
||||
export { getContainerTags } from "./tools-shared"
|
||||
|
|
|
|||
|
|
@ -1,17 +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
|
||||
// `search.execute` to return a single document with known multi-line content.
|
||||
const searchExecute = 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 = { execute: searchExecute }
|
||||
add = addMock
|
||||
memories = { forget: vi.fn() }
|
||||
documents = {
|
||||
list: documentsListMock,
|
||||
get: documentsGetMock,
|
||||
deleteBulk: documentsDeleteBulkMock,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -21,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 `documentId === normalizePathToCustomId(path)`.
|
||||
// normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content }],
|
||||
})
|
||||
mockDocuments([{ ...FILE_DOCUMENT, content }])
|
||||
}
|
||||
|
||||
describe("ClaudeMemoryTool view_range", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
documentsListMock.mockReset()
|
||||
documentsGetMock.mockReset()
|
||||
mockDocument(FILE_CONTENT)
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
|
@ -89,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.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 () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
{ documentId: "memories_notes_txt", content: 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",
|
||||
|
|
@ -113,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.
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
mockDocuments([NEIGHBOUR_DOCUMENT])
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
|
|
@ -131,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
})
|
||||
|
||||
it("str_replace refuses to modify a different file than requested", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
mockDocuments([NEIGHBOUR_DOCUMENT])
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
|
|
@ -153,11 +184,10 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
documentsListMock.mockReset()
|
||||
documentsGetMock.mockReset()
|
||||
addMock.mockReset()
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }],
|
||||
})
|
||||
mockDocument(FILE_CONTENT)
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
|
|
@ -178,6 +208,5 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
|||
expect(addMock).toHaveBeenCalledTimes(1)
|
||||
const stored = addMock.mock.calls[0]?.[0]?.content as string
|
||||
expect(stored).toContain(`price is ${dollarSequence} today`)
|
||||
expect(stored).not.toContain("line3")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,42 +203,89 @@ export class ClaudeMemoryTool {
|
|||
*/
|
||||
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
|
||||
try {
|
||||
// Search for all memory files
|
||||
const response = await this.client.search.execute({
|
||||
q: "*", // Search for all
|
||||
containerTags: this.containerTags,
|
||||
limit: 100, // Get many files (max allowed)
|
||||
includeFullDocs: false,
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,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 {
|
||||
|
|
@ -276,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) {
|
||||
|
|
@ -374,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)) {
|
||||
|
|
@ -433,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
|
||||
|
|
@ -488,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,
|
||||
|
|
@ -529,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
|
||||
|
|
@ -550,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 {
|
||||
|
|
@ -571,36 +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.execute({
|
||||
q: normalizedId,
|
||||
containerTags: this.containerTags,
|
||||
limit: 5,
|
||||
includeFullDocs: true,
|
||||
})
|
||||
// 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 document = response.results?.find(
|
||||
(r) => r.documentId === normalizedId,
|
||||
)
|
||||
for (const document of response.memories) {
|
||||
if (
|
||||
document.customId === normalizedId &&
|
||||
this.getDocumentFilePath(document) === filePath &&
|
||||
this.isDocumentInConfiguredScope(document)
|
||||
) {
|
||||
candidates.set(document.id, document)
|
||||
}
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
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 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,
|
||||
document: { documentId: candidate.id, content, metadata },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -610,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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,10 +14,53 @@ export interface ConversationMessage {
|
|||
tool_call_id?: string
|
||||
}
|
||||
|
||||
export interface ContentPart {
|
||||
type: "text" | "image_url"
|
||||
text?: string
|
||||
image_url?: { url: string }
|
||||
export type ContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; imageUrl: { url: string } }
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
let encoded = ""
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index] ?? 0
|
||||
const second = bytes[index + 1]
|
||||
const third = bytes[index + 2]
|
||||
const value = (first << 16) | ((second ?? 0) << 8) | (third ?? 0)
|
||||
encoded += BASE64_ALPHABET[(value >> 18) & 63]
|
||||
encoded += BASE64_ALPHABET[(value >> 12) & 63]
|
||||
encoded += second === undefined ? "=" : BASE64_ALPHABET[(value >> 6) & 63]
|
||||
encoded += third === undefined ? "=" : BASE64_ALPHABET[value & 63]
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
/** Normalize supported SDK image representations for `/v4/conversations`. */
|
||||
export const toConversationImageUrl = (
|
||||
value: unknown,
|
||||
mediaType = "image/jpeg",
|
||||
): string | null => {
|
||||
if (typeof URL !== "undefined" && value instanceof URL) {
|
||||
return value.toString()
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
return /^[a-z][a-z\d+.-]*:/i.test(trimmed)
|
||||
? trimmed
|
||||
: `data:${mediaType};base64,${trimmed}`
|
||||
}
|
||||
|
||||
const bytes =
|
||||
value instanceof Uint8Array
|
||||
? value
|
||||
: value instanceof ArrayBuffer
|
||||
? new Uint8Array(value)
|
||||
: null
|
||||
return bytes && bytes.length > 0
|
||||
? `data:${mediaType};base64,${encodeBase64(bytes)}`
|
||||
: null
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
|
@ -34,7 +77,6 @@ export interface AddConversationParams {
|
|||
messages: ConversationMessage[]
|
||||
containerTags?: string[]
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
entityContext?: string
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
}
|
||||
|
|
@ -45,6 +87,8 @@ export interface AddConversationResponse {
|
|||
status: string
|
||||
}
|
||||
|
||||
const CONVERSATION_REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Adds a conversation to Supermemory using the /v4/conversations endpoint
|
||||
*
|
||||
|
|
@ -87,8 +131,9 @@ export async function addConversation(
|
|||
messages: params.messages,
|
||||
containerTags: params.containerTags,
|
||||
metadata: params.metadata,
|
||||
entityContext: params.entityContext,
|
||||
}),
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(CONVERSATION_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -2,4 +2,11 @@ export type { SupermemoryToolsConfig } from "./types"
|
|||
|
||||
export type { OpenAIMiddlewareOptions } from "./openai"
|
||||
|
||||
export type { SupermemoryVoltAgent } from "./voltagent"
|
||||
export type { SupermemoryVoltAgent } from "./voltagent/options"
|
||||
|
||||
export {
|
||||
TOOL_DESCRIPTIONS,
|
||||
PARAMETER_DESCRIPTIONS,
|
||||
DEFAULT_VALUES,
|
||||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type OpenAI from "openai"
|
||||
import { validateApiKey } from "../shared"
|
||||
import {
|
||||
createOpenAIMiddleware,
|
||||
type OpenAIMiddlewareOptions,
|
||||
|
|
@ -22,7 +21,7 @@ import {
|
|||
* @param options.verbose - Optional flag to enable detailed logging of memory search and injection process (default: false)
|
||||
* @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full"
|
||||
* @param options.addMemory - Optional mode for memory addition: "always" (default), "never"
|
||||
* @param options.apiKey - Optional Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
|
||||
* @param options.apiKey - Optional Supermemory API key; falls back to SUPERMEMORY_API_KEY
|
||||
*
|
||||
* @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs
|
||||
*
|
||||
|
|
@ -58,14 +57,18 @@ import {
|
|||
* })
|
||||
* ```
|
||||
*
|
||||
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
|
||||
* @throws {Error} When neither options.apiKey nor SUPERMEMORY_API_KEY is set
|
||||
* @throws {Error} When supermemory API request fails
|
||||
*/
|
||||
export function withSupermemory(
|
||||
openaiClient: OpenAI,
|
||||
options: OpenAIMiddlewareOptions,
|
||||
) {
|
||||
validateApiKey(options.apiKey)
|
||||
if (!options.apiKey?.trim() && !process.env.SUPERMEMORY_API_KEY?.trim()) {
|
||||
throw new Error(
|
||||
"SUPERMEMORY_API_KEY is not set — provide it via options.apiKey or set the environment variable",
|
||||
)
|
||||
}
|
||||
|
||||
if (!options.containerTag) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,15 +1,67 @@
|
|||
import type OpenAI from "openai"
|
||||
import { APIPromise } from "openai/core"
|
||||
import Supermemory from "supermemory"
|
||||
import { addConversation } from "../conversations-client"
|
||||
import { validateApiKey } from "../shared"
|
||||
import {
|
||||
addConversation,
|
||||
type ContentPart as ConversationContentPart,
|
||||
type ConversationMessage,
|
||||
} from "../conversations-client"
|
||||
import { deduplicateMemoriesForMode } from "../tools-shared"
|
||||
import { createLogger, type Logger } from "../vercel/logger"
|
||||
import { convertProfileToMarkdown } from "../vercel/util"
|
||||
|
||||
const normalizeBaseUrl = (url?: string): string => {
|
||||
const defaultUrl = "https://api.supermemory.ai"
|
||||
if (!url) return defaultUrl
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url
|
||||
return url?.trim().replace(/\/+$/, "") || defaultUrl
|
||||
}
|
||||
|
||||
const PROFILE_REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
const deferAPIPromise = <T>(
|
||||
start: () => Promise<{ request: APIPromise<T> }>,
|
||||
): APIPromise<T> => {
|
||||
const ready = start()
|
||||
|
||||
const responsePromise = ready.then(async ({ request }) => ({
|
||||
response: await request.asResponse(),
|
||||
options: {} as never,
|
||||
controller: new AbortController(),
|
||||
}))
|
||||
|
||||
return new APIPromise<T>(responsePromise, async () => {
|
||||
const { request } = await ready
|
||||
return await request
|
||||
})
|
||||
}
|
||||
|
||||
const convertConversationContent = (
|
||||
content: unknown,
|
||||
): string | ConversationContentPart[] => {
|
||||
if (typeof content === "string") return content
|
||||
if (!Array.isArray(content)) return ""
|
||||
|
||||
const converted: ConversationContentPart[] = []
|
||||
for (const value of content) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const part = value as {
|
||||
type?: unknown
|
||||
text?: unknown
|
||||
image_url?: { url?: unknown }
|
||||
}
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
converted.push({ type: "text", text: part.text })
|
||||
} else if (
|
||||
part.type === "image_url" &&
|
||||
typeof part.image_url?.url === "string"
|
||||
) {
|
||||
converted.push({
|
||||
type: "image_url",
|
||||
imageUrl: { url: part.image_url.url },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return converted
|
||||
}
|
||||
|
||||
export interface OpenAIMiddlewareOptions {
|
||||
|
|
@ -20,8 +72,9 @@ export interface OpenAIMiddlewareOptions {
|
|||
verbose?: boolean
|
||||
mode?: "profile" | "query" | "full"
|
||||
addMemory?: "always" | "never"
|
||||
baseUrl?: string
|
||||
/** Supermemory API key (falls back to SUPERMEMORY_API_KEY). */
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
interface SupermemoryProfileSearch {
|
||||
|
|
@ -77,33 +130,35 @@ const getLastUserMessage = (
|
|||
*
|
||||
* @param containerTag - The container tag/identifier for memory search (e.g., user ID, project ID)
|
||||
* @param queryText - Optional query text to search for specific memories. If empty, returns all profile memories
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @param apiKey - The Supermemory API key used to authenticate the request
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @returns Promise that resolves to the SuperMemory profile search response
|
||||
* @throws {Error} When the API request fails or returns an error status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Search with query
|
||||
* const results = await supermemoryProfileSearch("user-123", "favorite programming language", baseUrl, apiKey)
|
||||
* const results = await supermemoryProfileSearch("user-123", "favorite programming language", apiKey, baseUrl)
|
||||
*
|
||||
* // Get all profile memories
|
||||
* const profile = await supermemoryProfileSearch("user-123", "", baseUrl, apiKey)
|
||||
* const profile = await supermemoryProfileSearch("user-123", "", apiKey, baseUrl)
|
||||
* ```
|
||||
*/
|
||||
const supermemoryProfileSearch = async (
|
||||
containerTag: string,
|
||||
queryText: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
): Promise<SupermemoryProfileSearch> => {
|
||||
const payload = queryText
|
||||
? JSON.stringify({
|
||||
q: queryText,
|
||||
containerTag: containerTag,
|
||||
include: ["static", "dynamic"],
|
||||
})
|
||||
: JSON.stringify({
|
||||
containerTag: containerTag,
|
||||
include: ["static", "dynamic"],
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
@ -114,6 +169,8 @@ const supermemoryProfileSearch = async (
|
|||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: payload,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -143,8 +200,8 @@ const supermemoryProfileSearch = async (
|
|||
* @param containerTag - The container tag/identifier for memory search
|
||||
* @param logger - Logger instance for debugging and info output
|
||||
* @param mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both)
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @param apiKey - The Supermemory API key used to authenticate the request
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @returns Promise that resolves to enhanced messages with memory-injected system prompt
|
||||
*
|
||||
* @example
|
||||
|
|
@ -158,8 +215,8 @@ const supermemoryProfileSearch = async (
|
|||
* "user-123",
|
||||
* logger,
|
||||
* "full",
|
||||
* baseUrl,
|
||||
* apiKey
|
||||
* apiKey,
|
||||
* baseUrl
|
||||
* )
|
||||
* // Returns messages with system prompt containing relevant memories
|
||||
* ```
|
||||
|
|
@ -169,8 +226,8 @@ const addSystemPrompt = async (
|
|||
containerTag: string,
|
||||
logger: Logger,
|
||||
mode: "profile" | "query" | "full",
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
) => {
|
||||
const systemPromptExists = messages.some((msg) => msg.role === "system")
|
||||
|
||||
|
|
@ -179,8 +236,8 @@ const addSystemPrompt = async (
|
|||
const memoriesResponse = await supermemoryProfileSearch(
|
||||
containerTag,
|
||||
queryText,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
)
|
||||
|
||||
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
|
||||
|
|
@ -339,27 +396,24 @@ const addMemoryTool = async (
|
|||
const conversationId = customId.replace("conversation:", "")
|
||||
|
||||
// Convert OpenAI messages to conversation format
|
||||
const conversationMessages = messages.map((msg) => ({
|
||||
role: msg.role as "user" | "assistant" | "system" | "tool",
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => ({
|
||||
type: "text" as const,
|
||||
text: (c as { type: "text"; text: string }).text,
|
||||
}))
|
||||
: "",
|
||||
...("name" in msg && msg.name && { name: msg.name }),
|
||||
...("tool_calls" in msg &&
|
||||
msg.tool_calls && { tool_calls: msg.tool_calls }),
|
||||
...("tool_call_id" in msg &&
|
||||
msg.tool_call_id && {
|
||||
tool_call_id: msg.tool_call_id,
|
||||
}),
|
||||
}))
|
||||
const conversationMessages: ConversationMessage[] = messages.map(
|
||||
(msg) => ({
|
||||
role:
|
||||
msg.role === "developer"
|
||||
? "system"
|
||||
: msg.role === "function"
|
||||
? "tool"
|
||||
: msg.role,
|
||||
content: convertConversationContent(msg.content),
|
||||
...("name" in msg && msg.name && { name: msg.name }),
|
||||
...("tool_calls" in msg &&
|
||||
msg.tool_calls && { tool_calls: msg.tool_calls }),
|
||||
...("tool_call_id" in msg &&
|
||||
msg.tool_call_id && {
|
||||
tool_call_id: msg.tool_call_id,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const response = await addConversation({
|
||||
conversationId,
|
||||
|
|
@ -411,9 +465,9 @@ const addMemoryTool = async (
|
|||
* @param options.verbose - Enable detailed logging of memory operations (default: false)
|
||||
* @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile")
|
||||
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always")
|
||||
* @param options.apiKey - Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
|
||||
* @param options.apiKey - Supermemory API key (falls back to SUPERMEMORY_API_KEY)
|
||||
* @returns Object with `wrapClient` and `createClient` methods
|
||||
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
|
||||
* @throws {Error} When neither options.apiKey nor SUPERMEMORY_API_KEY is set
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
@ -432,8 +486,14 @@ export function createOpenAIMiddleware(
|
|||
options?: OpenAIMiddlewareOptions,
|
||||
) {
|
||||
const logger = createLogger(options?.verbose ?? false)
|
||||
const apiKey =
|
||||
options?.apiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim() || ""
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"SUPERMEMORY_API_KEY is not set — provide it via options.apiKey or set the environment variable",
|
||||
)
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(options?.baseUrl)
|
||||
const apiKey = validateApiKey(options?.apiKey)
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}),
|
||||
|
|
@ -469,8 +529,8 @@ export function createOpenAIMiddleware(
|
|||
const memoriesResponse = await supermemoryProfileSearch(
|
||||
containerTag,
|
||||
queryText,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
)
|
||||
|
||||
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
|
||||
|
|
@ -535,8 +595,9 @@ export function createOpenAIMiddleware(
|
|||
return memories
|
||||
}
|
||||
|
||||
const createResponsesWithMemory = async (
|
||||
const prepareResponsesWithMemory = async (
|
||||
params: Parameters<typeof originalResponsesCreate>[0],
|
||||
requestOptions?: OpenAI.RequestOptions,
|
||||
) => {
|
||||
if (!originalResponsesCreate) {
|
||||
throw new Error(
|
||||
|
|
@ -548,7 +609,13 @@ export function createOpenAIMiddleware(
|
|||
|
||||
if (mode !== "profile" && !input) {
|
||||
logger.debug("No input found for Responses API, skipping memory search")
|
||||
return originalResponsesCreate.call(openaiClient.responses, params)
|
||||
return {
|
||||
request: originalResponsesCreate.call(
|
||||
openaiClient.responses,
|
||||
params,
|
||||
requestOptions,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Starting memory search for Responses API", {
|
||||
|
|
@ -586,22 +653,58 @@ export function createOpenAIMiddleware(
|
|||
? `${params.instructions || ""}\n\n${memories}`.trim()
|
||||
: params.instructions
|
||||
|
||||
return originalResponsesCreate.call(openaiClient.responses, {
|
||||
...params,
|
||||
instructions: enhancedInstructions,
|
||||
})
|
||||
return {
|
||||
request: originalResponsesCreate.call(
|
||||
openaiClient.responses,
|
||||
{
|
||||
...params,
|
||||
instructions: enhancedInstructions,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const createWithMemory = async (
|
||||
const createResponsesWithMemory = (
|
||||
params: Parameters<typeof originalResponsesCreate>[0],
|
||||
requestOptions?: OpenAI.RequestOptions,
|
||||
) => deferAPIPromise(() => prepareResponsesWithMemory(params, requestOptions))
|
||||
|
||||
const prepareCreateWithMemory = async (
|
||||
params: OpenAI.Chat.Completions.ChatCompletionCreateParams,
|
||||
requestOptions?: OpenAI.RequestOptions,
|
||||
) => {
|
||||
const messages = Array.isArray(params.messages) ? params.messages : []
|
||||
const userMessage = getLastUserMessage(messages)
|
||||
const hasUserMessage = messages.some((message) => message.role === "user")
|
||||
const shouldPersist =
|
||||
addMemory === "always" &&
|
||||
(customId ? hasUserMessage : Boolean(userMessage.trim()))
|
||||
const memoryContent = customId
|
||||
? getConversationContent(messages)
|
||||
: userMessage
|
||||
const memoryCustomId = customId ? `conversation:${customId}` : undefined
|
||||
|
||||
if (mode !== "profile") {
|
||||
const userMessage = getLastUserMessage(messages)
|
||||
if (!userMessage) {
|
||||
logger.debug("No user message found, skipping memory search")
|
||||
return originalCreate.call(openaiClient.chat.completions, params)
|
||||
if (mode !== "profile" && !userMessage) {
|
||||
if (shouldPersist) {
|
||||
await addMemoryTool(
|
||||
client,
|
||||
containerTag,
|
||||
memoryContent,
|
||||
memoryCustomId,
|
||||
logger,
|
||||
messages,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
)
|
||||
}
|
||||
logger.debug("No textual user message found, skipping memory search")
|
||||
return {
|
||||
request: originalCreate.call(
|
||||
openaiClient.chat.completions,
|
||||
params,
|
||||
requestOptions,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -613,42 +716,47 @@ export function createOpenAIMiddleware(
|
|||
|
||||
const operations: Promise<unknown>[] = []
|
||||
|
||||
if (addMemory === "always") {
|
||||
const userMessage = getLastUserMessage(messages)
|
||||
if (userMessage?.trim()) {
|
||||
const content = customId
|
||||
? getConversationContent(messages)
|
||||
: userMessage
|
||||
const memoryCustomId = customId ? `conversation:${customId}` : undefined
|
||||
|
||||
operations.push(
|
||||
addMemoryTool(
|
||||
client,
|
||||
containerTag,
|
||||
content,
|
||||
memoryCustomId,
|
||||
logger,
|
||||
messages,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (shouldPersist) {
|
||||
operations.push(
|
||||
addMemoryTool(
|
||||
client,
|
||||
containerTag,
|
||||
memoryContent,
|
||||
memoryCustomId,
|
||||
logger,
|
||||
messages,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
operations.push(
|
||||
addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey),
|
||||
addSystemPrompt(messages, containerTag, logger, mode, apiKey, baseUrl),
|
||||
)
|
||||
|
||||
const results = await Promise.all(operations)
|
||||
const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last
|
||||
const enhancedMessages = results[
|
||||
results.length - 1
|
||||
] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last
|
||||
|
||||
return originalCreate.call(openaiClient.chat.completions, {
|
||||
...params,
|
||||
messages: enhancedMessages,
|
||||
})
|
||||
return {
|
||||
request: originalCreate.call(
|
||||
openaiClient.chat.completions,
|
||||
{
|
||||
...params,
|
||||
messages: enhancedMessages,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const createWithMemory = (
|
||||
params: OpenAI.Chat.Completions.ChatCompletionCreateParams,
|
||||
requestOptions?: OpenAI.RequestOptions,
|
||||
) => deferAPIPromise(() => prepareCreateWithMemory(params, requestOptions))
|
||||
|
||||
openaiClient.chat.completions.create =
|
||||
createWithMemory as typeof originalCreate
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import type OpenAI from "openai"
|
||||
import Supermemory from "supermemory"
|
||||
import {
|
||||
CLIENT_OPTIONS,
|
||||
DEFAULT_VALUES,
|
||||
PARAMETER_DESCRIPTIONS,
|
||||
SEARCH_LIMIT_BOUNDS,
|
||||
TOOL_DESCRIPTIONS,
|
||||
clampSearchLimit,
|
||||
deleteDocumentByIdentifier,
|
||||
getContainerTags,
|
||||
} from "../tools-shared"
|
||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||
|
|
@ -14,14 +18,14 @@ import type { SupermemoryToolsConfig } from "../types"
|
|||
*/
|
||||
export interface MemorySearchResult {
|
||||
success: boolean
|
||||
results?: Awaited<ReturnType<Supermemory["search"]["execute"]>>["results"]
|
||||
results?: Awaited<ReturnType<Supermemory["search"]>>["results"]
|
||||
count?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MemoryAddResult {
|
||||
success: boolean
|
||||
memory?: Awaited<ReturnType<Supermemory["memories"]["add"]>>
|
||||
memory?: Awaited<ReturnType<Supermemory["add"]>>
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +35,7 @@ export interface ProfileResult {
|
|||
static: string[]
|
||||
dynamic: string[]
|
||||
}
|
||||
searchResults?: Awaited<ReturnType<Supermemory["search"]["execute"]>>
|
||||
searchResults?: Awaited<ReturnType<Supermemory["profile"]>>["searchResults"]
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
|
@ -82,8 +86,10 @@ export const memoryToolSchemas = {
|
|||
default: DEFAULT_VALUES.includeFullDocs,
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: PARAMETER_DESCRIPTIONS.limit,
|
||||
type: "integer",
|
||||
minimum: SEARCH_LIMIT_BOUNDS.min,
|
||||
maximum: SEARCH_LIMIT_BOUNDS.max,
|
||||
description: PARAMETER_DESCRIPTIONS.searchLimit,
|
||||
default: DEFAULT_VALUES.limit,
|
||||
},
|
||||
},
|
||||
|
|
@ -159,6 +165,10 @@ export const memoryToolSchemas = {
|
|||
type: "string",
|
||||
description: PARAMETER_DESCRIPTIONS.documentId,
|
||||
},
|
||||
containerTag: {
|
||||
type: "string",
|
||||
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
|
||||
},
|
||||
},
|
||||
required: ["documentId"],
|
||||
},
|
||||
|
|
@ -221,6 +231,7 @@ export const memoryToolSchemas = {
|
|||
function createClient(apiKey: string, config?: SupermemoryToolsConfig) {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...CLIENT_OPTIONS,
|
||||
...(config?.baseUrl && { baseURL: config.baseUrl }),
|
||||
})
|
||||
|
||||
|
|
@ -240,7 +251,6 @@ export function createSearchMemoriesFunction(
|
|||
|
||||
return async function searchMemories({
|
||||
informationToGet,
|
||||
includeFullDocs = DEFAULT_VALUES.includeFullDocs,
|
||||
limit = DEFAULT_VALUES.limit,
|
||||
}: {
|
||||
informationToGet: string
|
||||
|
|
@ -248,12 +258,12 @@ export function createSearchMemoriesFunction(
|
|||
limit?: number
|
||||
}): Promise<MemorySearchResult> {
|
||||
try {
|
||||
const response = await client.search.execute({
|
||||
const response = await client.search({
|
||||
q: informationToGet,
|
||||
containerTags,
|
||||
limit,
|
||||
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
|
||||
includeFullDocs,
|
||||
containerTag: containerTags[0],
|
||||
limit: clampSearchLimit(limit),
|
||||
threshold: DEFAULT_VALUES.searchThreshold,
|
||||
searchMode: "hybrid",
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -363,10 +373,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 +404,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,
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ export const supermemoryProfileSearch = async (
|
|||
? JSON.stringify({
|
||||
q: queryText,
|
||||
containerTag: containerTag,
|
||||
include: ["static", "dynamic"],
|
||||
})
|
||||
: JSON.stringify({
|
||||
containerTag: containerTag,
|
||||
include: ["static", "dynamic"],
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@ 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 searchExecute = vi.fn()
|
||||
const clientAdd = vi.fn()
|
||||
const clientSearch = vi.fn()
|
||||
const clientOptions: unknown[] = []
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = { execute: searchExecute }
|
||||
constructor(options: unknown) {
|
||||
clientOptions.push(options)
|
||||
}
|
||||
add = clientAdd
|
||||
search = clientSearch
|
||||
documents = {
|
||||
delete: documentsDelete,
|
||||
deleteBulk: documentsDeleteBulk,
|
||||
get: documentsGet,
|
||||
list: documentsList,
|
||||
add: vi.fn(),
|
||||
}
|
||||
|
|
@ -35,16 +41,77 @@ 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 },
|
||||
})
|
||||
searchExecute.mockReset()
|
||||
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
|
||||
clientSearch.mockReset().mockResolvedValue({ results: [] })
|
||||
clientOptions.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("searchMemories", () => {
|
||||
it("ai-sdk variant clamps an oversized limit before calling the SDK", async () => {
|
||||
const tool = aiSdk.searchMemoriesTool(API_KEY)
|
||||
const result = (await executeTool(tool, {
|
||||
informationToGet: "coffee order",
|
||||
limit: 999,
|
||||
})) as { success: boolean }
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(clientSearch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: "coffee order", limit: 50 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("ai-sdk schema rejects out-of-range limits", () => {
|
||||
const tool = aiSdk.searchMemoriesTool(API_KEY) as unknown as {
|
||||
inputSchema: { safeParse: (v: unknown) => { success: boolean } }
|
||||
}
|
||||
expect(
|
||||
tool.inputSchema.safeParse({ informationToGet: "x", limit: 0 }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
tool.inputSchema.safeParse({ informationToGet: "x", limit: 51 }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
tool.inputSchema.safeParse({ informationToGet: "x", limit: 50 }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("openai variant clamps a non-positive limit before calling the SDK", async () => {
|
||||
const search = openAi.createSearchMemoriesFunction(API_KEY)
|
||||
await search({ informationToGet: "coffee order", limit: 0 })
|
||||
|
||||
expect(clientSearch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("creates SDK clients with a bounded timeout and retry budget", () => {
|
||||
aiSdk.searchMemoriesTool(API_KEY)
|
||||
openAi.createSearchMemoriesFunction(API_KEY)
|
||||
|
||||
expect(clientOptions).toHaveLength(2)
|
||||
for (const options of clientOptions) {
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({ timeout: 30_000, maxRetries: 2 }),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("documentDelete", () => {
|
||||
it("ai-sdk variant passes the document id string to the SDK", async () => {
|
||||
const tool = aiSdk.documentDeleteTool(API_KEY)
|
||||
|
|
@ -53,7 +120,8 @@ describe("documentDelete", () => {
|
|||
}
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(documentsDelete).toHaveBeenCalledWith("doc_123")
|
||||
expect(documentsGet).toHaveBeenCalledWith("doc_123")
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -177,16 +245,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) {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
const metadata = {
|
||||
claude_memory_type: "file",
|
||||
file_path: FILE_PATH,
|
||||
}
|
||||
documentsList.mockResolvedValue({
|
||||
memories: [
|
||||
{
|
||||
documentId: CUSTOM_ID,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +329,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 () => {
|
||||
|
|
@ -264,6 +346,6 @@ describe("ClaudeMemoryTool", () => {
|
|||
expect(clientAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customId: "memories_renamed_txt" }),
|
||||
)
|
||||
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
|
||||
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,36 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { deduplicateMemoriesForMode, getContainerTags } from "./tools-shared"
|
||||
import {
|
||||
DEFAULT_VALUES,
|
||||
SEARCH_LIMIT_BOUNDS,
|
||||
clampSearchLimit,
|
||||
deduplicateMemoriesForMode,
|
||||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
|
||||
describe("clampSearchLimit", () => {
|
||||
it("keeps in-range integers", () => {
|
||||
expect(clampSearchLimit(7)).toBe(7)
|
||||
})
|
||||
|
||||
it("clamps into SEARCH_LIMIT_BOUNDS", () => {
|
||||
expect(clampSearchLimit(0)).toBe(SEARCH_LIMIT_BOUNDS.min)
|
||||
expect(clampSearchLimit(-5)).toBe(SEARCH_LIMIT_BOUNDS.min)
|
||||
expect(clampSearchLimit(999)).toBe(SEARCH_LIMIT_BOUNDS.max)
|
||||
})
|
||||
|
||||
it("floors fractional values and coerces numeric strings", () => {
|
||||
expect(clampSearchLimit(7.9)).toBe(7)
|
||||
expect(clampSearchLimit("12")).toBe(12)
|
||||
})
|
||||
|
||||
it("falls back to the default for non-numeric input", () => {
|
||||
expect(clampSearchLimit("lots")).toBe(DEFAULT_VALUES.limit)
|
||||
expect(clampSearchLimit(undefined)).toBe(DEFAULT_VALUES.limit)
|
||||
expect(clampSearchLimit(Number.POSITIVE_INFINITY)).toBe(
|
||||
DEFAULT_VALUES.limit,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getContainerTags", () => {
|
||||
it("uses the default project when no config is provided", () => {
|
||||
|
|
|
|||
|
|
@ -2,58 +2,92 @@
|
|||
* 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) memories/details/information about the user or other facts or entities. Run when explicitly asked or when context about user's past choices would be helpful.",
|
||||
"Search the primary configured container tag for relevant facts, preferences, history, and source 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. Hybrid results mix learned memories (memory field) and source chunks (chunk field). Only an ID on a result containing a memory field is a profile-memory ID that can be passed to memoryForget; chunk-result IDs cannot be forgotten.",
|
||||
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.",
|
||||
"Get the user profile for the primary configured container tag, unless containerTag explicitly overrides it. The profile contains 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 documents with optional filtering by container tag and page-based pagination. Useful for browsing or managing saved content.",
|
||||
"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:
|
||||
"Delete a document and its associated memories by document ID or customId. Deletes are permanent. Use when user wants to remove saved content.",
|
||||
"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. For safety, deletion is refused while the document is processing or nonterminal, or when its authoritative tag set is empty, unavailable, or contains any tag outside the effective scope. To forget one learned fact, use memoryForget instead.",
|
||||
documentAdd:
|
||||
"Add a new document (URL, text, or content) to memory. The content is queued for processing, and memories will be extracted automatically.",
|
||||
"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:
|
||||
"Forget (soft delete) a specific memory by ID or content match. The memory is marked as forgotten but not permanently deleted. Use when user wants to remove specific information from their profile.",
|
||||
"Soft-forget a single extracted profile memory (a learned fact) in the primary configured container tag, unless containerTag explicitly overrides it, so the fact no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults or from a searchMemories result containing a memory field, or provide memoryContent for an exact text match. Chunk-result 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: "Terms to search for in the user's memories",
|
||||
informationToGet:
|
||||
"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.",
|
||||
"Deprecated compatibility input. It is ignored because v4 hybrid search returns learned memories and matching chunks, not full source documents.",
|
||||
limit: "Maximum number of results to return",
|
||||
searchLimit: "Maximum number of results to return (1-50)",
|
||||
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. Deletion is refused if the document has any tag outside the resulting effective scope.",
|
||||
query: "Optional search query to include relevant search results",
|
||||
page: "Page number to fetch, 1-based (default: 1)",
|
||||
documentId: "The unique identifier of the document to operate on",
|
||||
content: "The content to add - can be text, URL, or other supported formats",
|
||||
documentId:
|
||||
"Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories only after processing reaches a terminal done or failed state. If documentList used a containerTag override, pass it again. Deletion is refused if the document has any tag outside the effective scope. 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: "The unique identifier of the memory entry",
|
||||
memoryId:
|
||||
"Profile-memory ID from query-backed getProfile searchResults or a searchMemories result containing a memory field. Soft-forgets one learned fact; chunk-result and document IDs are not valid.",
|
||||
memoryContent:
|
||||
"Exact content match of the memory entry to operate on (alternative to ID)",
|
||||
reason: "Optional reason for forgetting this memory",
|
||||
"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
|
||||
export const DEFAULT_VALUES = {
|
||||
includeFullDocs: true,
|
||||
limit: 10,
|
||||
chunkThreshold: 0.6,
|
||||
searchThreshold: 0.6,
|
||||
} as const
|
||||
|
||||
// Bounds for the searchMemories `limit` input.
|
||||
export const SEARCH_LIMIT_BOUNDS = { min: 1, max: 50 } as const
|
||||
|
||||
/**
|
||||
* Clamp a searchMemories `limit` into SEARCH_LIMIT_BOUNDS.
|
||||
* The schema constrains well-behaved models; a prompt-injected one can still
|
||||
* send anything, so tool execution clamps as well. Non-numeric input falls
|
||||
* back to DEFAULT_VALUES.limit.
|
||||
*/
|
||||
export function clampSearchLimit(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_VALUES.limit
|
||||
return Math.min(
|
||||
SEARCH_LIMIT_BOUNDS.max,
|
||||
Math.max(SEARCH_LIMIT_BOUNDS.min, Math.floor(parsed)),
|
||||
)
|
||||
}
|
||||
|
||||
// Supermemory client options shared by the tool surfaces: bound each request
|
||||
// and limit retries so a slow API cannot stall an agent turn indefinitely.
|
||||
export const CLIENT_OPTIONS = {
|
||||
timeout: 30_000,
|
||||
maxRetries: 2,
|
||||
} as const
|
||||
|
||||
// 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
|
||||
|
||||
/**
|
||||
|
|
@ -62,16 +96,195 @@ 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) {
|
||||
assertDocumentCanBeDeleted(directMatch, 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
|
||||
}
|
||||
assertDocumentCanBeDeleted(document, containerTags)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
const TERMINAL_DOCUMENT_STATUSES = new Set(["done", "failed"])
|
||||
|
||||
function assertDocumentCanBeDeleted(
|
||||
document: Awaited<ReturnType<Supermemory["documents"]["get"]>>,
|
||||
expectedContainerTags: readonly string[],
|
||||
): void {
|
||||
if (
|
||||
!hasCompleteContainerTagScope(document.containerTags, expectedContainerTags)
|
||||
) {
|
||||
throw new Error(
|
||||
`Document ${document.id} could not be verified safely: its complete non-empty container-tag set must be contained in the configured scope.`,
|
||||
)
|
||||
}
|
||||
|
||||
// The current SDK always supplies status. Keeping undefined permissive lets
|
||||
// older SDKs and lightweight client doubles continue to work.
|
||||
const status = (document as { status?: string }).status
|
||||
if (status !== undefined && !TERMINAL_DOCUMENT_STATUSES.has(status)) {
|
||||
throw new Error(
|
||||
`Document ${document.id} cannot be deleted while it is processing or otherwise nonterminal (status: ${status}).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function hasCompleteContainerTagScope(
|
||||
actual: string[] | undefined,
|
||||
expected: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
actual !== undefined &&
|
||||
actual.length > 0 &&
|
||||
actual.every((tag) => tag.trim() !== "" && expected.includes(tag))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import {
|
|||
type LanguageModel,
|
||||
type LanguageModelCallOptions,
|
||||
type LanguageModelStreamPart,
|
||||
getLastUserMessage,
|
||||
hasPersistableUserContent,
|
||||
} from "./util"
|
||||
import {
|
||||
createSupermemoryContext,
|
||||
|
|
@ -182,11 +182,9 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
|
|||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3
|
||||
const result = await target.doGenerate(modelParams as any)
|
||||
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (
|
||||
ctx.addMemory === "always" &&
|
||||
userMessage &&
|
||||
userMessage.trim()
|
||||
hasPersistableUserContent(params)
|
||||
) {
|
||||
const assistantResponseText = extractAssistantResponseText(
|
||||
result.content as unknown[],
|
||||
|
|
@ -261,11 +259,9 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
|
|||
controller.enqueue(chunk)
|
||||
},
|
||||
flush: async () => {
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (
|
||||
ctx.addMemory === "always" &&
|
||||
userMessage &&
|
||||
userMessage.trim()
|
||||
hasPersistableUserContent(params)
|
||||
) {
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
addConversation,
|
||||
type ContentPart,
|
||||
type ConversationMessage,
|
||||
toConversationImageUrl,
|
||||
} from "../conversations-client"
|
||||
import {
|
||||
createLogger,
|
||||
|
|
@ -105,13 +106,12 @@ export const convertToConversationMessages = (
|
|||
})
|
||||
} else if (
|
||||
content.type === "file" &&
|
||||
typeof content.data === "string" &&
|
||||
content.mediaType.startsWith("image/")
|
||||
) {
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: content.data },
|
||||
})
|
||||
const url = toConversationImageUrl(content.data, content.mediaType)
|
||||
if (url) {
|
||||
contentParts.push({ type: "image_url", imageUrl: { url } })
|
||||
}
|
||||
} else if (
|
||||
includeToolCalls &&
|
||||
content.type === "tool-call" &&
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@ import type {
|
|||
LanguageModelV2CallOptions,
|
||||
LanguageModelV2Message,
|
||||
LanguageModelV2StreamPart,
|
||||
LanguageModelV3,
|
||||
LanguageModelV3CallOptions,
|
||||
LanguageModelV3Message,
|
||||
LanguageModelV3StreamPart,
|
||||
} from "@ai-sdk/provider"
|
||||
import { toConversationImageUrl } from "../conversations-client"
|
||||
|
||||
// Re-export shared types for backward compatibility
|
||||
export type {
|
||||
|
|
@ -15,17 +12,23 @@ export type {
|
|||
ProfileMarkdownData,
|
||||
} from "../shared"
|
||||
|
||||
// Union types for dual SDK version support (V2 = SDK 5, V3 = SDK 6)
|
||||
export type LanguageModel = LanguageModelV2 | LanguageModelV3
|
||||
export type LanguageModelCallOptions =
|
||||
| LanguageModelV2CallOptions
|
||||
| LanguageModelV3CallOptions
|
||||
export type LanguageModelMessage =
|
||||
| LanguageModelV2Message
|
||||
| LanguageModelV3Message
|
||||
export type LanguageModelStreamPart =
|
||||
| LanguageModelV2StreamPart
|
||||
| LanguageModelV3StreamPart
|
||||
// Provider v2 does not export V3 names, so keep the public declaration on the
|
||||
// common V2 surface and structurally accept V3 models at the wrapper boundary.
|
||||
type LanguageModelV3Compat = Omit<
|
||||
LanguageModelV2,
|
||||
"specificationVersion" | "doGenerate" | "doStream"
|
||||
> & {
|
||||
readonly specificationVersion: "v3"
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations.
|
||||
doGenerate(...args: any[]): PromiseLike<any>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations.
|
||||
doStream(...args: any[]): PromiseLike<any>
|
||||
}
|
||||
|
||||
export type LanguageModel = LanguageModelV2 | LanguageModelV3Compat
|
||||
export type LanguageModelCallOptions = LanguageModelV2CallOptions
|
||||
export type LanguageModelMessage = LanguageModelV2Message
|
||||
export type LanguageModelStreamPart = LanguageModelV2StreamPart
|
||||
|
||||
export type OutputContentItem =
|
||||
| { type: "text"; text: string }
|
||||
|
|
@ -73,6 +76,38 @@ export const getLastUserMessage = (
|
|||
.join(" ")
|
||||
}
|
||||
|
||||
/** Whether the prompt contains user content that `/v4/conversations` can store. */
|
||||
export const hasPersistableUserContent = (
|
||||
params: LanguageModelCallOptions,
|
||||
): boolean => {
|
||||
return params.prompt.some((message) => {
|
||||
if (message.role !== "user") return false
|
||||
const content: unknown = message.content
|
||||
if (typeof content === "string") {
|
||||
return Boolean(content.trim())
|
||||
}
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some((value) => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const part = value as {
|
||||
type?: unknown
|
||||
text?: unknown
|
||||
mediaType?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
return Boolean(part.text.trim())
|
||||
}
|
||||
return (
|
||||
part.type === "file" &&
|
||||
typeof part.mediaType === "string" &&
|
||||
part.mediaType.startsWith("image/") &&
|
||||
toConversationImageUrl(part.data, part.mediaType) !== null
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const filterOutSupermemories = (content: string) => {
|
||||
return content.split("User Supermemories: ")[0]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,32 @@ import {
|
|||
saveConversation,
|
||||
} from "./middleware"
|
||||
|
||||
const getInputMessages = (input: unknown): VoltAgentMessage[] => {
|
||||
if (typeof input === "string") {
|
||||
return input.trim() ? [{ role: "user", content: input }] : []
|
||||
}
|
||||
if (Array.isArray(input)) return input as VoltAgentMessage[]
|
||||
if (
|
||||
input &&
|
||||
typeof input === "object" &&
|
||||
"messages" in input &&
|
||||
Array.isArray(input.messages)
|
||||
) {
|
||||
return input.messages as VoltAgentMessage[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getOutputText = (output: unknown): string => {
|
||||
if (typeof output === "string") return output
|
||||
if (!output || typeof output !== "object") return ""
|
||||
if ("text" in output && typeof output.text === "string") return output.text
|
||||
if ("content" in output && typeof output.content === "string") {
|
||||
return output.content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Supermemory hooks for VoltAgent agents.
|
||||
*
|
||||
|
|
@ -41,7 +67,6 @@ import {
|
|||
* const agent = new Agent({
|
||||
* name: "my-agent",
|
||||
* instructions: "You are a helpful assistant",
|
||||
* llm: new VercelAIProvider(),
|
||||
* model: openai("gpt-4o"),
|
||||
* hooks
|
||||
* })
|
||||
|
|
@ -54,16 +79,12 @@ export function createSupermemoryHooks(
|
|||
const ctx = createSupermemoryContext(containerTag, options)
|
||||
|
||||
return {
|
||||
onPrepareMessages: async (
|
||||
args: HookPrepareMessagesArgs,
|
||||
): Promise<{ messages: VoltAgentMessage[] }> => {
|
||||
onPrepareMessages: async (args: HookPrepareMessagesArgs) => {
|
||||
try {
|
||||
// VoltAgent passes user messages in args.context.input.messages
|
||||
// and the prepared messages (system + conversation) in args.messages
|
||||
const contextInput = args.context?.input as
|
||||
| { messages?: VoltAgentMessage[] }
|
||||
| undefined
|
||||
const inputMessages = contextInput?.messages || []
|
||||
// VoltAgent 2.x supplies canonical UI messages directly on the hook.
|
||||
const inputMessages = (args.rawMessages ??
|
||||
args.messages) as unknown as VoltAgentMessage[]
|
||||
const preparedMessages = args.messages as unknown as VoltAgentMessage[]
|
||||
|
||||
ctx.logger.debug("onPrepareMessages called", {
|
||||
messageCount: args.messages.length,
|
||||
|
|
@ -74,7 +95,7 @@ export function createSupermemoryHooks(
|
|||
const enhancedMessages = await enhanceMessagesWithMemories(
|
||||
inputMessages,
|
||||
ctx,
|
||||
args.messages,
|
||||
preparedMessages,
|
||||
)
|
||||
|
||||
ctx.logger.debug("Messages enhanced with memories", {
|
||||
|
|
@ -82,7 +103,9 @@ export function createSupermemoryHooks(
|
|||
enhancedCount: enhancedMessages.length,
|
||||
})
|
||||
|
||||
return { messages: enhancedMessages }
|
||||
return {
|
||||
messages: enhancedMessages as unknown as typeof args.messages,
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error in onPrepareMessages", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
|
|
@ -102,19 +125,8 @@ export function createSupermemoryHooks(
|
|||
let messages: VoltAgentMessage[] = []
|
||||
|
||||
if (args.context?.input && args.output) {
|
||||
const inputData = args.context.input as
|
||||
| { messages?: VoltAgentMessage[] }
|
||||
| undefined
|
||||
const inputMessages = inputData?.messages || []
|
||||
|
||||
const outputData = args.output as
|
||||
| string
|
||||
| { text?: string; content?: string }
|
||||
| undefined
|
||||
const outputText =
|
||||
typeof outputData === "string"
|
||||
? outputData
|
||||
: outputData?.text || outputData?.content
|
||||
const inputMessages = getInputMessages(args.context.input)
|
||||
const outputText = getOutputText(args.output)
|
||||
|
||||
if (inputMessages.length > 0 && outputText) {
|
||||
messages = [
|
||||
|
|
|
|||
|
|
@ -43,15 +43,15 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* @param options.apiKey - Supermemory API key (falls back to SUPERMEMORY_API_KEY env var)
|
||||
* @param options.baseUrl - Custom Supermemory API base URL
|
||||
* @param options.promptTemplate - Custom function to format memory data into prompt
|
||||
* @param options.threshold - Search sensitivity: 0 (more results) to 1 (more accurate). Default: 0.1
|
||||
* @param options.limit - Maximum number of memory results to return. Default: 10
|
||||
* @param options.threshold - Search sensitivity: 0 (more results) to 1 (more accurate)
|
||||
* @param options.limit - Maximum number of memory results to return (integer from 1 to 100)
|
||||
* @param options.rerank - If true, rerank results for relevance. Default: false
|
||||
* @param options.rewriteQuery - If true, AI-rewrite query for better results (+400ms latency). Default: false
|
||||
* @param options.filters - Advanced AND/OR filters for search
|
||||
* @param options.include - Control what additional data to include (chunks, documents, etc.)
|
||||
* @param options.metadata - Optional metadata to attach to saved conversations
|
||||
* @param options.searchMode - Search mode: "memories" (atomic facts), "documents" (chunks), or "hybrid" (both)
|
||||
* @param options.entityContext - Context for memory extraction (max 1500 chars), guides how memories are understood
|
||||
* @param options.entityContext - Deprecated and ignored; configure entity context on the container tag instead
|
||||
* @returns Enhanced agent config with Supermemory hooks injected
|
||||
*
|
||||
* @example
|
||||
|
|
@ -59,14 +59,12 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* ```typescript
|
||||
* import { withSupermemory } from "@supermemory/tools/voltagent"
|
||||
* import { Agent } from "@voltagent/core"
|
||||
* import { VercelAIProvider } from "@voltagent/vercel-ai"
|
||||
* import { openai } from "@ai-sdk/openai"
|
||||
*
|
||||
* const configWithMemory = withSupermemory({
|
||||
* agentConfig: {
|
||||
* name: "my-agent",
|
||||
* instructions: "You are a helpful assistant",
|
||||
* llm: new VercelAIProvider(),
|
||||
* model: openai("gpt-4o"),
|
||||
* },
|
||||
* containerTag: "user-123",
|
||||
|
|
@ -83,7 +81,6 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* agentConfig: {
|
||||
* name: "my-agent",
|
||||
* instructions: "You are a helpful assistant",
|
||||
* llm: new VercelAIProvider(),
|
||||
* model: openai("gpt-4o"),
|
||||
* },
|
||||
* containerTag: "user-123", // Required: user/project ID
|
||||
|
|
@ -94,7 +91,6 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* limit: 15, // Max results to return
|
||||
* rerank: true, // Rerank for best relevance
|
||||
* searchMode: "hybrid", // "memories" | "documents" | "hybrid"
|
||||
* entityContext: "This is John, a software engineer saving technical discussions",
|
||||
* metadata: { // Custom metadata
|
||||
* source: "voltagent",
|
||||
* version: "1.0"
|
||||
|
|
@ -104,9 +100,9 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* const agent = new Agent(configWithMemory)
|
||||
*
|
||||
* // Use the agent - memories are automatically injected
|
||||
* const result = await agent.generateText({
|
||||
* messages: [{ role: "user", content: "What's my favorite programming language?" }]
|
||||
* })
|
||||
* const result = await agent.generateText(
|
||||
* "What's my favorite programming language?",
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
|
|
@ -116,7 +112,6 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* agentConfig: {
|
||||
* name: "my-agent",
|
||||
* instructions: "...",
|
||||
* llm: new VercelAIProvider(),
|
||||
* model: openai("gpt-4o"),
|
||||
* },
|
||||
* containerTag: "user-123",
|
||||
|
|
@ -138,7 +133,7 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
*/
|
||||
export function withSupermemory<T extends VoltAgentConfig>(
|
||||
options: WithSupermemoryOptions<T>,
|
||||
): T {
|
||||
): T & { hooks: NonNullable<VoltAgentConfig["hooks"]> } {
|
||||
const { agentConfig, containerTag, ...supermemoryOptions } = options
|
||||
|
||||
// Create Supermemory hooks (internally creates its own context, validates API key)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
import Supermemory from "supermemory"
|
||||
import {
|
||||
addConversation,
|
||||
type ContentPart as ConversationContentPart,
|
||||
type ConversationMessage,
|
||||
toConversationImageUrl,
|
||||
} from "../conversations-client"
|
||||
import {
|
||||
createLogger,
|
||||
|
|
@ -18,7 +20,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 +53,7 @@ export interface SupermemoryMiddlewareContext {
|
|||
limit?: number
|
||||
rerank?: boolean
|
||||
rewriteQuery?: boolean
|
||||
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
|
||||
filters?: SearchFilters
|
||||
include?: {
|
||||
chunks?: boolean
|
||||
documents?: boolean
|
||||
|
|
@ -58,7 +64,6 @@ export interface SupermemoryMiddlewareContext {
|
|||
// Storage parameters
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
searchMode?: "memories" | "documents" | "hybrid"
|
||||
entityContext?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,7 +94,6 @@ export const createSupermemoryContext = (
|
|||
include,
|
||||
metadata,
|
||||
searchMode,
|
||||
entityContext,
|
||||
verbose = false,
|
||||
} = options
|
||||
|
||||
|
|
@ -99,8 +103,25 @@ export const createSupermemoryContext = (
|
|||
"customId is required and must be a non-empty string — provide it via `options.customId`",
|
||||
)
|
||||
}
|
||||
if (
|
||||
threshold !== undefined &&
|
||||
(!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
|
||||
) {
|
||||
throw new Error("threshold must be between 0 and 1")
|
||||
}
|
||||
if (
|
||||
limit !== undefined &&
|
||||
(!Number.isInteger(limit) || limit < 1 || limit > 100)
|
||||
) {
|
||||
throw new Error("limit must be an integer between 1 and 100")
|
||||
}
|
||||
|
||||
const logger = createLogger(verbose)
|
||||
if (options.entityContext !== undefined) {
|
||||
logger.warn(
|
||||
"entityContext is not supported by /v4/conversations and will be ignored; configure it on the container tag instead.",
|
||||
)
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
|
||||
const client = new Supermemory({
|
||||
|
|
@ -129,7 +150,6 @@ export const createSupermemoryContext = (
|
|||
include,
|
||||
metadata,
|
||||
searchMode,
|
||||
entityContext,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +176,21 @@ const isNewUserTurn = (messages: VoltAgentMessage[]): boolean => {
|
|||
return lastMessage?.role === "user"
|
||||
}
|
||||
|
||||
type VoltAgentContentPart = {
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const getMessageContent = (
|
||||
message: VoltAgentMessage,
|
||||
): string | VoltAgentContentPart[] => {
|
||||
if (typeof message.content === "string" || Array.isArray(message.content)) {
|
||||
return message.content
|
||||
}
|
||||
return Array.isArray(message.parts) ? message.parts : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the last user message text from messages array.
|
||||
*/
|
||||
|
|
@ -169,7 +204,7 @@ const getLastUserMessage = (messages: VoltAgentMessage[]): string => {
|
|||
return ""
|
||||
}
|
||||
|
||||
const content = lastUserMessage.content
|
||||
const content = getMessageContent(lastUserMessage)
|
||||
|
||||
if (typeof content === "string") {
|
||||
return content
|
||||
|
|
@ -230,7 +265,7 @@ export const enhanceMessagesWithMemories = async (
|
|||
|
||||
const genericMessages = messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
content: getMessageContent(msg),
|
||||
}))
|
||||
|
||||
const queryText = extractQueryText(genericMessages, ctx.mode)
|
||||
|
|
@ -258,23 +293,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 +307,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 {
|
||||
|
|
@ -399,40 +419,58 @@ const convertToConversationMessages = (
|
|||
messages: VoltAgentMessage[],
|
||||
): ConversationMessage[] => {
|
||||
const conversationMessages: ConversationMessage[] = []
|
||||
const convertPart = (
|
||||
part: VoltAgentContentPart,
|
||||
): ConversationContentPart | null => {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text) {
|
||||
return { type: "text", text: part.text }
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
const mediaType = part.mediaType
|
||||
const url =
|
||||
typeof mediaType === "string" && mediaType.startsWith("image/")
|
||||
? toConversationImageUrl(part.url ?? part.data, mediaType)
|
||||
: null
|
||||
if (url) return { type: "image_url", imageUrl: { url } }
|
||||
}
|
||||
|
||||
if (part.type === "image") {
|
||||
const mediaType =
|
||||
typeof part.mediaType === "string" ? part.mediaType : "image/jpeg"
|
||||
const url = toConversationImageUrl(part.image, mediaType)
|
||||
if (url) return { type: "image_url", imageUrl: { url } }
|
||||
}
|
||||
|
||||
if (part.type === "image_url") {
|
||||
const imageUrl =
|
||||
typeof part.imageUrl === "object" && part.imageUrl
|
||||
? (part.imageUrl as { url?: unknown })
|
||||
: typeof part.image_url === "object" && part.image_url
|
||||
? (part.image_url as { url?: unknown })
|
||||
: undefined
|
||||
if (typeof imageUrl?.url === "string") {
|
||||
return { type: "image_url", imageUrl: { url: imageUrl.url } }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content) {
|
||||
conversationMessages.push({
|
||||
role: msg.role as "user" | "assistant" | "tool",
|
||||
content: msg.content,
|
||||
})
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const contentParts = msg.content
|
||||
.map((c) => {
|
||||
if (c.type === "text" && c.text) {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: c.text,
|
||||
}
|
||||
}
|
||||
// Handle image URLs if present
|
||||
if (c.type === "image_url" && typeof c.image_url === "object") {
|
||||
const imageUrl = c.image_url as { url?: string }
|
||||
if (imageUrl.url) {
|
||||
return {
|
||||
type: "image_url" as const,
|
||||
image_url: { url: imageUrl.url },
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const structuredParts = Array.isArray(msg.parts)
|
||||
? msg.parts
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: undefined
|
||||
|
||||
if (structuredParts) {
|
||||
const contentParts = structuredParts
|
||||
.map(convertPart)
|
||||
.filter((part) => part !== null)
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
|
|
@ -441,6 +479,13 @@ const convertToConversationMessages = (
|
|||
content: contentParts,
|
||||
})
|
||||
}
|
||||
} else if (typeof msg.content === "string") {
|
||||
if (msg.content) {
|
||||
conversationMessages.push({
|
||||
role: msg.role as "user" | "assistant" | "tool",
|
||||
content: msg.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +516,6 @@ export const saveConversation = async (
|
|||
messages: conversationMessages,
|
||||
containerTags: [ctx.containerTag],
|
||||
metadata: ctx.metadata,
|
||||
entityContext: ctx.entityContext,
|
||||
apiKey: ctx.apiKey,
|
||||
baseUrl: ctx.normalizedBaseUrl,
|
||||
})
|
||||
|
|
|
|||
109
packages/tools/src/voltagent/options.ts
Normal file
109
packages/tools/src/voltagent/options.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Peer-free configuration types for the VoltAgent integration.
|
||||
*
|
||||
* This module intentionally avoids importing @voltagent/core so the root
|
||||
* @supermemory/tools declarations remain usable when the optional peer is absent.
|
||||
*/
|
||||
|
||||
import type Supermemory from "supermemory"
|
||||
import type { SupermemoryBaseOptions } from "../shared"
|
||||
|
||||
/**
|
||||
* Configuration options for the Supermemory VoltAgent integration.
|
||||
* Extends base options with VoltAgent-specific settings.
|
||||
*/
|
||||
export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
|
||||
/**
|
||||
* Custom ID to group messages into a single document.
|
||||
* Ensures related messages are added to the same document for that conversation.
|
||||
*/
|
||||
customId: string
|
||||
|
||||
/**
|
||||
* Threshold / sensitivity for memory selection. 0 is least sensitive (returns
|
||||
* most memories, more results), 1 is most sensitive (returns fewer memories,
|
||||
* more accurate results). When omitted, the selected backend route applies
|
||||
* its own default.
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
threshold?: number
|
||||
|
||||
/**
|
||||
* Maximum number of memory results to return. Must be an integer between 1
|
||||
* and 100. When omitted, the selected backend route applies its own default.
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
limit?: number
|
||||
|
||||
/**
|
||||
* If true, rerank the results based on the query. This helps ensure the most
|
||||
* relevant results are returned. Default: false
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
rerank?: boolean
|
||||
|
||||
/**
|
||||
* If true, rewrites the query to make it easier to find memories. This increases
|
||||
* latency by about 400ms. Default: false
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
rewriteQuery?: boolean
|
||||
|
||||
/**
|
||||
* Advanced filters to apply to the search using AND/OR logic.
|
||||
* Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] }
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
filters?: SearchFilters
|
||||
|
||||
/**
|
||||
* Control what additional data to include in search results.
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
include?: IncludeOptions
|
||||
|
||||
/**
|
||||
* Optional metadata to attach to saved documents/conversations.
|
||||
* Can include strings, numbers, or booleans.
|
||||
*/
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
|
||||
/**
|
||||
* Search mode controlling what type of results to search.
|
||||
* - "memories": Search only memory entries (atomic facts)
|
||||
* - "documents": Search only document chunks
|
||||
* - "hybrid": Search both memories AND document chunks (recommended)
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
searchMode?: "memories" | "documents" | "hybrid"
|
||||
|
||||
/**
|
||||
* @deprecated The conversations API does not accept per-request entity context.
|
||||
* Configure entity context on the container tag instead.
|
||||
*/
|
||||
entityContext?: string
|
||||
}
|
||||
|
||||
/** Advanced search filters using AND/OR logic. */
|
||||
export type SearchFilters = NonNullable<Supermemory.SearchParams["filters"]>
|
||||
|
||||
/** Options for including additional data in search results. */
|
||||
export interface IncludeOptions {
|
||||
/** Fetch chunks from documents associated with found memories. */
|
||||
chunks?: boolean
|
||||
/** Include full document information in results. */
|
||||
documents?: boolean
|
||||
/** Include explicitly forgotten or expired memories. */
|
||||
forgottenMemories?: boolean
|
||||
/** Include parent/child memories from the memory graph. */
|
||||
relatedMemories?: boolean
|
||||
/** Include document summaries in results. */
|
||||
summaries?: boolean
|
||||
}
|
||||
|
|
@ -5,219 +5,49 @@
|
|||
* Supermemory by providing hooks that inject memories before LLM calls.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentHooks,
|
||||
AgentOptions,
|
||||
OnEndHookArgs,
|
||||
OnPrepareMessagesHookArgs,
|
||||
OnStartHookArgs,
|
||||
} from "@voltagent/core"
|
||||
import type {
|
||||
PromptTemplate,
|
||||
MemoryMode,
|
||||
AddMemoryMode,
|
||||
MemoryPromptData,
|
||||
SupermemoryBaseOptions,
|
||||
} from "../shared"
|
||||
|
||||
/**
|
||||
* Configuration options for the Supermemory VoltAgent integration.
|
||||
* Extends base options with VoltAgent-specific settings.
|
||||
*/
|
||||
export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
|
||||
/**
|
||||
* Custom ID to group messages into a single document.
|
||||
* Ensures related messages are added to the same document for that conversation.
|
||||
*/
|
||||
customId: string
|
||||
|
||||
/**
|
||||
* Threshold / sensitivity for memory selection. 0 is least sensitive (returns
|
||||
* most memories, more results), 1 is most sensitive (returns fewer memories,
|
||||
* more accurate results). Default: 0.1
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
threshold?: number
|
||||
|
||||
/**
|
||||
* Maximum number of memory results to return. Default: 10
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
limit?: number
|
||||
|
||||
/**
|
||||
* If true, rerank the results based on the query. This helps ensure the most
|
||||
* relevant results are returned. Default: false
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
rerank?: boolean
|
||||
|
||||
/**
|
||||
* If true, rewrites the query to make it easier to find memories. This increases
|
||||
* latency by about 400ms. Default: false
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
rewriteQuery?: boolean
|
||||
|
||||
/**
|
||||
* Advanced filters to apply to the search using AND/OR logic.
|
||||
* Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] }
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
filters?: SearchFilters
|
||||
|
||||
/**
|
||||
* Control what additional data to include in search results
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
include?: IncludeOptions
|
||||
|
||||
/**
|
||||
* Optional metadata to attach to saved documents/conversations.
|
||||
* Can include strings, numbers, or booleans.
|
||||
*/
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
|
||||
/**
|
||||
* Search mode controlling what type of results to search.
|
||||
* - "memories": Search only memory entries (atomic facts)
|
||||
* - "documents": Search only document chunks
|
||||
* - "hybrid": Search both memories AND document chunks (recommended)
|
||||
*
|
||||
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
|
||||
*/
|
||||
searchMode?: "memories" | "documents" | "hybrid"
|
||||
|
||||
/**
|
||||
* Context for memory extraction when saving conversations.
|
||||
* Helps guide how memories are extracted and understood from content.
|
||||
* Max 1500 characters.
|
||||
* Example: "This is John, saving items in a personal knowledge management system"
|
||||
*/
|
||||
entityContext?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced search filters using AND/OR logic
|
||||
*/
|
||||
export type SearchFilters = { OR: Array<unknown> } | { AND: Array<unknown> }
|
||||
|
||||
/**
|
||||
* Options for including additional data in search results
|
||||
*/
|
||||
export interface IncludeOptions {
|
||||
/**
|
||||
* If true, fetch and return chunks from documents associated with found memories.
|
||||
* Performs vector search on chunks within those documents.
|
||||
*/
|
||||
chunks?: boolean
|
||||
|
||||
/**
|
||||
* If true, include full document information in results
|
||||
*/
|
||||
documents?: boolean
|
||||
|
||||
/**
|
||||
* If true, include forgotten memories in search results. Forgotten memories are
|
||||
* memories that have been explicitly forgotten or have passed their expiration date.
|
||||
*/
|
||||
forgottenMemories?: boolean
|
||||
|
||||
/**
|
||||
* If true, include related memories (parents/children in the memory graph)
|
||||
*/
|
||||
relatedMemories?: boolean
|
||||
|
||||
/**
|
||||
* If true, include document summaries in results
|
||||
*/
|
||||
summaries?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* VoltAgent message format (simplified to avoid direct dependency).
|
||||
* Compatible with VoltAgent's Message type.
|
||||
* VoltAgent message format used internally by the integration.
|
||||
* Compatible with current UI and model message shapes.
|
||||
*/
|
||||
export interface VoltAgentMessage {
|
||||
role: "system" | "user" | "assistant" | "tool"
|
||||
content:
|
||||
content?:
|
||||
| string
|
||||
| Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
parts?: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal VoltAgent AgentConfig interface representing properties we enhance.
|
||||
* This avoids a direct dependency on @voltagent/core while staying type-safe.
|
||||
*/
|
||||
export interface VoltAgentConfig {
|
||||
name: string
|
||||
instructions?: string
|
||||
model?: unknown
|
||||
llm?: unknown
|
||||
hooks?: VoltAgentHooks
|
||||
[key: string]: unknown
|
||||
/** VoltAgent agent configuration accepted by the integration. */
|
||||
export type VoltAgentConfig = Omit<AgentOptions, "hooks"> & {
|
||||
hooks?: AgentHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* VoltAgent hooks interface (simplified).
|
||||
* Hooks allow intercepting agent lifecycle events.
|
||||
*/
|
||||
export interface VoltAgentHooks {
|
||||
onStart?: (args: HookStartArgs) => void | Promise<void>
|
||||
onPrepareMessages?: (
|
||||
args: HookPrepareMessagesArgs,
|
||||
) =>
|
||||
| { messages?: VoltAgentMessage[] }
|
||||
| Promise<{ messages?: VoltAgentMessage[] }>
|
||||
onEnd?: (args: HookEndArgs) => void | Promise<void>
|
||||
[key: string]: unknown
|
||||
}
|
||||
/** Current VoltAgent peer types used by the public integration contract. */
|
||||
export type VoltAgentHooks = AgentHooks
|
||||
export type HookStartArgs = OnStartHookArgs
|
||||
export type HookPrepareMessagesArgs = OnPrepareMessagesHookArgs
|
||||
export type HookEndArgs = OnEndHookArgs
|
||||
|
||||
/**
|
||||
* Arguments passed to onStart hook.
|
||||
*/
|
||||
export interface HookStartArgs {
|
||||
agent: {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
context?: {
|
||||
messages?: VoltAgentMessage[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments passed to onPrepareMessages hook.
|
||||
*/
|
||||
export interface HookPrepareMessagesArgs {
|
||||
messages: VoltAgentMessage[]
|
||||
agent: {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
context?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments passed to onEnd hook.
|
||||
*/
|
||||
export interface HookEndArgs {
|
||||
agent: {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
context?: {
|
||||
input?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
output?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type {
|
||||
IncludeOptions,
|
||||
SearchFilters,
|
||||
SupermemoryVoltAgent,
|
||||
} from "./options"
|
||||
|
||||
// Re-export shared types for convenience
|
||||
export type { PromptTemplate, MemoryMode, AddMemoryMode, MemoryPromptData }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue