From d5937b8b26fdb7aebee94d637626d8be96b0f7e8 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 7 Aug 2026 19:39:50 -0700 Subject: [PATCH 1/7] feat(tools): align 7-tool surface and refresh tool descriptions Update shared tool descriptions (proactive search, documentAdd guidance), export TOOL_DESCRIPTIONS from package index, and align OpenAI/AI SDK tool schemas with memoryForget and document operations. Co-authored-by: Cursor --- packages/tools/package.json | 3 +- packages/tools/src/ai-sdk.ts | 9 +-- packages/tools/src/claude-memory.test.ts | 72 +++++++++++----------- packages/tools/src/claude-memory.ts | 34 +++++++--- packages/tools/src/index.ts | 7 +++ packages/tools/src/openai/tools.ts | 14 ++--- packages/tools/src/tool-operations.test.ts | 12 ++-- packages/tools/src/tools-shared.ts | 28 +++++---- 8 files changed, 102 insertions(+), 77 deletions(-) diff --git a/packages/tools/package.json b/packages/tools/package.json index 59289f3d9..a4d0034f3 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -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 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": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index f8d88154f..b7e437dbc 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -56,12 +56,12 @@ export const searchMemoriesTool = ( limit = DEFAULT_VALUES.limit, }) => { try { - const response = await client.search.execute({ + const response = await client.search({ q: informationToGet, - containerTags, + ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), limit, - chunkThreshold: DEFAULT_VALUES.chunkThreshold, - includeFullDocs, + threshold: DEFAULT_VALUES.chunkThreshold, + searchMode: "hybrid", }) return { @@ -373,3 +373,4 @@ export function supermemoryTools( } export { withSupermemory } from "./vercel" +export { getContainerTags } from "./tools-shared" diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index c4f62f16c..7d16493fe 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -2,16 +2,17 @@ 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() +// `client.search()` to return a single document with known multi-line content. +const searchMock = vi.fn() const addMock = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = { execute: searchExecute } + search = searchMock add = addMock memories = { forget: vi.fn() } + documents = { delete: vi.fn() } }, } }) @@ -23,10 +24,10 @@ const FILE_PATH = "/memories/notes.txt" const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5" function mockDocument(content: string) { - // `readFile` matches by `documentId === normalizePathToCustomId(path)`. + // `readFile` matches by `id === normalizePathToCustomId(path)`. // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt" - searchExecute.mockResolvedValue({ - results: [{ documentId: "memories_notes_txt", content }], + searchMock.mockResolvedValue({ + results: [{ id: "memories_notes_txt", chunk: content }], }) } @@ -34,7 +35,7 @@ describe("ClaudeMemoryTool view_range", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + searchMock.mockReset() mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) @@ -89,16 +90,16 @@ describe("ClaudeMemoryTool exact-file matching", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + searchMock.mockReset() addMock.mockReset() tool = new ClaudeMemoryTool("test-api-key") }) it("view finds the exact file even when a neighbour ranks first", async () => { - searchExecute.mockResolvedValue({ + searchMock.mockResolvedValue({ results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, - { documentId: "memories_notes_txt", content: FILE_CONTENT }, + { id: "memories_notes_backup_txt", chunk: "backup stuff" }, + { id: "memories_notes_txt", chunk: FILE_CONTENT }, ], }) @@ -115,9 +116,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 // be served as the requested one. - searchExecute.mockResolvedValue({ + searchMock.mockResolvedValue({ results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + { id: "memories_notes_backup_txt", chunk: "backup stuff" }, ], }) @@ -131,9 +132,9 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("str_replace refuses to modify a different file than requested", async () => { - searchExecute.mockResolvedValue({ + searchMock.mockResolvedValue({ results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + { id: "memories_notes_backup_txt", chunk: "backup stuff" }, ], }) @@ -153,31 +154,28 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + searchMock.mockReset() addMock.mockReset() - searchExecute.mockResolvedValue({ - results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }], + searchMock.mockResolvedValue({ + results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }], }) tool = new ClaudeMemoryTool("test-api-key") }) - it.each([ - "$&", - "$'", - "$`", - "$$", - ])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => { - const result = await tool.handleCommand({ - command: "str_replace", - path: FILE_PATH, - old_str: "line3", - new_str: `price is ${dollarSequence} today`, - }) - - expect(result.success).toBe(true) - expect(addMock).toHaveBeenCalledTimes(1) - const stored = addMock.mock.calls[0]?.[0]?.content as string - expect(stored).toContain(`price is ${dollarSequence} today`) - expect(stored).not.toContain("line3") - }) + it.each(["$&", "$'", "$`", "$$"])( + "stores %s literally instead of expanding it as a replacement pattern", + async (dollarSequence) => { + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "line3", + new_str: `price is ${dollarSequence} today`, + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toContain(`price is ${dollarSequence} today`) + }, + ) }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 8c665701a..ad05c108c 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -194,11 +194,13 @@ export class ClaudeMemoryTool { private async listDirectory(dirPath: string): Promise { try { // Search for all memory files - const response = await this.client.search.execute({ + const response = await this.client.search({ q: "*", // Search for all - containerTags: this.containerTags, + ...(this.containerTags[0] + ? { containerTag: this.containerTags[0] } + : {}), limit: 100, // Get many files (max allowed) - includeFullDocs: false, + searchMode: "hybrid", }) if (!response.results) { @@ -577,30 +579,42 @@ export class ClaudeMemoryTool { try { const normalizedId = this.normalizePathToCustomId(filePath) - const response = await this.client.search.execute({ + const response = await this.client.search({ q: normalizedId, - containerTags: this.containerTags, + ...(this.containerTags[0] + ? { containerTag: this.containerTags[0] } + : {}), limit: 5, - includeFullDocs: true, + searchMode: "hybrid", }) // 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, + const match = response.results?.find( + (r) => + r.id === normalizedId || + r.documents?.some((d) => d.id === normalizedId), ) - if (!document) { + if (!match) { return { success: false, error: `File not found: ${filePath}`, } } + const content = match.chunk || match.memory || "" + const documentId = match.documents?.[0]?.id ?? match.id + return { success: true, - document, + document: { + documentId, + content, + raw: content, + metadata: match.metadata, + }, } } catch (error) { return { diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index c93deae13..b1a43d9bd 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -3,3 +3,10 @@ export type { SupermemoryToolsConfig } from "./types" export type { OpenAIMiddlewareOptions } from "./openai" export type { SupermemoryVoltAgent } from "./voltagent" + +export { + TOOL_DESCRIPTIONS, + PARAMETER_DESCRIPTIONS, + DEFAULT_VALUES, + getContainerTags, +} from "./tools-shared" diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 4695c9205..49b5c88a0 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -14,14 +14,14 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited>["results"] + results?: Awaited>["results"] count?: number error?: string } export interface MemoryAddResult { success: boolean - memory?: Awaited> + memory?: Awaited> error?: string } @@ -31,7 +31,7 @@ export interface ProfileResult { static: string[] dynamic: string[] } - searchResults?: Awaited> + searchResults?: Awaited> error?: string } @@ -248,12 +248,12 @@ export function createSearchMemoriesFunction( limit?: number }): Promise { try { - const response = await client.search.execute({ + const response = await client.search({ q: informationToGet, - containerTags, + ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), limit, - chunkThreshold: DEFAULT_VALUES.chunkThreshold, - includeFullDocs, + threshold: DEFAULT_VALUES.chunkThreshold, + searchMode: "hybrid", }) return { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 69f125940..22d3bc872 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -4,13 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // executions can be verified deterministically without network access. const documentsDelete = vi.fn() const documentsList = vi.fn() -const searchExecute = vi.fn() +const searchMock = vi.fn() const clientAdd = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = { execute: searchExecute } + search = searchMock add = clientAdd documents = { delete: documentsDelete, @@ -40,7 +40,7 @@ beforeEach(() => { memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) - searchExecute.mockReset() + searchMock.mockReset() clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) vi.unstubAllGlobals() }) @@ -163,11 +163,11 @@ describe("ClaudeMemoryTool", () => { const CUSTOM_ID = "memories_prefs_txt" function mockFileDocument(content: string) { - searchExecute.mockResolvedValue({ + searchMock.mockResolvedValue({ results: [ { - documentId: CUSTOM_ID, - content, + id: CUSTOM_ID, + chunk: content, metadata: { file_path: FILE_PATH }, }, ], diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index 80ba33a6c..cbb1c1a72 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -7,24 +7,25 @@ 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 (recall) stored memories for facts, preferences, history, and context about the user or any topic. Use proactively before answering whenever memory could help — do not wait for the user to explicitly ask you to search or recall. Search when the question touches personal context, past conversations, preferences, projects, people, plans, or anything you may have learned before. Results include memory/chunk IDs — use those IDs with memoryForget to remove a specific learned fact.", 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 user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query. Profile and search result entries may include memory IDs useful for memoryForget.", 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. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.", 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 document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).", 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-delete a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId (preferred — from searchMemories or getProfile) OR memoryContent for an exact text match. Use when the user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). To remove an entire conversation or file, use documentDelete instead.", } as const // Parameter descriptions export const PARAMETER_DESCRIPTIONS = { - informationToGet: "Terms to search for in the user's memories", + informationToGet: + "What to look up in memory — keywords from the user's message, topic, entity names, or question phrasing. Search even when the user did not explicitly ask you to recall.", includeFullDocs: "Whether to include the full document content in the response. Defaults to true for better AI context.", limit: "Maximum number of results to return", @@ -33,14 +34,17 @@ export const PARAMETER_DESCRIPTIONS = { containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)", query: "Optional search query to include relevant search results", page: "Page number to fetch, 1-based (default: 1)", - 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 — permanently deletes the source document and all extracted memories. 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 searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.", 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, search first and use memoryId.", + reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)", } as const // Default values From 61030190d978c6b7f8b26d71ff98aab72f596c8b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:19:56 +0000 Subject: [PATCH 2/7] chore: update bun.lock Co-Authored-By: Claude Opus 4.5 --- bun.lock | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index d55c8237e..3eb49d31f 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,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": { @@ -5536,9 +5536,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=="], From d21d66138085382143c2f14c5d41ac17df8d8c73 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:13:25 +0530 Subject: [PATCH 3/7] fix(tools): harden seven-tool parity --- .github/workflows/ci.yml | 17 ++ bun.lock | 2 +- packages/tools/package.json | 4 +- packages/tools/src/ai-sdk.ts | 33 ++- packages/tools/src/claude-memory.test.ts | 99 ++++--- packages/tools/src/claude-memory.ts | 325 ++++++++++++++++----- packages/tools/src/openai/tools.ts | 34 ++- packages/tools/src/tool-operations.test.ts | 51 +++- packages/tools/src/tools-shared.ts | 180 +++++++++++- packages/tools/src/voltagent/middleware.ts | 61 ++-- packages/tools/src/voltagent/types.ts | 5 +- 11 files changed, 616 insertions(+), 195 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae50..62670c9b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/bun.lock b/bun.lock index 3eb49d31f..2084333c3 100644 --- a/bun.lock +++ b/bun.lock @@ -336,7 +336,7 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "2.1.1", + "version": "2.2.0", "dependencies": { "@ai-sdk/anthropic": "^2.0.25", "@ai-sdk/openai": "^2.0.23", diff --git a/packages/tools/package.json b/packages/tools/package.json index a4d0034f3..74f2f5bca 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,14 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.1.1", + "version": "2.2.0", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index b7e437dbc..1b7108087 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -5,6 +5,7 @@ import { DEFAULT_VALUES, PARAMETER_DESCRIPTIONS, TOOL_DESCRIPTIONS, + deleteDocumentByIdentifier, getContainerTags, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" @@ -56,12 +57,12 @@ export const searchMemoriesTool = ( limit = DEFAULT_VALUES.limit, }) => { try { - const response = await client.search({ + const response = await client.search.documents({ q: informationToGet, - ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), + containerTags, limit, - threshold: DEFAULT_VALUES.chunkThreshold, - searchMode: "hybrid", + chunkThreshold: DEFAULT_VALUES.chunkThreshold, + includeFullDocs, }) return { @@ -196,10 +197,12 @@ export const documentListTool = ( }), execute: async ({ containerTag, limit, page }) => { try { - const tag = containerTag || containerTags[0] + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags const response = await client.documents.list({ - containerTags: [tag], + containerTags: scopeTags, limit: limit || DEFAULT_VALUES.limit, ...(page !== undefined && { page }), }) @@ -227,15 +230,29 @@ export const documentDeleteTool = ( apiKey, ...(config?.baseUrl ? { baseURL: config.baseUrl } : {}), }) + const containerTags = getContainerTags(config) + const strict = config?.strict ?? false return tool({ description: TOOL_DESCRIPTIONS.documentDelete, inputSchema: z.object({ documentId: z.string().describe(PARAMETER_DESCRIPTIONS.documentId), + containerTag: strict + ? z + .string() + .nullable() + .describe(PARAMETER_DESCRIPTIONS.documentContainerTag) + : z + .string() + .optional() + .describe(PARAMETER_DESCRIPTIONS.documentContainerTag), }), - execute: async ({ documentId }) => { + execute: async ({ documentId, containerTag }) => { try { - await client.documents.delete(documentId) + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags + await deleteDocumentByIdentifier(client, documentId, scopeTags) return { success: true, diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7d16493fe..afe16dcf2 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path -// can be exercised deterministically without any network access. We only need -// `client.search()` to return a single document with known multi-line content. -const searchMock = vi.fn() +// Mock the Supermemory SDK so the Claude memory tool's document-backed file +// operations can be exercised deterministically without any network access. +const documentsListMock = vi.fn() +const documentsGetMock = vi.fn() +const documentsDeleteBulkMock = vi.fn() const addMock = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = searchMock add = addMock memories = { forget: vi.fn() } - documents = { delete: vi.fn() } + documents = { + list: documentsListMock, + get: documentsGetMock, + deleteBulk: documentsDeleteBulkMock, + } }, } }) @@ -22,20 +26,58 @@ import { ClaudeMemoryTool } from "./claude-memory" const FILE_PATH = "/memories/notes.txt" // 5 distinct lines so an off-by-one at either end is observable. const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5" +const FILE_DOCUMENT = { + id: "document-notes", + customId: "memories_notes_txt", + filePath: FILE_PATH, + content: FILE_CONTENT, +} +const NEIGHBOUR_DOCUMENT = { + id: "document-notes-backup", + customId: "memories_notes_backup_txt", + filePath: "/memories/notes.backup.txt", + content: "backup stuff", +} -function mockDocument(content: string) { - // `readFile` matches by `id === normalizePathToCustomId(path)`. - // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt" - searchMock.mockResolvedValue({ - results: [{ id: "memories_notes_txt", chunk: content }], +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) { + mockDocuments([{ ...FILE_DOCUMENT, content }]) +} + describe("ClaudeMemoryTool view_range", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) @@ -90,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() addMock.mockReset() tool = new ClaudeMemoryTool("test-api-key") }) - it("view finds the exact file even when a neighbour ranks first", async () => { - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - { id: "memories_notes_txt", chunk: FILE_CONTENT }, - ], - }) + it("view finds the exact file even when a neighbour is listed first", async () => { + mockDocuments([NEIGHBOUR_DOCUMENT, FILE_DOCUMENT]) const result = await tool.handleCommand({ command: "view", @@ -114,13 +152,9 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("view reports not-found instead of returning a different file", async () => { - // Semantic search can surface a similarly-named file; that must not + // The document list can contain a similarly-named file; that must not // be served as the requested one. - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - ], - }) + mockDocuments([NEIGHBOUR_DOCUMENT]) const result = await tool.handleCommand({ command: "view", @@ -132,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("str_replace refuses to modify a different file than requested", async () => { - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - ], - }) + mockDocuments([NEIGHBOUR_DOCUMENT]) const result = await tool.handleCommand({ command: "str_replace", @@ -154,11 +184,10 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() addMock.mockReset() - searchMock.mockResolvedValue({ - results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }], - }) + mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index ad05c108c..3792e9f8f 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -1,5 +1,5 @@ import Supermemory from "supermemory" -import { getContainerTags } from "./tools-shared" +import { deleteDocumentById, getContainerTags } from "./tools-shared" import type { SupermemoryToolsConfig } from "./types" // Claude Memory Tool Types @@ -37,6 +37,14 @@ export interface MemoryToolResult { is_error: boolean } +type ClaudeFileMetadata = Record + +interface ClaudeFileDocument { + documentId: string + content: string + metadata: ClaudeFileMetadata +} + /** * Claude Memory Tool - Client-side implementation * Maps Claude's memory tool commands to supermemory document operations @@ -44,6 +52,7 @@ export interface MemoryToolResult { export class ClaudeMemoryTool { private client: Supermemory private containerTags: string[] + private scopeContainerTags: [string, ...string[]] private memoryContainerPrefix: string /** @@ -68,6 +77,7 @@ export class ClaudeMemoryTool { // Get base container tags and add memory-specific tag const baseContainerTags = getContainerTags(config) + this.scopeContainerTags = baseContainerTags this.containerTags = [...baseContainerTags, this.memoryContainerPrefix] } @@ -193,44 +203,89 @@ export class ClaudeMemoryTool { */ private async listDirectory(dirPath: string): Promise { try { - // Search for all memory files - const response = await this.client.search({ - q: "*", // Search for all - ...(this.containerTags[0] - ? { containerTag: this.containerTags[0] } - : {}), - limit: 100, // Get many files (max allowed) - searchMode: "hybrid", - }) + // Document search returns ranked chunks, not a complete inventory. Walk + // every page of the document-list endpoint so files cannot disappear + // from a directory merely because they did not rank in a search page. + const documents: Supermemory.DocumentListResponse.Memory[] = [] + let page = 1 + + 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, + }) - if (!response.results) { - return { - success: true, - content: `Directory: ${dirPath}\n(empty)`, - } + documents.push(...response.memories) + + if (page >= response.pagination.totalPages) break + page += 1 } // Filter files that match the directory path and extract relative paths const files: string[] = [] const dirs = new Set() + const candidates: Array<{ + document: Supermemory.DocumentListResponse.Memory + filePath: string + }> = [] + + for (const document of documents) { + if (!this.isDocumentInConfiguredScope(document)) continue - 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 - - // 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) + const filePath = this.getDocumentFilePath(document) + if (!filePath || !filePath.startsWith(dirPath)) { + continue + } + candidates.push({ document, filePath }) + } + + // Full GETs are required to verify hidden project tags. Keep them bounded + // so large directories do not become a long serial chain or a burst of + // unbounded requests. + const verificationBatchSize = 8 + for ( + let index = 0; + index < candidates.length; + index += verificationBatchSize + ) { + const batch = candidates.slice(index, index + verificationBatchSize) + const verified = await Promise.all( + batch.map(async (candidate) => + (await this.isDirectoryDocumentInExactScope(candidate.document)) + ? candidate + : undefined, + ), + ) + + for (const candidate of verified) { + if (!candidate) continue + const { filePath } = candidate + + // Get relative path from directory + const relativePath = filePath.substring(dirPath.length) + if (!relativePath) continue + + // If path contains /, it's in a subdirectory + const slashIndex = relativePath.indexOf("/") + if (slashIndex > 0) { + // It's a subdirectory + dirs.add(`${relativePath.substring(0, slashIndex)}/`) + } else if (relativePath !== "") { + // It's a file in this directory + files.push(relativePath) + } } } @@ -264,10 +319,8 @@ export class ClaudeMemoryTool { viewRange?: [number, number], ): Promise { try { - // Same lookup as every mutating command: limit 5 so the exact - // customId match is findable among semantic near-neighbours. - // With the old limit of 1, a similarly-named file ranking first - // made this return the wrong file's contents as a success. + // Resolve the exact document inside the configured scope so reads and + // mutations use the complete stored file, not one ranked search chunk. const readResult = await this.getFileDocument(filePath) if (!readResult.success || !readResult.document) { return { @@ -278,7 +331,7 @@ export class ClaudeMemoryTool { const document = readResult.document - let content: string = document.raw || document.content || "" + let content = document.content // Apply line range if specified if (viewRange) { @@ -376,8 +429,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content // Check if old_str exists in the content if (!originalContent.includes(oldStr)) { @@ -435,8 +487,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content const lines = originalContent.split("\n") // Validate line number @@ -490,9 +541,7 @@ export class ClaudeMemoryTool { } } - const documentId = - readResult.document.documentId ?? this.normalizePathToCustomId(filePath) - await this.client.documents.delete(documentId) + await deleteDocumentById(this.client, readResult.document.documentId) return { success: true, @@ -531,8 +580,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content const newNormalizedId = this.normalizePathToCustomId(newPath) // Create new document with new path @@ -552,8 +600,7 @@ export class ClaudeMemoryTool { // customId — the add above already replaced the content. const oldNormalizedId = this.normalizePathToCustomId(oldPath) if (oldNormalizedId !== newNormalizedId) { - const oldDocumentId = readResult.document.documentId ?? oldNormalizedId - await this.client.documents.delete(oldDocumentId) + await deleteDocumentById(this.client, readResult.document.documentId) } return { @@ -573,48 +620,124 @@ export class ClaudeMemoryTool { */ private async getFileDocument(filePath: string): Promise<{ success: boolean - document?: any + document?: ClaudeFileDocument error?: string }> { try { const normalizedId = this.normalizePathToCustomId(filePath) + let page = 1 + const candidates = new Map< + string, + Supermemory.DocumentListResponse.Memory + >() + + // 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, + }) - const response = await this.client.search({ - q: normalizedId, - ...(this.containerTags[0] - ? { containerTag: this.containerTags[0] } - : {}), - limit: 5, - searchMode: "hybrid", - }) + for (const document of response.memories) { + if ( + document.customId === normalizedId && + this.getDocumentFilePath(document) === filePath && + this.isDocumentInConfiguredScope(document) + ) { + candidates.set(document.id, document) + } + } - // Only accept the exact customId match. Falling back to the top - // semantic hit would let callers read — and worse, modify or - // delete — a different file than the one they asked for. - const match = response.results?.find( - (r) => - r.id === normalizedId || - r.documents?.some((d) => d.id === normalizedId), - ) + 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 (!match) { + if (document.id !== candidate.id) { + hasUnverifiedCandidate = true + continue + } + if ( + document.customId !== normalizedId || + this.getDocumentFilePath(document) !== filePath || + !this.hasExactContainerTags(document.containerTags) + ) { + continue + } + + exactMatches.push({ candidate, document }) + } + + if (exactMatches.length === 0) { return { success: false, error: `File not found: ${filePath}`, } } + if (exactMatches.length > 1) { + return { + success: false, + error: `File path is ambiguous in the configured container scope: ${filePath}`, + } + } + if (hasUnverifiedCandidate) { + return { + success: false, + error: `File path could not be resolved unambiguously in the configured container scope: ${filePath}`, + } + } - const content = match.chunk || match.memory || "" - const documentId = match.documents?.[0]?.id ?? match.id + const match = exactMatches[0] + if (!match) { + return { success: false, error: `File not found: ${filePath}` } + } + const { candidate, document } = match + const content = + typeof document.content === "string" + ? document.content + : typeof document.raw === "string" + ? document.raw + : undefined + if (content === undefined) { + return { + success: false, + error: `File content unavailable: ${filePath}`, + } + } + const metadata = + document.metadata && + typeof document.metadata === "object" && + !Array.isArray(document.metadata) + ? (document.metadata as ClaudeFileMetadata) + : {} return { success: true, - document: { - documentId, - content, - raw: content, - metadata: match.metadata, - }, + document: { documentId: candidate.id, content, metadata }, } } catch (error) { return { @@ -624,6 +747,60 @@ export class ClaudeMemoryTool { } } + private getDocumentFilePath(document: { + metadata: unknown + }): string | undefined { + const metadata = document.metadata + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined + } + const metadataRecord = metadata as Record + + return typeof metadataRecord.file_path === "string" + ? metadataRecord.file_path + : undefined + } + + private isDocumentInConfiguredScope( + document: Supermemory.DocumentListResponse.Memory, + ): boolean { + const documentTags = document.containerTags ?? [] + const expectedTags = this.containerTags.filter( + (tag) => !tag.startsWith("sm_project_"), + ) + + return ( + documentTags.length === expectedTags.length && + documentTags.every((tag, index) => tag === expectedTags[index]) + ) + } + + private async isDirectoryDocumentInExactScope( + document: Supermemory.DocumentListResponse.Memory, + ): Promise { + try { + // Mono strips internal project tags from every list response, so only a + // full get can prove that no hidden tags change this document's scope. + const fullDocument = await this.client.documents.get(document.id) + return ( + fullDocument.id === document.id && + this.hasExactContainerTags(fullDocument.containerTags) + ) + } catch (error) { + if (!(error instanceof Supermemory.NotFoundError)) throw error + // A document can disappear between list and get. Skip stale entries + // instead of failing the entire directory view. + return false + } + } + + private hasExactContainerTags(containerTags?: string[]): boolean { + return ( + containerTags?.length === this.containerTags.length && + containerTags.every((tag, index) => tag === this.containerTags[index]) + ) + } + /** * Validate that path starts with /memories for security */ diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 49b5c88a0..06a269354 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -4,6 +4,7 @@ import { DEFAULT_VALUES, PARAMETER_DESCRIPTIONS, TOOL_DESCRIPTIONS, + deleteDocumentByIdentifier, getContainerTags, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" @@ -14,7 +15,9 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited>["results"] + results?: Awaited< + ReturnType + >["results"] count?: number error?: string } @@ -31,7 +34,7 @@ export interface ProfileResult { static: string[] dynamic: string[] } - searchResults?: Awaited> + searchResults?: Awaited>["searchResults"] error?: string } @@ -159,6 +162,10 @@ export const memoryToolSchemas = { type: "string", description: PARAMETER_DESCRIPTIONS.documentId, }, + containerTag: { + type: "string", + description: PARAMETER_DESCRIPTIONS.documentContainerTag, + }, }, required: ["documentId"], }, @@ -248,12 +255,12 @@ export function createSearchMemoriesFunction( limit?: number }): Promise { try { - const response = await client.search({ + const response = await client.search.documents({ q: informationToGet, - ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), + containerTags, limit, - threshold: DEFAULT_VALUES.chunkThreshold, - searchMode: "hybrid", + chunkThreshold: DEFAULT_VALUES.chunkThreshold, + includeFullDocs, }) return { @@ -363,10 +370,12 @@ export function createDocumentListFunction( page?: number }): Promise { try { - const tag = containerTag || containerTags[0] + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags const response = await client.documents.list({ - containerTags: [tag], + containerTags: scopeTags, limit: limit || DEFAULT_VALUES.limit, ...(page !== undefined && { page }), }) @@ -392,15 +401,20 @@ export function createDocumentDeleteFunction( apiKey: string, config?: SupermemoryToolsConfig, ) { - const { client } = createClient(apiKey, config) + const { client, containerTags } = createClient(apiKey, config) return async function documentDelete({ documentId, + containerTag, }: { documentId: string + containerTag?: string }): Promise { try { - await client.documents.delete(documentId) + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags + await deleteDocumentByIdentifier(client, documentId, scopeTags) return { success: true, diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8ec46fa00..c61520213 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -2,18 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool // executions can be verified deterministically without network access. -const documentsDelete = vi.fn() +const documentsDeleteBulk = vi.fn() +const documentsGet = vi.fn() const documentsList = vi.fn() -const searchMock = vi.fn() const clientAdd = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = searchMock add = clientAdd documents = { - delete: documentsDelete, + deleteBulk: documentsDeleteBulk, + get: documentsGet, list: documentsList, add: vi.fn(), } @@ -35,12 +35,20 @@ function executeTool(tool: unknown, args: Record) { } beforeEach(() => { - documentsDelete.mockReset().mockResolvedValue(undefined) + documentsDeleteBulk.mockReset().mockResolvedValue({ + success: true, + deletedCount: 1, + errors: [], + }) + documentsGet.mockReset().mockResolvedValue({ + id: "doc_123", + customId: "doc_123", + containerTags: ["sm_project_default"], + }) documentsList.mockReset().mockResolvedValue({ memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) - searchMock.mockReset() clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) vi.unstubAllGlobals() }) @@ -53,7 +61,8 @@ describe("documentDelete", () => { } expect(result.success).toBe(true) - expect(documentsDelete).toHaveBeenCalledWith("doc_123") + expect(documentsGet).toHaveBeenCalledWith("doc_123") + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] }) }) }) @@ -177,16 +186,30 @@ describe("memoryForget", () => { describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const CUSTOM_ID = "memories_prefs_txt" + const DOCUMENT_ID = "doc_file_1" function mockFileDocument(content: string) { - searchMock.mockResolvedValue({ - results: [ + const metadata = { + claude_memory_type: "file", + file_path: FILE_PATH, + } + documentsList.mockResolvedValue({ + memories: [ { - id: CUSTOM_ID, - chunk: content, - metadata: { file_path: FILE_PATH }, + id: DOCUMENT_ID, + customId: CUSTOM_ID, + containerTags: ["claude_memory"], + metadata, }, ], + pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, + }) + documentsGet.mockResolvedValue({ + id: DOCUMENT_ID, + customId: CUSTOM_ID, + containerTags: ["sm_project_default", "claude_memory"], + content, + metadata, }) } @@ -247,7 +270,7 @@ describe("ClaudeMemoryTool", () => { }) expect(result.success).toBe(true) - expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) it("rename removes the old document after creating the new one", async () => { @@ -264,6 +287,6 @@ describe("ClaudeMemoryTool", () => { expect(clientAdd).toHaveBeenCalledWith( expect.objectContaining({ customId: "memories_renamed_txt" }), ) - expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) }) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index cbb1c1a72..dffc70938 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -2,48 +2,51 @@ * Shared constants and descriptions for Supermemory tools */ +import type Supermemory from "supermemory" import type { MemoryMode } from "./shared/types" // Tool descriptions export const TOOL_DESCRIPTIONS = { searchMemories: - "Search (recall) stored memories for facts, preferences, history, and context about the user or any topic. Use proactively before answering whenever memory could help — do not wait for the user to explicitly ask you to search or recall. Search when the question touches personal context, past conversations, preferences, projects, people, plans, or anything you may have learned before. Results include memory/chunk IDs — use those IDs with memoryForget to remove a specific learned fact.", + "Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.", addMemory: "Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.", getProfile: - "Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query. Profile and search result entries may include memory IDs useful for memoryForget.", + "Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.", documentList: - "List stored source documents (conversations, URLs, files, pasted text) with pagination. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.", + "List stored source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.", documentDelete: - "Permanently delete a stored document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).", + "Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.", documentAdd: "Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.", memoryForget: - "Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId (preferred — from searchMemories or getProfile) OR memoryContent for an exact text match. Use when the user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). To remove an entire conversation or file, use documentDelete instead.", + "Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.", } as const // Parameter descriptions export const PARAMETER_DESCRIPTIONS = { informationToGet: - "What to look up in memory — keywords from the user's message, topic, entity names, or question phrasing. Search even when the user did not explicitly ask you to recall.", + "What to look up in stored context — keywords from the user's message, topic, entity names, or question phrasing.", includeFullDocs: "Whether to include the full document content in the response. Defaults to true for better AI context.", limit: "Maximum number of results to return", memory: "The text content of the memory to add. This should be a single sentence or a short paragraph.", containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)", + documentContainerTag: + "Optional one-tag scope override. When deleting a document returned by documentList with a containerTag override, pass the same value here. In strict mode, pass null to use the configured union.", query: "Optional search query to include relevant search results", page: "Page number to fetch, 1-based (default: 1)", documentId: - "Document ID from documentList — permanently deletes the source document and all extracted memories. Not a profile memory ID.", + "Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.", content: "Document body to store — plain text, a conversation transcript, a long pasted blob, or a URL to a webpage/PDF/image/video. Content is queued and memories are extracted automatically after background processing; do not split into addMemory calls.", title: "Optional title for the document", description: "Optional description for the document", memoryId: - "Profile memory ID from searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.", + "Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.", memoryContent: - "Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, search first and use memoryId.", + "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 @@ -57,7 +60,7 @@ export const DEFAULT_VALUES = { // Container tag constants export const CONTAINER_TAG_CONSTANTS = { projectPrefix: "sm_project_", - defaultTags: ["sm_project_default"] as string[], + defaultTags: ["sm_project_default"] as const, } as const /** @@ -66,16 +69,167 @@ export const CONTAINER_TAG_CONSTANTS = { export function getContainerTags(config?: { projectId?: string containerTags?: string[] -}): string[] { +}): [string, ...string[]] { if (config?.projectId !== undefined && config.containerTags !== undefined) { throw new Error( "Supermemory tools config accepts either projectId or containerTags, not both.", ) } - if (config?.projectId) { + if (config?.projectId !== undefined) { + if (config.projectId.trim() === "") { + throw new Error("Supermemory tools config requires a non-empty projectId.") + } return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`] } - return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags + if (config?.containerTags !== undefined) { + const [firstTag, ...remainingTags] = config.containerTags + if ( + firstTag === undefined || + config.containerTags.some((tag) => tag.trim() === "") + ) { + throw new Error( + "Supermemory tools config requires at least one non-empty containerTag.", + ) + } + return [firstTag, ...remainingTags] + } + return [...CONTAINER_TAG_CONSTANTS.defaultTags] +} + +/** Delete exactly one document by its internal ID. */ +export async function deleteDocumentById( + client: Supermemory, + documentId: string, +): Promise { + const response = await client.documents.deleteBulk({ ids: [documentId] }) + if (response.success && response.deletedCount === 1) return + + const detail = response.errors?.find((error) => error.id === documentId)?.error + throw new Error( + detail + ? `Failed to delete document ${documentId}: ${detail}` + : `Failed to delete document ${documentId}: expected one deletion, received ${response.deletedCount}`, + ) +} + +/** + * Resolve an internal ID or customId inside the effective container-tag union, + * then delete the exact internal document ID. Internal IDs take precedence over + * customId matches. + */ +export async function deleteDocumentByIdentifier( + client: Supermemory, + documentIdentifier: string, + containerTags: readonly [string, ...string[]], +): Promise { + const directMatch = await getDocumentIfFound(client, documentIdentifier) + if ( + directMatch?.id === documentIdentifier && + hasContainerTagOverlap(directMatch.containerTags, containerTags) + ) { + await deleteDocumentById(client, directMatch.id) + return + } + + const candidateIds = new Set() + let hasInternalIdCandidate = false + let page = 1 + while (true) { + const response = await client.documents.list({ + containerTags: [...containerTags], + includeContent: false, + limit: 100, + page, + }) + for (const document of response.memories) { + if (document.id === documentIdentifier) { + hasInternalIdCandidate = true + } + if ( + document.id === documentIdentifier || + document.customId === documentIdentifier + ) { + candidateIds.add(document.id) + } + } + if (page >= response.pagination.totalPages) break + page += 1 + } + + let exactIdMatch: string | undefined + let hasUnverifiedCandidate = false + const customIdMatches: string[] = [] + for (const candidateId of candidateIds) { + const document = await getDocumentIfFound(client, candidateId) + if (document?.id !== candidateId) { + hasUnverifiedCandidate = true + continue + } + if (!hasContainerTagOverlap(document.containerTags, containerTags)) { + continue + } + if (document.id === documentIdentifier) { + exactIdMatch = document.id + break + } + if (document.customId === documentIdentifier) { + customIdMatches.push(document.id) + } else { + hasUnverifiedCandidate = true + } + } + + if (exactIdMatch) { + await deleteDocumentById(client, exactIdMatch) + return + } + if (hasInternalIdCandidate) { + throw new Error( + `Document ID ${documentIdentifier} could not be verified safely in the configured container scope.`, + ) + } + if (hasUnverifiedCandidate) { + throw new Error( + `Document identifier ${documentIdentifier} could not be resolved unambiguously in the configured container scope.`, + ) + } + if (customIdMatches.length === 1) { + await deleteDocumentById(client, customIdMatches[0] as string) + return + } + if (customIdMatches.length > 1) { + throw new Error( + `Document customId ${documentIdentifier} is ambiguous in the configured container scope.`, + ) + } + throw new Error( + `Document ${documentIdentifier} was not found in the configured container scope.`, + ) +} + +async function getDocumentIfFound(client: Supermemory, documentId: string) { + try { + return await client.documents.get(documentId) + } catch (error) { + if (isNotFoundError(error)) return undefined + throw error + } +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "status" in error && + error.status === 404 + ) +} + +function hasContainerTagOverlap( + actual: string[] | undefined, + expected: readonly string[], +): boolean { + return actual?.some((tag) => expected.includes(tag)) ?? false } /** diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index bf7717265..7e788c3d3 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -18,7 +18,11 @@ import { type Logger, type MemoryMode, } from "../shared" -import type { SupermemoryVoltAgent, VoltAgentMessage } from "./types" +import type { + SearchFilters, + SupermemoryVoltAgent, + VoltAgentMessage, +} from "./types" /** * Context for Supermemory middleware operations. @@ -47,7 +51,7 @@ export interface SupermemoryMiddlewareContext { limit?: number rerank?: boolean rewriteQuery?: boolean - filters?: { OR: Array } | { AND: Array } + filters?: SearchFilters include?: { chunks?: boolean documents?: boolean @@ -258,23 +262,7 @@ export const enhanceMessagesWithMemories = async ( if (useAdvancedSearch && ctx.mode !== "profile") { ctx.logger.info("Using advanced search with custom parameters") - const searchParams: { - q: string - containerTag: string - threshold?: number - limit?: number - rerank?: boolean - rewriteQuery?: boolean - filters?: { OR: Array } | { AND: Array } - include?: { - chunks?: boolean - documents?: boolean - forgottenMemories?: boolean - relatedMemories?: boolean - summaries?: boolean - } - searchMode?: "memories" | "documents" | "hybrid" - } = { + const searchParams: Supermemory.SearchParams = { q: queryText, containerTag: ctx.containerTag, } @@ -288,31 +276,32 @@ export const enhanceMessagesWithMemories = async ( if (ctx.include !== undefined) searchParams.include = ctx.include if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode - const response = await ctx.client.search.memories(searchParams) + const response = await ctx.client.search(searchParams) // Hybrid search returns both memory entries (`memory` field) and - // document chunks (`chunk` field). Handle both. - type SearchResult = { - memory?: string - chunk?: string - metadata?: Record - } - const formattedMemories = response.results - .map((result: SearchResult) => { - const text = result.memory || result.chunk - return text ? `- ${text}` : null - }) - .filter(Boolean) + // document chunks (`chunk` field). Normalize both for prompt templates. + const searchResults = response.results.flatMap((result) => { + const memory = result.memory ?? result.chunk + if (!memory) { + return [] + } + + return [ + { + memory, + ...(result.metadata ? { metadata: result.metadata } : {}), + }, + ] + }) + const formattedMemories = searchResults + .map((result) => `- ${result.memory}`) .join("\n") memories = ctx.promptTemplate ? ctx.promptTemplate({ userMemories: "", generalSearchMemories: formattedMemories, - searchResults: response.results as Array<{ - memory: string - metadata?: Record - }>, + searchResults, }) : `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}` } else { diff --git a/packages/tools/src/voltagent/types.ts b/packages/tools/src/voltagent/types.ts index cc5350eac..e6524ebc7 100644 --- a/packages/tools/src/voltagent/types.ts +++ b/packages/tools/src/voltagent/types.ts @@ -5,6 +5,7 @@ * Supermemory by providing hooks that inject memories before LLM calls. */ +import type Supermemory from "supermemory" import type { PromptTemplate, MemoryMode, @@ -58,7 +59,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { /** * Advanced filters to apply to the search using AND/OR logic. - * Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] } + * Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] } * * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. */ @@ -99,7 +100,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { /** * Advanced search filters using AND/OR logic */ -export type SearchFilters = { OR: Array } | { AND: Array } +export type SearchFilters = NonNullable /** * Options for including additional data in search results From 7153801f11dfdb50aeed6c4e0840ce79ccc7f805 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:46:13 +0000 Subject: [PATCH 4/7] fix(tools): resolve lint and format errors - Replace `as any` with typed assertion in claude-memory.ts - Apply Biome formatting fixes Co-Authored-By: Claude Opus 4.5 --- packages/tools/src/claude-memory.test.ts | 36 +++++++++++++----------- packages/tools/src/claude-memory.ts | 2 +- packages/tools/src/openai/tools.ts | 4 +-- packages/tools/src/tools-shared.ts | 11 ++++++-- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index afe16dcf2..7337441a9 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -39,7 +39,7 @@ const NEIGHBOUR_DOCUMENT = { content: "backup stuff", } -function mockDocuments(documents: typeof FILE_DOCUMENT[]) { +function mockDocuments(documents: (typeof FILE_DOCUMENT)[]) { documentsListMock.mockResolvedValue({ memories: documents.map((document) => ({ id: document.id, @@ -191,20 +191,22 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { tool = new ClaudeMemoryTool("test-api-key") }) - it.each(["$&", "$'", "$`", "$$"])( - "stores %s literally instead of expanding it as a replacement pattern", - async (dollarSequence) => { - const result = await tool.handleCommand({ - command: "str_replace", - path: FILE_PATH, - old_str: "line3", - new_str: `price is ${dollarSequence} today`, - }) - - expect(result.success).toBe(true) - expect(addMock).toHaveBeenCalledTimes(1) - const stored = addMock.mock.calls[0]?.[0]?.content as string - expect(stored).toContain(`price is ${dollarSequence} today`) - }, - ) + it.each([ + "$&", + "$'", + "$`", + "$$", + ])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => { + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "line3", + new_str: `price is ${dollarSequence} today`, + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toContain(`price is ${dollarSequence} today`) + }) }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 3792e9f8f..867c4d82d 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -150,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) { diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 06a269354..22257727c 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -15,9 +15,7 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited< - ReturnType - >["results"] + results?: Awaited>["results"] count?: number error?: string } diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index dffc70938..c0a3b540f 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -47,7 +47,8 @@ export const PARAMETER_DESCRIPTIONS = { "Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.", memoryContent: "Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.", - reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)", + reason: + "Optional reason recorded when forgetting (e.g. outdated, user correction)", } as const // Default values @@ -77,7 +78,9 @@ export function getContainerTags(config?: { } if (config?.projectId !== undefined) { if (config.projectId.trim() === "") { - throw new Error("Supermemory tools config requires a non-empty projectId.") + throw new Error( + "Supermemory tools config requires a non-empty projectId.", + ) } return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`] } @@ -104,7 +107,9 @@ export async function deleteDocumentById( 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 + const detail = response.errors?.find( + (error) => error.id === documentId, + )?.error throw new Error( detail ? `Failed to delete document ${documentId}: ${detail}` From 594e64b29db62685e4538ee231e069ce0500c797 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:01:54 +0530 Subject: [PATCH 5/7] fix(tools): align v4 search and safe deletion --- packages/tools/README.md | 9 +++-- packages/tools/src/ai-sdk.ts | 14 +++---- packages/tools/src/openai/tools.ts | 11 +++--- packages/tools/src/tools-shared.ts | 60 +++++++++++++++++++++--------- 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/tools/README.md b/packages/tools/README.md index 69856f69c..be27c7777 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -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 @@ -606,7 +607,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 +671,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 diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 1b7108087..bf0cb5e43 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -51,18 +51,14 @@ export const searchMemoriesTool = ( .default(DEFAULT_VALUES.limit) .describe(PARAMETER_DESCRIPTIONS.limit), }), - execute: async ({ - informationToGet, - includeFullDocs = DEFAULT_VALUES.includeFullDocs, - limit = DEFAULT_VALUES.limit, - }) => { + execute: async ({ informationToGet, limit = DEFAULT_VALUES.limit }) => { try { - const response = await client.search.documents({ + const response = await client.search({ q: informationToGet, - containerTags, + containerTag: containerTags[0], limit, - chunkThreshold: DEFAULT_VALUES.chunkThreshold, - includeFullDocs, + threshold: DEFAULT_VALUES.searchThreshold, + searchMode: "hybrid", }) return { diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 22257727c..ad4280761 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -15,7 +15,7 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited>["results"] + results?: Awaited>["results"] count?: number error?: string } @@ -245,7 +245,6 @@ export function createSearchMemoriesFunction( return async function searchMemories({ informationToGet, - includeFullDocs = DEFAULT_VALUES.includeFullDocs, limit = DEFAULT_VALUES.limit, }: { informationToGet: string @@ -253,12 +252,12 @@ export function createSearchMemoriesFunction( limit?: number }): Promise { try { - const response = await client.search.documents({ + const response = await client.search({ q: informationToGet, - containerTags, + containerTag: containerTags[0], limit, - chunkThreshold: DEFAULT_VALUES.chunkThreshold, - includeFullDocs, + threshold: DEFAULT_VALUES.searchThreshold, + searchMode: "hybrid", }) return { diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index c0a3b540f..fa25bcae5 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -8,19 +8,19 @@ import type { MemoryMode } from "./shared/types" // Tool descriptions export const TOOL_DESCRIPTIONS = { searchMemories: - "Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.", + "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). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.", + "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 source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.", documentDelete: - "Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.", + "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: "Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.", memoryForget: - "Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.", + "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 @@ -28,23 +28,23 @@ export const PARAMETER_DESCRIPTIONS = { 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", 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.", + "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: - "Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.", + "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: - "Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.", + "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 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: @@ -55,7 +55,7 @@ export const PARAMETER_DESCRIPTIONS = { export const DEFAULT_VALUES = { includeFullDocs: true, limit: 10, - chunkThreshold: 0.6, + searchThreshold: 0.6, } as const // Container tag constants @@ -128,10 +128,8 @@ export async function deleteDocumentByIdentifier( containerTags: readonly [string, ...string[]], ): Promise { const directMatch = await getDocumentIfFound(client, documentIdentifier) - if ( - directMatch?.id === documentIdentifier && - hasContainerTagOverlap(directMatch.containerTags, containerTags) - ) { + if (directMatch?.id === documentIdentifier) { + assertDocumentCanBeDeleted(directMatch, containerTags) await deleteDocumentById(client, directMatch.id) return } @@ -170,9 +168,7 @@ export async function deleteDocumentByIdentifier( hasUnverifiedCandidate = true continue } - if (!hasContainerTagOverlap(document.containerTags, containerTags)) { - continue - } + assertDocumentCanBeDeleted(document, containerTags) if (document.id === documentIdentifier) { exactIdMatch = document.id break @@ -230,11 +226,39 @@ function isNotFoundError(error: unknown): boolean { ) } -function hasContainerTagOverlap( +const TERMINAL_DOCUMENT_STATUSES = new Set(["done", "failed"]) + +function assertDocumentCanBeDeleted( + document: Awaited>, + 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?.some((tag) => expected.includes(tag)) ?? false + return ( + actual !== undefined && + actual.length > 0 && + actual.every((tag) => tag.trim() !== "" && expected.includes(tag)) + ) } /** From 42ebf17b38e9bf8a014a611049aaa21a79a02400 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:59:44 +0530 Subject: [PATCH 6/7] fix(tools): harden OpenAI middleware requests --- packages/tools/README.md | 6 ++ packages/tools/src/conversations-client.ts | 4 ++ packages/tools/src/openai/index.ts | 9 ++- packages/tools/src/openai/middleware.ts | 69 ++++++++++++++++------ 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/packages/tools/README.md b/packages/tools/README.md index be27c7777..056ae59c8 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -272,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, @@ -296,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 @@ -320,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, diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 03dc94b55..92f067f33 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -45,6 +45,8 @@ export interface AddConversationResponse { status: string } +const CONVERSATION_REQUEST_TIMEOUT_MS = 30_000 + /** * Adds a conversation to Supermemory using the /v4/conversations endpoint * @@ -89,6 +91,8 @@ export async function addConversation( metadata: params.metadata, entityContext: params.entityContext, }), + redirect: "error", + signal: AbortSignal.timeout(CONVERSATION_REQUEST_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652c..0567f197e 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -21,6 +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; falls back to SUPERMEMORY_API_KEY * * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs * @@ -56,15 +57,17 @@ import { * }) * ``` * - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not 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, ) { - if (!process.env.SUPERMEMORY_API_KEY) { - throw new Error("SUPERMEMORY_API_KEY is not set") + 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) { diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index c9b8b4b88..bc4d71081 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -7,10 +7,11 @@ 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 + export interface OpenAIMiddlewareOptions { /** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */ containerTag: string @@ -19,6 +20,8 @@ export interface OpenAIMiddlewareOptions { verbose?: boolean mode?: "profile" | "query" | "full" addMemory?: "always" | "never" + /** Supermemory API key (falls back to SUPERMEMORY_API_KEY). */ + apiKey?: string baseUrl?: string } @@ -90,6 +93,7 @@ const getLastUserMessage = ( const supermemoryProfileSearch = async ( containerTag: string, queryText: string, + apiKey: string, baseUrl: string, ): Promise => { const payload = queryText @@ -106,9 +110,11 @@ const supermemoryProfileSearch = async ( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${apiKey}`, }, body: payload, + redirect: "error", + signal: AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS), }) if (!response.ok) { @@ -160,6 +166,7 @@ const addSystemPrompt = async ( containerTag: string, logger: Logger, mode: "profile" | "query" | "full", + apiKey: string, baseUrl: string, ) => { const systemPromptExists = messages.some((msg) => msg.role === "system") @@ -169,6 +176,7 @@ const addSystemPrompt = async ( const memoriesResponse = await supermemoryProfileSearch( containerTag, queryText, + apiKey, baseUrl, ) @@ -400,8 +408,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 (falls back to SUPERMEMORY_API_KEY) * @returns Object with `wrapClient` and `createClient` methods - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither options.apiKey nor SUPERMEMORY_API_KEY is set * * @example * ```typescript @@ -420,9 +429,16 @@ 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 client = new Supermemory({ - apiKey: process.env.SUPERMEMORY_API_KEY, + apiKey, ...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}), }) @@ -456,6 +472,7 @@ export function createOpenAIMiddleware( const memoriesResponse = await supermemoryProfileSearch( containerTag, queryText, + apiKey, baseUrl, ) @@ -523,6 +540,7 @@ export function createOpenAIMiddleware( const createResponsesWithMemory = async ( params: Parameters[0], + requestOptions?: OpenAI.RequestOptions, ) => { if (!originalResponsesCreate) { throw new Error( @@ -534,7 +552,11 @@ 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 originalResponsesCreate.call( + openaiClient.responses, + params, + requestOptions, + ) } logger.info("Starting memory search for Responses API", { @@ -572,14 +594,19 @@ export function createOpenAIMiddleware( ? `${params.instructions || ""}\n\n${memories}`.trim() : params.instructions - return originalResponsesCreate.call(openaiClient.responses, { - ...params, - instructions: enhancedInstructions, - }) + return originalResponsesCreate.call( + openaiClient.responses, + { + ...params, + instructions: enhancedInstructions, + }, + requestOptions, + ) } const createWithMemory = async ( params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions?: OpenAI.RequestOptions, ) => { const messages = Array.isArray(params.messages) ? params.messages : [] @@ -587,7 +614,11 @@ export function createOpenAIMiddleware( const userMessage = getLastUserMessage(messages) if (!userMessage) { logger.debug("No user message found, skipping memory search") - return originalCreate.call(openaiClient.chat.completions, params) + return originalCreate.call( + openaiClient.chat.completions, + params, + requestOptions, + ) } } @@ -615,7 +646,7 @@ export function createOpenAIMiddleware( memoryCustomId, logger, messages, - process.env.SUPERMEMORY_API_KEY, + apiKey, baseUrl, ), ) @@ -623,16 +654,20 @@ export function createOpenAIMiddleware( } operations.push( - addSystemPrompt(messages, containerTag, logger, mode, baseUrl), + 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 - return originalCreate.call(openaiClient.chat.completions, { - ...params, - messages: enhancedMessages, - }) + return originalCreate.call( + openaiClient.chat.completions, + { + ...params, + messages: enhancedMessages, + }, + requestOptions, + ) } openaiClient.chat.completions.create = From cf339bf3eff03a39e87fac779e6d5f5ce0907558 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:33:03 +0530 Subject: [PATCH 7/7] fix(tools): align middleware SDK compatibility --- apps/docs/integrations/voltagent.mdx | 22 +- packages/tools/src/conversations-client.ts | 53 ++++- packages/tools/src/index.ts | 2 +- packages/tools/src/openai/middleware.ts | 231 ++++++++++++++------- packages/tools/src/shared/memory-client.ts | 2 + packages/tools/src/vercel/index.ts | 10 +- packages/tools/src/vercel/middleware.ts | 10 +- packages/tools/src/vercel/util.ts | 65 ++++-- packages/tools/src/voltagent/hooks.ts | 62 +++--- packages/tools/src/voltagent/index.ts | 19 +- packages/tools/src/voltagent/middleware.ts | 123 ++++++++--- packages/tools/src/voltagent/options.ts | 109 ++++++++++ packages/tools/src/voltagent/types.ts | 219 +++---------------- 13 files changed, 540 insertions(+), 387 deletions(-) create mode 100644 packages/tools/src/voltagent/options.ts diff --git a/apps/docs/integrations/voltagent.mdx b/apps/docs/integrations/voltagent.mdx index b54479389..5afb000cd 100644 --- a/apps/docs/integrations/voltagent.mdx +++ b/apps/docs/integrations/voltagent.mdx @@ -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?") ``` @@ -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) | - diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 92f067f33..4a458e3ab 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -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 - entityContext?: string apiKey: string baseUrl?: string } @@ -89,7 +131,6 @@ 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), diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index b1a43d9bd..e7bef409f 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -2,7 +2,7 @@ export type { SupermemoryToolsConfig } from "./types" export type { OpenAIMiddlewareOptions } from "./openai" -export type { SupermemoryVoltAgent } from "./voltagent" +export type { SupermemoryVoltAgent } from "./voltagent/options" export { TOOL_DESCRIPTIONS, diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index bc4d71081..d3a273beb 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,11 @@ import type OpenAI from "openai" +import { APIPromise } from "openai/core" import Supermemory from "supermemory" -import { addConversation } from "../conversations-client" +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" @@ -12,6 +17,53 @@ const normalizeBaseUrl = (url?: string): string => { const PROFILE_REQUEST_TIMEOUT_MS = 30_000 +const deferAPIPromise = ( + start: () => Promise<{ request: APIPromise }>, +): APIPromise => { + const ready = start() + + const responsePromise = ready.then(async ({ request }) => ({ + response: await request.asResponse(), + options: {} as never, + controller: new AbortController(), + })) + + return new APIPromise(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 { /** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */ containerTag: string @@ -100,9 +152,11 @@ const supermemoryProfileSearch = async ( ? JSON.stringify({ q: queryText, containerTag: containerTag, + include: ["static", "dynamic"], }) : JSON.stringify({ containerTag: containerTag, + include: ["static", "dynamic"], }) try { @@ -336,27 +390,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, @@ -538,7 +589,7 @@ export function createOpenAIMiddleware( return memories } - const createResponsesWithMemory = async ( + const prepareResponsesWithMemory = async ( params: Parameters[0], requestOptions?: OpenAI.RequestOptions, ) => { @@ -552,11 +603,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, - requestOptions, - ) + return { + request: originalResponsesCreate.call( + openaiClient.responses, + params, + requestOptions, + ), + } } logger.info("Starting memory search for Responses API", { @@ -594,31 +647,58 @@ export function createOpenAIMiddleware( ? `${params.instructions || ""}\n\n${memories}`.trim() : params.instructions - return originalResponsesCreate.call( - openaiClient.responses, - { - ...params, - instructions: enhancedInstructions, - }, - requestOptions, - ) + return { + request: originalResponsesCreate.call( + openaiClient.responses, + { + ...params, + instructions: enhancedInstructions, + }, + requestOptions, + ), + } } - const createWithMemory = async ( + const createResponsesWithMemory = ( + params: Parameters[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 : [] - - if (mode !== "profile") { - const userMessage = getLastUserMessage(messages) - if (!userMessage) { - logger.debug("No user message found, skipping memory search") - return originalCreate.call( + 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" && !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, - ) + ), } } @@ -630,27 +710,19 @@ export function createOpenAIMiddleware( const operations: Promise[] = [] - 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( @@ -658,18 +730,27 @@ export function createOpenAIMiddleware( ) const results = await Promise.all(operations) - const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last - - return originalCreate.call( - openaiClient.chat.completions, - { - ...params, - messages: enhancedMessages, - }, - requestOptions, - ) + const enhancedMessages = results[ + results.length - 1 + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last + + 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 diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 9f2d73a7c..c477b36d4 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -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 { diff --git a/packages/tools/src/vercel/index.ts b/packages/tools/src/vercel/index.ts index 7726ba2e2..6de60e446 100644 --- a/packages/tools/src/vercel/index.ts +++ b/packages/tools/src/vercel/index.ts @@ -2,7 +2,7 @@ import { type LanguageModel, type LanguageModelCallOptions, type LanguageModelStreamPart, - getLastUserMessage, + hasPersistableUserContent, } from "./util" import { createSupermemoryContext, @@ -182,11 +182,9 @@ const wrapVercelLanguageModel = ( // 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 = ( controller.enqueue(chunk) }, flush: async () => { - const userMessage = getLastUserMessage(params) if ( ctx.addMemory === "always" && - userMessage && - userMessage.trim() + hasPersistableUserContent(params) ) { saveMemoryAfterResponse( ctx.client, diff --git a/packages/tools/src/vercel/middleware.ts b/packages/tools/src/vercel/middleware.ts index ac1227ab2..412eed922 100644 --- a/packages/tools/src/vercel/middleware.ts +++ b/packages/tools/src/vercel/middleware.ts @@ -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" && diff --git a/packages/tools/src/vercel/util.ts b/packages/tools/src/vercel/util.ts index 49ab30c5c..97b2c3e86 100644 --- a/packages/tools/src/vercel/util.ts +++ b/packages/tools/src/vercel/util.ts @@ -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 + // biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations. + doStream(...args: any[]): PromiseLike +} + +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] } diff --git a/packages/tools/src/voltagent/hooks.ts b/packages/tools/src/voltagent/hooks.ts index 87c788315..49553cd43 100644 --- a/packages/tools/src/voltagent/hooks.ts +++ b/packages/tools/src/voltagent/hooks.ts @@ -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 = [ diff --git a/packages/tools/src/voltagent/index.ts b/packages/tools/src/voltagent/index.ts index 9b834691a..4eb6a0a81 100644 --- a/packages/tools/src/voltagent/index.ts +++ b/packages/tools/src/voltagent/index.ts @@ -43,15 +43,15 @@ interface WithSupermemoryOptions * @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 * ```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 * 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 * 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 * 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 * agentConfig: { * name: "my-agent", * instructions: "...", - * llm: new VercelAIProvider(), * model: openai("gpt-4o"), * }, * containerTag: "user-123", @@ -138,7 +133,7 @@ interface WithSupermemoryOptions */ export function withSupermemory( options: WithSupermemoryOptions, -): T { +): T & { hooks: NonNullable } { const { agentConfig, containerTag, ...supermemoryOptions } = options // Create Supermemory hooks (internally creates its own context, validates API key) diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index 7e788c3d3..b5cf5aa7d 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -7,7 +7,9 @@ import Supermemory from "supermemory" import { addConversation, + type ContentPart as ConversationContentPart, type ConversationMessage, + toConversationImageUrl, } from "../conversations-client" import { createLogger, @@ -62,7 +64,6 @@ export interface SupermemoryMiddlewareContext { // Storage parameters metadata?: Record searchMode?: "memories" | "documents" | "hybrid" - entityContext?: string } /** @@ -93,7 +94,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, verbose = false, } = options @@ -103,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({ @@ -133,7 +150,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, } } @@ -160,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. */ @@ -173,7 +204,7 @@ const getLastUserMessage = (messages: VoltAgentMessage[]): string => { return "" } - const content = lastUserMessage.content + const content = getMessageContent(lastUserMessage) if (typeof content === "string") { return content @@ -234,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) @@ -388,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) { @@ -430,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, + }) + } } } @@ -460,7 +516,6 @@ export const saveConversation = async ( messages: conversationMessages, containerTags: [ctx.containerTag], metadata: ctx.metadata, - entityContext: ctx.entityContext, apiKey: ctx.apiKey, baseUrl: ctx.normalizedBaseUrl, }) diff --git a/packages/tools/src/voltagent/options.ts b/packages/tools/src/voltagent/options.ts new file mode 100644 index 000000000..21f3f3953 --- /dev/null +++ b/packages/tools/src/voltagent/options.ts @@ -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 + + /** + * 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 + +/** 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 +} diff --git a/packages/tools/src/voltagent/types.ts b/packages/tools/src/voltagent/types.ts index e6524ebc7..e773f1d88 100644 --- a/packages/tools/src/voltagent/types.ts +++ b/packages/tools/src/voltagent/types.ts @@ -5,220 +5,49 @@ * Supermemory by providing hooks that inject memories before LLM calls. */ -import type Supermemory from "supermemory" +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: [{ 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 - - /** - * 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 = NonNullable - -/** - * 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 & { + hooks?: AgentHooks } -/** - * VoltAgent hooks interface (simplified). - * Hooks allow intercepting agent lifecycle events. - */ -export interface VoltAgentHooks { - onStart?: (args: HookStartArgs) => void | Promise - onPrepareMessages?: ( - args: HookPrepareMessagesArgs, - ) => - | { messages?: VoltAgentMessage[] } - | Promise<{ messages?: VoltAgentMessage[] }> - onEnd?: (args: HookEndArgs) => void | Promise - [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 }