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:
Dhravya 2026-09-01 05:59:57 +00:00
parent 5fee2f2872
commit de3bbb3ce9
No known key found for this signature in database
GPG key ID: 135A27003CF4F6CB
26 changed files with 1577 additions and 656 deletions

View file

@ -9,97 +9,231 @@ on:
- "packages/pipecat-sdk-python/**"
- ".github/workflows/ci-python.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
jobs:
agent-framework-python:
name: agent-framework-python
name: agent-framework-python (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.13"]
defaults:
run:
working-directory: packages/agent-framework-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/agent-framework-python/pyproject.toml
- name: Install package and test dependencies
run: |
pip install -e .
pip install pytest pytest-asyncio
- name: Install build and test tools
run: python -m pip install build pytest pytest-asyncio
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel and runtime dependencies
run: python -m pip install "$RUNNER_TEMP"/wheels/*.whl
- name: Check dependency compatibility
run: python -m pip check
- name: Verify installed wheel
run: >-
python -c "from pathlib import Path; import supermemory_agent_framework;
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
- name: Run tests
run: pytest
run: python -m pytest
openai-sdk-python:
name: openai-sdk-python
name: openai-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.9"
dependency-lane: minimum-supermemory
supermemory-version: "3.50.0"
expected-supermemory-version: "3.50.0"
- python-version: "3.12"
dependency-lane: locked
supermemory-version: ""
expected-supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/openai-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
- name: Install package and test dependencies
run: |
pip install -e .
pip install pytest pytest-asyncio python-dotenv
- name: Setup uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.12.5"
enable-cache: true
working-directory: packages/openai-sdk-python
cache-dependency-glob: uv.lock
- name: Run tests
run: pytest
- name: Install locked dependencies
run: uv sync --locked --python "${{ matrix.python-version }}"
- name: Build wheel
run: uv build --wheel --out-dir "$RUNNER_TEMP/wheels"
- name: Install built wheel
run: >-
uv pip install --python .venv/bin/python --reinstall --no-deps
"$RUNNER_TEMP"/wheels/*.whl
- name: Install minimum Supermemory SDK
if: matrix.supermemory-version != ''
run: >-
uv pip install --python .venv/bin/python
"supermemory==${{ matrix.supermemory-version }}"
- name: Check dependency compatibility
run: uv pip check --python .venv/bin/python
- name: Verify installed wheel and SDK version
run: >-
.venv/bin/python -c "from importlib.metadata import version;
from pathlib import Path; import supermemory_openai;
assert version('supermemory') == '${{ matrix.expected-supermemory-version }}';
assert 'site-packages' in Path(supermemory_openai.__file__).parts"
- name: Run tests without changing the verified environment
run: .venv/bin/python -m pytest
cartesia-sdk-python:
name: cartesia-sdk-python
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-supermemory
supermemory-spec: "supermemory==3.16.0"
supermemory-version: "3.16.0"
- python-version: "3.12"
dependency-lane: current-supermemory
supermemory-spec: "supermemory==3.59.0"
supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/cartesia-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
# Suite stubs cartesia-line / loguru / pydantic at import time, so it
# runs against sources with nothing installed.
- name: Run tests
run: PYTHONPATH=src python -m unittest discover -s tests -v
- name: Install build tools and lightweight test dependencies
run: >-
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
"${{ matrix.supermemory-spec }}"
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel without the voice framework
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
- name: Run tests against the installed wheel and real lightweight dependencies
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
import supermemory_cartesia;
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
raise SystemExit(result)"
pipecat-sdk-python:
name: pipecat-sdk-python
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.10"
dependency-lane: minimum-supermemory
supermemory-spec: "supermemory==3.16.0"
supermemory-version: "3.16.0"
- python-version: "3.12"
dependency-lane: current-supermemory
supermemory-spec: "supermemory==3.59.0"
supermemory-version: "3.59.0"
defaults:
run:
working-directory: packages/pipecat-sdk-python
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
# Suite stubs pipecat-ai / loguru / pydantic at import time, so it
# runs against sources with nothing installed.
- name: Run tests
run: PYTHONPATH=src python -m unittest discover -s tests -v
- name: Install build tools and lightweight test dependencies
run: >-
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
"${{ matrix.supermemory-spec }}"
- name: Build wheel
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
- name: Install wheel without the voice framework
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
- name: Run tests against the installed wheel and real lightweight dependencies
run: >-
python -c "from importlib.metadata import version;
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
assert version('supermemory') == '${{ matrix.supermemory-version }}';
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
import supermemory_pipecat;
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
raise SystemExit(result)"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import Supermemory from "supermemory"
import { getContainerTags } from "./tools-shared"
import { deleteDocumentById, getContainerTags } from "./tools-shared"
import type { SupermemoryToolsConfig } from "./types"
// Claude Memory Tool Types
@ -37,6 +37,14 @@ export interface MemoryToolResult {
is_error: boolean
}
type ClaudeFileMetadata = Record<string, string | number | boolean | string[]>
interface ClaudeFileDocument {
documentId: string
content: string
metadata: ClaudeFileMetadata
}
/**
* Claude Memory Tool - Client-side implementation
* Maps Claude's memory tool commands to supermemory document operations
@ -44,6 +52,7 @@ export interface MemoryToolResult {
export class ClaudeMemoryTool {
private client: Supermemory
private containerTags: string[]
private scopeContainerTags: [string, ...string[]]
private memoryContainerPrefix: string
/**
@ -68,6 +77,7 @@ export class ClaudeMemoryTool {
// Get base container tags and add memory-specific tag
const baseContainerTags = getContainerTags(config)
this.scopeContainerTags = baseContainerTags
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]
}
@ -140,7 +150,7 @@ export class ClaudeMemoryTool {
default:
return {
success: false,
error: `Unknown command: ${(command as any).command}`,
error: `Unknown command: ${(command as { command: string }).command}`,
}
}
} catch (error) {
@ -193,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
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

@ -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))
)
}
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View file

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