From 0d77c8f7590e945482cfbfe1a1aefc3053b1ab15 Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:46:43 +0530 Subject: [PATCH 01/21] fix(tools): isolate OpenAI middleware clients --- packages/tools/README.md | 2 +- packages/tools/src/openai/index.ts | 2 +- packages/tools/src/openai/middleware.ts | 75 ++++++++++++++++++++----- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/tools/README.md b/packages/tools/README.md index 056ae59c..689c5775 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -263,7 +263,7 @@ The `MemoryPromptData` object provides: #### OpenAI Middleware with Supermemory -The `withSupermemory` function creates an OpenAI client with SuperMemory middleware automatically injected: +The `withSupermemory` function creates an isolated middleware facade around an OpenAI client. It does not mutate the supplied client, so a shared base client can safely be wrapped with different user or conversation options: ```typescript import { withSupermemory } from "@supermemory/tools/openai" diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 0567f197..d56c136f 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -23,7 +23,7 @@ import { * @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 + * @returns A distinct OpenAI middleware facade for Chat Completions and Responses APIs. The supplied client is not mutated. * * @example * ```typescript diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 518db547..192f27cf 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -16,6 +16,34 @@ import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" +// Keep canonicalization stable across duplicate package copies and hot reloads. +const BASE_CLIENT_SYMBOL = Symbol.for("@supermemory/tools/openai/base-client") +const baseClientByWrapper = new WeakMap() + +const getBaseClient = (client: OpenAI) => + (Reflect.get(client, BASE_CLIENT_SYMBOL) as OpenAI | undefined) ?? + baseClientByWrapper.get(client) ?? + client + +const cloneWithOverrides = ( + source: T, + overrides: Partial, +): T => { + const descriptors = Object.getOwnPropertyDescriptors(source) + + for (const key of Reflect.ownKeys(overrides) as Array) { + const current = Object.getOwnPropertyDescriptor(source, key) + Reflect.set(descriptors, key, { + configurable: current?.configurable ?? true, + enumerable: current?.enumerable ?? false, + value: overrides[key], + writable: current && "writable" in current ? current.writable : true, + }) + } + + return Object.create(Object.getPrototypeOf(source), descriptors) as T +} + const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" return url?.trim().replace(/\/+$/, "") || defaultUrl @@ -761,6 +789,11 @@ export function createOpenAIMiddleware( containerTag: string, options?: OpenAIMiddlewareOptions, ) { + const baseClient = getBaseClient(openaiClient) + const baseChat = baseClient.chat + const baseCompletions = baseChat.completions + const baseResponses = baseClient.responses + const logger = createLogger(options?.verbose ?? false) const apiKey = options?.apiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim() || "" @@ -779,8 +812,8 @@ export function createOpenAIMiddleware( const mode = options?.mode ?? "profile" const addMemory = options?.addMemory ?? "always" - const originalCreate = openaiClient.chat.completions.create - const originalResponsesCreate = openaiClient.responses?.create + const originalCreate = baseCompletions.create + const originalResponsesCreate = baseResponses?.create /** * Searches for memories and formats them for injection into API calls. @@ -920,7 +953,7 @@ export function createOpenAIMiddleware( } return { request: originalResponsesCreate.call( - openaiClient.responses, + baseResponses, cleanedParams, requestOptions, ), @@ -972,7 +1005,7 @@ export function createOpenAIMiddleware( return { request: originalResponsesCreate.call( - openaiClient.responses, + baseResponses, { ...params, input: cleanedInput, @@ -1023,7 +1056,7 @@ export function createOpenAIMiddleware( logger.debug("No textual user message found, skipping memory search") return { request: originalCreate.call( - openaiClient.chat.completions, + baseCompletions, { ...params, messages: updateChatMemoryContexts(messages), @@ -1078,7 +1111,7 @@ export function createOpenAIMiddleware( return { request: originalCreate.call( - openaiClient.chat.completions, + baseCompletions, { ...params, messages: enhancedMessages, @@ -1093,14 +1126,26 @@ export function createOpenAIMiddleware( requestOptions?: OpenAI.RequestOptions, ) => deferAPIPromise(() => prepareCreateWithMemory(params, requestOptions)) - openaiClient.chat.completions.create = - createWithMemory as typeof originalCreate + const wrappedCompletions = cloneWithOverrides(baseCompletions, { + create: createWithMemory as typeof originalCreate, + }) + const wrappedChat = cloneWithOverrides(baseChat, { + completions: wrappedCompletions, + }) + const wrappedResponses = + baseResponses && originalResponsesCreate + ? cloneWithOverrides(baseResponses, { + create: createResponsesWithMemory as typeof originalResponsesCreate, + }) + : undefined + const wrappedClient = cloneWithOverrides(baseClient, { + chat: wrappedChat, + ...(wrappedResponses ? { responses: wrappedResponses } : {}), + }) - // Wrap Responses API if available - if (originalResponsesCreate) { - openaiClient.responses.create = - createResponsesWithMemory as typeof originalResponsesCreate - } - - return openaiClient + Object.defineProperty(wrappedClient, BASE_CLIENT_SYMBOL, { + value: baseClient, + }) + baseClientByWrapper.set(wrappedClient, baseClient) + return wrappedClient } From 76470610ffd25cc2db13bbcad3468e4827e9c1ad Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:56:12 +0530 Subject: [PATCH 02/21] fix(tools): align claude-memory insert with memory_20250818 line semantics Anthropic's memory_20250818 spec defines insert as: insert_text is inserted AFTER line insert_line, 0 inserts at the beginning of the file, and the valid range is [0, n_lines]. The implementation treated insert_line as a 1-based insert-BEFORE index with range [1, n_lines + 1]. Since the caller of this tool is Claude itself, which is trained on the spec semantics, every model-driven insert landed one line earlier than intended, insert_line: 0 (insert at top of file) was rejected as invalid, and insert_line: n_lines (append) inserted before the last line instead of after it. Fix the validation range to [0, n_lines], splice at insert_line directly (0-based insert-after), and update the error and success messages to match. One existing tool-operations test encoded the old insert-before behavior; its insert_line is adjusted so its expected output is unchanged under spec semantics. Adds four regression tests covering top-of-file, middle, append, and both out-of-range directions. --- packages/tools/src/claude-memory.test.ts | 74 ++++++++++++++++++++++ packages/tools/src/claude-memory.ts | 14 ++-- packages/tools/src/tool-operations.test.ts | 2 +- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7337441a..622470e9 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -180,6 +180,80 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) }) +describe("ClaudeMemoryTool insert line semantics", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + searchExecute.mockReset() + addMock.mockReset() + mockDocument(FILE_CONTENT) + tool = new ClaudeMemoryTool("test-api-key") + }) + + // The memory_20250818 spec: insert_text is inserted AFTER line insert_line, + // 0 inserts at the beginning of the file, and the valid range is [0, n_lines]. + + it("insert_line: 0 inserts at the beginning of the file", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 0, + insert_text: "header", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toBe("header\nline1\nline2\nline3\nline4\nline5") + }) + + it("inserts AFTER the given line, not before it", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 2, + insert_text: "after2", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + // Regression guard: the old 1-based insert-BEFORE landed this one line early. + expect(stored).toBe("line1\nline2\nafter2\nline3\nline4\nline5") + }) + + it("insert_line: n_lines appends at the end of the file", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 5, + insert_text: "tail", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toBe("line1\nline2\nline3\nline4\nline5\ntail") + }) + + it("rejects insert_line outside [0, n_lines] without writing", async () => { + const below = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: -1, + insert_text: "x", + }) + expect(below.success).toBe(false) + expect(below.error).toContain("[0, 5]") + + const above = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 6, + insert_text: "x", + }) + expect(above.success).toBe(false) + expect(addMock).not.toHaveBeenCalled() + }) +}) + describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 867c4d82..1184bf4b 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -490,16 +490,18 @@ export class ClaudeMemoryTool { const originalContent = readResult.document.content const lines = originalContent.split("\n") - // Validate line number - if (insertLine < 1 || insertLine > lines.length + 1) { + // Validate line number. Per the memory_20250818 spec the valid range + // is [0, n_lines]: text is inserted AFTER `insert_line`, and 0 means + // the beginning of the file. + if (insertLine < 0 || insertLine > lines.length) { return { success: false, - error: `Invalid line number: ${insertLine}. File has ${lines.length} lines.`, + error: `Invalid insert_line parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, } } - // Insert the text (insertLine is 1-based) - lines.splice(insertLine - 1, 0, insertText) + // Insert the text after line `insertLine` (0-based insert-after) + lines.splice(insertLine, 0, insertText) const newContent = lines.join("\n") // Update the document @@ -517,7 +519,7 @@ export class ClaudeMemoryTool { return { success: true, - content: `Text inserted at line ${insertLine} in file: ${filePath}`, + content: `Text inserted after line ${insertLine} in file: ${filePath}`, } } catch (error) { return { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8efef91d..63ff8e24 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -309,7 +309,7 @@ describe("ClaudeMemoryTool", () => { const result = await tool.handleCommand({ command: "insert", path: FILE_PATH, - insert_line: 2, + insert_line: 1, insert_text: "", }) From b8b95112afe8e629fd7af8111529d3808ea15025 Mon Sep 17 00:00:00 2001 From: Aniruddha Adak Date: Tue, 8 Sep 2026 12:01:29 +0530 Subject: [PATCH 03/21] fix(tools): reject parent-directory segments in Claude memory paths --- packages/tools/src/claude-memory.test.ts | 26 ++++++++++++++++++++++++ packages/tools/src/claude-memory.ts | 14 ++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 622470e9..1f334344 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -284,3 +284,29 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { expect(stored).toContain(`price is ${dollarSequence} today`) }) }) + +describe("ClaudeMemoryTool path traversal", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + documentsListMock.mockReset() + documentsGetMock.mockReset() + addMock.mockReset() + mockDocument(FILE_CONTENT) + tool = new ClaudeMemoryTool("test-api-key") + }) + + it.each(["/memories/..", "/memories/foo/..", "/memories/../secrets.txt"])( + "rejects parent-directory path %s", + async (path) => { + const result = await tool.handleCommand({ + command: "view", + path, + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") + expect(documentsListMock).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 1184bf4b..a051dfa8 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -807,11 +807,15 @@ export class ClaudeMemoryTool { * Validate that path starts with /memories for security */ private isValidPath(path: string): boolean { - return ( - (path.startsWith("/memories/") || path === "/memories") && - !path.includes("../") && - !path.includes("..\\") - ) + if (!(path.startsWith("/memories/") || path === "/memories")) { + return false + } + if (path.includes("..\\")) { + return false + } + // Reject any parent-directory segment, including trailing "/.." which + // the previous "../" substring check missed (e.g. "/memories/.."). + return !path.split("/").some((segment) => segment === "..") } } From db64d1369c1bd2cc8872a648edeac2ba1457ccde Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:27:11 +0530 Subject: [PATCH 04/21] fix(tools): prevent customId collisions in claude memory tool (#1547) --- packages/tools/src/claude-memory.test.ts | 39 ++++++++++++++++++++++ packages/tools/src/claude-memory.ts | 23 ++++++++++--- packages/tools/src/tool-operations.test.ts | 4 ++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 1f334344..b5a9e636 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -310,3 +310,42 @@ describe("ClaudeMemoryTool path traversal", () => { }, ) }) + +describe("ClaudeMemoryTool path normalization collision resistance", () => { + it("produces distinct customIds for paths that previously collided", () => { + const tool = new ClaudeMemoryTool("test-api-key") + const paths = [ + "/memories/notes.txt", + "/memories/notes_txt", + "/memories/notes/txt", + "/memories/project/a.md", + "/memories/project_a.md", + ] + + const ids = paths.map((path) => tool.normalizePathToCustomId(path)) + const uniqueIds = new Set(ids) + + expect(uniqueIds.size).toBe(paths.length) + }) + + it("resolves documents stored under legacy customId format", async () => { + // Mock a document saved with legacy normalization (memories_notes_txt) + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content", + }, + ]) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "view", + path: "/memories/notes.txt", + }) + + expect(result.success).toBe(true) + expect(result.content).toContain("legacy content") + }) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index a051dfa8..126cbc25 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -57,13 +57,22 @@ export class ClaudeMemoryTool { /** * Normalize file path to be used as customId - * Converts /memories/file.txt -> memories_file_txt + * Reversibly encodes path components to prevent collisions between paths like + * `/memories/notes.txt`, `/memories/notes_txt`, and `/memories/notes/txt`. */ - private normalizePathToCustomId(path: string): string { + normalizePathToCustomId(path: string): string { return path .replace(/^\//, "") // Remove leading slash - .replace(/\//g, "_") // Replace / with _ - .replace(/\./g, "_") // Replace . with _ + .replace(/_/g, "__") // Escape underscores: _ -> __ + .replace(/\//g, "_s_") // Encode slashes: / -> _s_ + .replace(/\./g, "_d_") // Encode dots: . -> _d_ + } + + /** + * Legacy normalization used in older versions (/ and . both flattened to _) + */ + private legacyNormalizePathToCustomId(path: string): string { + return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_") } constructor(apiKey: string, config?: ClaudeMemoryConfig) { @@ -652,8 +661,12 @@ export class ClaudeMemoryTool { }) for (const document of response.memories) { + const isMatchingCustomId = + document.customId === normalizedId || + document.customId === this.legacyNormalizePathToCustomId(filePath) + if ( - document.customId === normalizedId && + isMatchingCustomId && this.getDocumentFilePath(document) === filePath && this.isDocumentInConfiguredScope(document) ) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 63ff8e24..ca45cdd8 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -344,7 +344,9 @@ describe("ClaudeMemoryTool", () => { expect(result.success).toBe(true) expect(clientAdd).toHaveBeenCalledWith( - expect.objectContaining({ customId: "memories_renamed_txt" }), + expect.objectContaining({ + customId: tool.normalizePathToCustomId("/memories/renamed.txt"), + }), ) expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) From d110df8ad2eda45aab016c8fff9f41d68c4b214e Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:29:40 +0530 Subject: [PATCH 05/21] fix(tools): handle legacy customIds during document verification --- packages/tools/src/claude-memory.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 126cbc25..742d3144 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -696,8 +696,12 @@ export class ClaudeMemoryTool { hasUnverifiedCandidate = true continue } + const isMatchingCustomId = + document.customId === normalizedId || + document.customId === this.legacyNormalizePathToCustomId(filePath) + if ( - document.customId !== normalizedId || + !isMatchingCustomId || this.getDocumentFilePath(document) !== filePath || !this.hasExactContainerTags(document.containerTags) ) { From 4929603cb46ffbe66ce6d6250af93e5503461da7 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:54:24 +0530 Subject: [PATCH 06/21] fix(tools): clean up legacy customId documents during mutations to prevent path ambiguity --- packages/tools/src/claude-memory.test.ts | 100 ++++++++++++++++++++++- packages/tools/src/claude-memory.ts | 39 ++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index b5a9e636..3509541c 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // operations can be exercised deterministically without any network access. const documentsListMock = vi.fn() const documentsGetMock = vi.fn() -const documentsDeleteBulkMock = vi.fn() +const documentsDeleteBulkMock = vi + .fn() + .mockResolvedValue({ success: true, deletedCount: 1 }) const addMock = vi.fn() vi.mock("supermemory", () => { @@ -312,6 +314,13 @@ describe("ClaudeMemoryTool path traversal", () => { }) describe("ClaudeMemoryTool path normalization collision resistance", () => { + beforeEach(() => { + documentsListMock.mockReset() + documentsGetMock.mockReset() + addMock.mockReset() + documentsDeleteBulkMock.mockReset() + }) + it("produces distinct customIds for paths that previously collided", () => { const tool = new ClaudeMemoryTool("test-api-key") const paths = [ @@ -348,4 +357,93 @@ describe("ClaudeMemoryTool path normalization collision resistance", () => { expect(result.success).toBe(true) expect(result.content).toContain("legacy content") }) + + it("cleans up legacy-customId document when str_replace updates the file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content hello", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "str_replace", + path: "/memories/notes.txt", + old_str: "hello", + new_str: "world", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) + + it("cleans up legacy-customId document when insert updates the file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "line1\nline2", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "insert", + path: "/memories/notes.txt", + insert_line: 2, + insert_text: "inserted line", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) + + it("cleans up legacy-customId document when create overwrites an existing file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "create", + path: "/memories/notes.txt", + file_text: "brand new content", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 742d3144..e0b5b3de 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -41,6 +41,7 @@ type ClaudeFileMetadata = Record interface ClaudeFileDocument { documentId: string + customId?: string content: string metadata: ClaudeFileMetadata } @@ -393,6 +394,8 @@ export class ClaudeMemoryTool { fileText: string, ): Promise { try { + const existing = await this.getFileDocument(filePath) + const normalizedId = this.normalizePathToCustomId(filePath) const _response = await this.client.add({ @@ -408,6 +411,17 @@ export class ClaudeMemoryTool { }, }) + // If an existing document was stored under a legacy customId, clean it up + // so the file path does not collide or become ambiguous with multiple documents. + if ( + existing.success && + existing.document && + existing.document.customId && + existing.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, existing.document.documentId) + } + return { success: true, content: `File created: ${filePath}`, @@ -466,6 +480,15 @@ export class ClaudeMemoryTool { }, }) + // If the modified file was stored under a legacy customId, clean up the legacy + // document to prevent path ambiguity. + if ( + readResult.document.customId && + readResult.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, readResult.document.documentId) + } + return { success: true, content: `String replaced in file: ${filePath}`, @@ -526,6 +549,15 @@ export class ClaudeMemoryTool { }, }) + // If the modified file was stored under a legacy customId, clean up the legacy + // document to prevent path ambiguity. + if ( + readResult.document.customId && + readResult.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, readResult.document.documentId) + } + return { success: true, content: `Text inserted after line ${insertLine} in file: ${filePath}`, @@ -756,7 +788,12 @@ export class ClaudeMemoryTool { return { success: true, - document: { documentId: candidate.id, content, metadata }, + document: { + documentId: candidate.id, + customId: document.customId ?? candidate.customId, + content, + metadata, + }, } } catch (error) { return { From 0d90a151005224099cb3573cb410ba7c67e8628f Mon Sep 17 00:00:00 2001 From: Rikinshah787 Date: Tue, 25 Aug 2026 16:07:14 -0700 Subject: [PATCH 07/21] fix(tools): handle the memory tool commands Claude actually sends Three places where ClaudeMemoryTool diverges from the documented memory_20250818 wire format: - rename sends old_path/new_path, not path. handleCommand validated command.path, so every rename coming from a real model died with "Cannot read properties of undefined (reading 'startsWith')". path is still accepted as the source for existing callers. - insert_line means "insert after this line" (0 = top of file), but we spliced at insertLine - 1 and rejected 0, so every insert landed one line above where Claude asked and inserting at the top was impossible. - str_replace with new_str omitted is a deletion per the spec; we rejected it. The new tests mock the supermemory client so they run without an API key. Also fixed the rename example in the docs, which showed the same path shape the code expected. --- apps/docs/integrations/claude-memory.mdx | 2 +- packages/tools/src/claude-memory.ts | 48 +++--- .../tools/test/claude-memory-commands.test.ts | 162 ++++++++++++++++++ 3 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 packages/tools/test/claude-memory-commands.test.ts diff --git a/apps/docs/integrations/claude-memory.mdx b/apps/docs/integrations/claude-memory.mdx index 6afc7947..e7a97c99 100644 --- a/apps/docs/integrations/claude-memory.mdx +++ b/apps/docs/integrations/claude-memory.mdx @@ -183,7 +183,7 @@ Paths are normalized for storage: `/memories/preferences` is stored as `--memori ```typescript { command: "rename", - path: "/memories/old-name.txt", + old_path: "/memories/old-name.txt", new_path: "/memories/new-name.txt" } ``` diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index e0b5b3de..5c36981f 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -9,7 +9,8 @@ export interface ClaudeMemoryConfig extends SupermemoryToolsConfig { export interface MemoryCommand { command: "view" | "create" | "str_replace" | "insert" | "delete" | "rename" - path: string + // every command except rename addresses the file via path + path?: string // view specific view_range?: [number, number] // create specific @@ -20,7 +21,9 @@ export interface MemoryCommand { // insert specific insert_line?: number insert_text?: string - // rename specific + // rename specific: Claude sends old_path/new_path (path is accepted too + // for backwards compatibility with earlier callers) + old_path?: string new_path?: string } @@ -96,17 +99,24 @@ export class ClaudeMemoryTool { */ async handleCommand(command: MemoryCommand): Promise { try { + // rename is the one command that doesn't use `path`: Claude sends + // old_path/new_path. Fall back to `path` so older callers keep working. + const path = + command.command === "rename" + ? (command.old_path ?? command.path) + : command.path + // Validate path security - if (!this.isValidPath(command.path)) { + if (path === undefined || !this.isValidPath(path)) { return { success: false, - error: `Invalid path: ${command.path}. All paths must start with /memories/`, + error: `Invalid path: ${path}. All paths must start with /memories/`, } } switch (command.command) { case "view": - return await this.view(command.path, command.view_range) + return await this.view(path, command.view_range) case "create": if (!command.file_text) { return { @@ -114,21 +124,21 @@ export class ClaudeMemoryTool { error: "file_text is required for create command", } } - return await this.create(command.path, command.file_text) + return await this.create(path, command.file_text) case "str_replace": - // new_str may legitimately be "" (deleting text), so only reject - // when it is missing entirely. old_str must be non-empty — replacing - // the empty string would prepend instead of replacing. - if (!command.old_str || command.new_str === undefined) { + // new_str may be omitted or "" — both mean "delete old_str". + // old_str must be non-empty — replacing the empty string would + // prepend instead of replacing. + if (!command.old_str) { return { success: false, - error: "old_str and new_str are required for str_replace command", + error: "old_str is required for str_replace command", } } return await this.strReplace( - command.path, + path, command.old_str, - command.new_str, + command.new_str ?? "", ) case "insert": // insert_text may be "" (inserting a blank line). @@ -143,12 +153,12 @@ export class ClaudeMemoryTool { } } return await this.insert( - command.path, + path, command.insert_line, command.insert_text, ) case "delete": - return await this.delete(command.path) + return await this.delete(path) case "rename": if (!command.new_path) { return { @@ -156,7 +166,7 @@ export class ClaudeMemoryTool { error: "new_path is required for rename command", } } - return await this.rename(command.path, command.new_path) + return await this.rename(path, command.new_path) default: return { success: false, @@ -522,9 +532,8 @@ export class ClaudeMemoryTool { const originalContent = readResult.document.content const lines = originalContent.split("\n") - // Validate line number. Per the memory_20250818 spec the valid range - // is [0, n_lines]: text is inserted AFTER `insert_line`, and 0 means - // the beginning of the file. + // insert_line is the line the text goes after: 0 means the beginning + // of the file and lines.length appends at the end. if (insertLine < 0 || insertLine > lines.length) { return { success: false, @@ -532,7 +541,6 @@ export class ClaudeMemoryTool { } } - // Insert the text after line `insertLine` (0-based insert-after) lines.splice(insertLine, 0, insertText) const newContent = lines.join("\n") diff --git a/packages/tools/test/claude-memory-commands.test.ts b/packages/tools/test/claude-memory-commands.test.ts new file mode 100644 index 00000000..fd72a83c --- /dev/null +++ b/packages/tools/test/claude-memory-commands.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Unit tests for the command handling in ClaudeMemoryTool, with the +// supermemory client mocked out so they run without an API key. They pin the +// wire format documented at +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool +// (rename uses old_path/new_path, insert_line means "insert after this line" +// with 0 = top of file, str_replace without new_str deletes old_str). + +const { addMock, deleteMock, executeMock } = vi.hoisted(() => ({ + addMock: vi.fn(), + deleteMock: vi.fn(), + executeMock: vi.fn(), +})) + +vi.mock("supermemory", () => ({ + default: class MockSupermemory { + add = addMock + search = { execute: executeMock } + documents = { delete: deleteMock } + }, +})) + +import { createClaudeMemoryTool } from "../src/claude-memory" + +// Matches ClaudeMemoryTool's normalizePathToCustomId +function customIdFor(path: string): string { + return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_") +} + +function stubFile(path: string, content: string) { + executeMock.mockResolvedValue({ + results: [ + { + documentId: customIdFor(path), + raw: content, + metadata: { file_path: path }, + }, + ], + }) +} + +describe("ClaudeMemoryTool command handling", () => { + let tool: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + addMock.mockResolvedValue({ id: "doc_1" }) + deleteMock.mockResolvedValue({}) + executeMock.mockResolvedValue({ results: [] }) + tool = createClaudeMemoryTool("test-api-key") + }) + + describe("rename", () => { + it("handles the old_path/new_path shape Claude actually sends", async () => { + stubFile("/memories/draft.txt", "file body") + + const result = await tool.handleCommand({ + command: "rename", + old_path: "/memories/draft.txt", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledWith( + expect.objectContaining({ + customId: customIdFor("/memories/final.txt"), + content: "file body", + }), + ) + expect(deleteMock).toHaveBeenCalledWith( + customIdFor("/memories/draft.txt"), + ) + }) + + it("still accepts path as the source for older callers", async () => { + stubFile("/memories/draft.txt", "file body") + + const result = await tool.handleCommand({ + command: "rename", + path: "/memories/draft.txt", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(true) + }) + + it("validates old_path like any other path", async () => { + const result = await tool.handleCommand({ + command: "rename", + old_path: "/etc/passwd", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") + }) + }) + + describe("insert", () => { + const path = "/memories/notes.txt" + + async function insertAt(line: number, text: string) { + stubFile(path, "one\ntwo\nthree") + return await tool.handleCommand({ + command: "insert", + path, + insert_line: line, + insert_text: text, + }) + } + + function savedContent(): string { + return addMock.mock.calls[0]?.[0]?.content + } + + it("inserts at the top of the file for insert_line 0", async () => { + const result = await insertAt(0, "zero") + + expect(result.success).toBe(true) + expect(savedContent()).toBe("zero\none\ntwo\nthree") + }) + + it("inserts after the given line, not before it", async () => { + const result = await insertAt(2, "new") + + expect(result.success).toBe(true) + expect(savedContent()).toBe("one\ntwo\nnew\nthree") + }) + + it("appends when insert_line equals the line count", async () => { + const result = await insertAt(3, "four") + + expect(result.success).toBe(true) + expect(savedContent()).toBe("one\ntwo\nthree\nfour") + }) + + it("rejects insert_line past the end of the file", async () => { + const result = await insertAt(4, "too far") + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line number") + }) + }) + + describe("str_replace", () => { + it("deletes old_str when new_str is omitted", async () => { + stubFile("/memories/prefs.txt", "keep this remove this") + + const result = await tool.handleCommand({ + command: "str_replace", + path: "/memories/prefs.txt", + old_str: " remove this", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledWith( + expect.objectContaining({ content: "keep this" }), + ) + }) + }) +}) From 444b4f16981a8d0b62ceced5f78793551872cc21 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:17:05 +0530 Subject: [PATCH 08/21] fix(tools): keep the forget-memory timeout when a caller passes a signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forgetMemoryRequest` combined the caller's signal and the 30s abort with `??`, making them mutually exclusive. Passing a cancellation signal removed the timeout, so a hung `DELETE /v4/memories` could wedge the tool call again — the exact condition #1451 set out to remove. There was also no way for a caller to ask for both cancellation and a timeout. Compose the two with `AbortSignal.any` instead of choosing between them. `AbortSignal.any` is available in Node 20.3+, Bun and workerd. No production call site passes `options` today (`ai-sdk.ts` and `openai/tools.ts` both omit it), so this was latent rather than live. The existing test asserted the buggy behaviour (`init.signal` being the caller's own signal), so it is replaced by two tests that pin the composed semantics: aborting the caller aborts the request, and the timeout leg still aborts the request on its own. Both fail against the previous implementation. Fixes #1549 --- packages/tools/src/shared/forget-memory.ts | 6 +++- packages/tools/src/tool-operations.test.ts | 36 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts index 8691c92a..97b7e510 100644 --- a/packages/tools/src/shared/forget-memory.ts +++ b/packages/tools/src/shared/forget-memory.ts @@ -33,7 +33,11 @@ export async function forgetMemoryRequest( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(params), - signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS), + // Compose rather than choose: a caller-supplied signal must add cancellation + // on top of the timeout, not replace it, or the request becomes unbounded. + signal: options?.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index ca45cdd8..b3212ca4 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -180,7 +180,7 @@ describe("memoryForget", () => { expect(init.signal).toBeInstanceOf(AbortSignal) }) - it("uses a caller-provided signal instead of creating a timeout", async () => { + it("cancels through a caller-provided signal", async () => { const fetchMock = stubFetch() const controller = new AbortController() @@ -192,7 +192,39 @@ describe("memoryForget", () => { ) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(init.signal).toBe(controller.signal) + // The request signal is a composite, not the caller's own, but aborting + // the caller still aborts the request. + expect(init.signal).not.toBe(controller.signal) + controller.abort() + expect(init.signal?.aborted).toBe(true) + }) + + it("keeps the timeout when a caller-provided signal is present", async () => { + const timeoutController = new AbortController() + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(timeoutController.signal) + const fetchMock = stubFetch() + const controller = new AbortController() + + try { + await forgetMemoryRequest( + API_KEY, + { containerTag: "user_1", id: "mem_1" }, + undefined, + { signal: controller.signal }, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + // Firing only the timeout leg aborts the request: a caller signal adds + // cancellation, it does not remove the 30s bound. + timeoutController.abort() + expect(init.signal?.aborted).toBe(true) + } finally { + timeoutSpy.mockRestore() + } }) it("throws a descriptive error on non-2xx responses", async () => { From 8233cfb99d62f90e5ba7e56db6a489d9093f475f Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:46:52 +0530 Subject: [PATCH 09/21] fix(mcp): keep the getDocuments timeout when a caller passes a signal Same pattern as forgetMemoryRequest: `options?.signal ?? AbortSignal.timeout(...)` dropped the 30s bound whenever a caller supplied its own signal. Compose the two with AbortSignal.any so the caller signal adds cancellation instead of replacing the timeout. --- apps/mcp/src/server/client/index.test.ts | 66 ++++++++++++++++++++++++ apps/mcp/src/server/client/index.ts | 9 +++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/mcp/src/server/client/index.test.ts diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 00000000..68ea5703 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { SupermemoryClient } from "./index" + +const API_URL = "https://api.example.com" + +describe("SupermemoryClient.getDocuments", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + documents: [], + pagination: { + currentPage: 1, + limit: 200, + totalItems: 0, + totalPages: 0, + }, + }), + ) + vi.stubGlobal("fetch", fetchMock) + return fetchMock + } + + it("cancels through a caller-provided signal", async () => { + const fetchMock = stubFetch() + const controller = new AbortController() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: controller.signal }, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + controller.abort() + expect(init.signal?.aborted).toBe(true) + }) + + it("keeps the timeout when a caller-provided signal is present", async () => { + const timeoutController = new AbortController() + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(timeoutController.signal) + const fetchMock = stubFetch() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: new AbortController().signal }, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + // Firing only the timeout leg aborts the request: a caller signal adds + // cancellation, it does not remove the 30s bound. + timeoutController.abort() + expect(init.signal?.aborted).toBe(true) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 1b575e15..3d9e00da 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -339,7 +339,14 @@ export class SupermemoryClient { options?: { signal?: AbortSignal }, ): Promise { try { - const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) + // Compose rather than choose: a caller-supplied signal must add + // cancellation on top of the timeout, not replace it. + const signal = options?.signal + ? AbortSignal.any([ + options.signal, + AbortSignal.timeout(FETCH_TIMEOUT_MS), + ]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS) const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: { From 274e6b6e6a89f74a5a58586b11fda37607431688 Mon Sep 17 00:00:00 2001 From: therahul-yo Date: Mon, 14 Sep 2026 06:29:41 +0000 Subject: [PATCH 10/21] fix(tools): bound and harden the shared /v4/profile request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supermemoryProfileSearch` in `shared/memory-client.ts` is the only Supermemory HTTP call in this package with neither a request timeout nor redirect handling. The identical `/v4/profile` call in `openai/middleware.ts` sets both, and `/v4/conversations` (`conversations-client.ts`) and `/v4/memories` (`shared/forget-memory.ts`) each set a 30s budget. Two consequences: - **Unbounded request.** A timeout only applied when the caller supplied a signal. `withSupermemory` passes one (5s), but `buildMemoriesText` is called with no signal by the Mastra processor and the VoltAgent middleware, and by the exported `buildMemoriesText` / `addSystemPrompt` helpers. `fetch` has no default deadline, so a stalled connection blocks the agent turn indefinitely — the failure both integrations' surrounding try/catch is written to absorb, but which never surfaces as an error. - **Redirects followed.** The request carries `Authorization: Bearer `; a 3xx from a misconfigured or attacker-influenced `baseUrl` was followed silently rather than refused. Apply a 30s `PROFILE_REQUEST_TIMEOUT_MS` unconditionally and set `redirect: "error"`. A caller signal is composed with the timeout via `AbortSignal.any` rather than replacing it, so a caller-side budget can only shorten the request, never leave it unbounded — the wrapper is kept separate so the composition is stated once rather than re-derived at the call site. `src/shared/memory-client.test.ts` existed but was absent from the `test:unit` file list CI runs, so its assertions never ran on a pull request; add it alongside the new coverage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L7GkmUn6skD6dCzKtcHDbe --- packages/tools/package.json | 2 +- .../tools/src/shared/memory-client.test.ts | 71 ++++++++++++++++++- packages/tools/src/shared/memory-client.ts | 31 +++++++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/tools/package.json b/packages/tools/package.json index 5c6b4086..49d957d7 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -8,7 +8,7 @@ "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts src/shared/memory-client.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/shared/memory-client.test.ts b/packages/tools/src/shared/memory-client.test.ts index 4b4edc0a..dee35699 100644 --- a/packages/tools/src/shared/memory-client.test.ts +++ b/packages/tools/src/shared/memory-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { buildMemoriesText } from "./memory-client" +import { buildMemoriesText, supermemoryProfileSearch } from "./memory-client" import { createLogger } from "./logger" const API_KEY = "sm_test_key" @@ -76,3 +76,72 @@ describe("buildMemoriesText", () => { expect(memories.match(/User is allergic to peanuts/g)).toHaveLength(1) }) }) + +describe("supermemoryProfileSearch request hardening", () => { + /** Captures the `fetch` init so the request options can be asserted. */ + function captureRequestInit() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ profile: { static: [], dynamic: [] } }), + }) + vi.stubGlobal("fetch", fetchMock) + return () => fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + } + + // The request carries the API key in an Authorization header. Following a + // redirect would replay it against a host the caller never configured. + it("refuses to follow redirects", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch(CONTAINER_TAG, "", BASE_URL, API_KEY) + + expect(getInit()?.redirect).toBe("error") + }) + + // Mastra, VoltAgent and the exported helpers call this with no signal, so + // without an unconditional timeout a hung socket blocks the turn forever. + it("bounds the request even when the caller passes no signal", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch(CONTAINER_TAG, "", BASE_URL, API_KEY) + + const signal = getInit()?.signal + expect(signal).toBeInstanceOf(AbortSignal) + expect(signal?.aborted).toBe(false) + }) + + // The caller signal is composed with the timeout rather than replacing it, + // so a caller-side budget still shortens the request. + it("still aborts when the caller's signal fires", async () => { + const getInit = captureRequestInit() + const controller = new AbortController() + + await supermemoryProfileSearch( + CONTAINER_TAG, + "", + BASE_URL, + API_KEY, + controller.signal, + ) + + const signal = getInit()?.signal + expect(signal?.aborted).toBe(false) + controller.abort(new Error("caller budget exhausted")) + expect(signal?.aborted).toBe(true) + expect((signal?.reason as Error).message).toBe("caller budget exhausted") + }) + + it("passes an already-aborted caller signal straight through", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch( + CONTAINER_TAG, + "", + BASE_URL, + API_KEY, + AbortSignal.abort(new Error("already cancelled")), + ) + + expect(getInit()?.signal?.aborted).toBe(true) + }) +}) diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 5097f7a1..f8c688ba 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -15,6 +15,28 @@ import { defaultPromptTemplate, } from "./prompt-builder" +/** + * Upper bound for a single `/v4/profile` request, matching the budget the other + * Supermemory calls in this package already use (`/v4/conversations` and + * `/v4/memories`). Without it a stalled connection has no deadline at all: the + * Mastra and VoltAgent integrations, and the exported `buildMemoriesText` / + * `addSystemPrompt` helpers, call this function with no signal of their own, so + * a hung socket would block the agent turn forever. + */ +export const PROFILE_REQUEST_TIMEOUT_MS = 30_000 + +/** + * Bound a request by the package timeout, keeping any caller signal live. + * + * The caller signal is composed with the timeout rather than replacing it, so + * a caller-supplied deadline can only ever shorten the request, never remove + * its upper bound. + */ +const withRequestTimeout = (signal?: AbortSignal): AbortSignal => { + const timeout = AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS) + return signal ? AbortSignal.any([signal, timeout]) : timeout +} + /** * Fetches profile and search results from the Supermemory API. * @@ -22,7 +44,9 @@ import { * @param queryText - Optional query text for semantic search * @param baseUrl - The API base URL * @param apiKey - The API key for authentication - * @param signal - Optional AbortSignal to cancel the request (e.g. retrieval timeout) + * @param signal - Optional AbortSignal to cancel the request early (e.g. a + * caller-side retrieval budget). It is composed with the package timeout, so + * it can shorten the request but never leaves it unbounded. * @returns The profile structure with static, dynamic, and search results */ export const supermemoryProfileSearch = async ( @@ -51,7 +75,10 @@ export const supermemoryProfileSearch = async ( Authorization: `Bearer ${apiKey}`, }, body: payload, - ...(signal ? { signal } : {}), + // The request carries the API key in an Authorization header, so a + // redirect is refused rather than followed to an unverified host. + redirect: "error", + signal: withRequestTimeout(signal), }) if (!response.ok) { From 17ced7223323e8458bac59095401ce4ac83ae06f Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:07:40 +0530 Subject: [PATCH 11/21] fix(tools): escape delimiters in makeTurnKey to prevent cache collision --- packages/tools/src/shared/cache.ts | 7 ++++- packages/tools/src/tools-shared.test.ts | 37 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/shared/cache.ts b/packages/tools/src/shared/cache.ts index 15ce16fe..0fe1beed 100644 --- a/packages/tools/src/shared/cache.ts +++ b/packages/tools/src/shared/cache.ts @@ -5,6 +5,9 @@ import type { MemoryMode } from "./types" * Generic memory cache for storing per-turn memories to avoid redundant API calls. * Used to cache memory retrieval results during tool-call loops within the same turn. */ +const escapeKeySegment = (segment: string): string => + segment.replace(/%/g, "%25").replace(/:/g, "%3A") + export class MemoryCache { private cache: LRUCache = new LRUCache({ max: 100 }) @@ -25,7 +28,9 @@ export class MemoryCache { message: string, ): string { const normalizedMessage = message.trim().replace(/\s+/g, " ") - return `${containerTag}:${threadId || ""}:${mode}:${normalizedMessage}` + const safeContainerTag = escapeKeySegment(containerTag) + const safeThreadId = escapeKeySegment(threadId ?? "") + return `${safeContainerTag}:${safeThreadId}:${mode}:${normalizedMessage}` } /** diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 3cc070ba..718b286b 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { makeTurnKey } from "./shared/cache" import { DEFAULT_VALUES, SEARCH_LIMIT_BOUNDS, @@ -130,3 +131,39 @@ describe("deduplicateMemoriesForMode", () => { expect(deduplicated.searchResults).toEqual([]) }) }) + +describe("makeTurnKey", () => { + it("generates predictable turn key for standard inputs", () => { + expect(makeTurnKey("user-123", "thread-456", "full", "hello world")).toBe( + "user-123:thread-456:full:hello world", + ) + }) + + it("normalizes and collapses whitespace in message", () => { + expect( + makeTurnKey("user-123", "thread-456", "full", " hello world \n "), + ).toBe("user-123:thread-456:full:hello world") + }) + + it("handles undefined threadId cleanly", () => { + expect(makeTurnKey("user-123", undefined, "profile", "test")).toBe( + "user-123::profile:test", + ) + }) + + it("escapes colons to prevent cache key collisions between tag and threadId", () => { + const keyA = makeTurnKey("user:123", "456", "profile", "hi") + const keyB = makeTurnKey("user", "123:456", "profile", "hi") + expect(keyA).toBe("user%3A123:456:profile:hi") + expect(keyB).toBe("user:123%3A456:profile:hi") + expect(keyA).not.toBe(keyB) + }) + + it("escapes percent signs to avoid ambiguity with encoded sequences", () => { + const keyA = makeTurnKey("user%3A123", "456", "profile", "hi") + const keyB = makeTurnKey("user:123", "456", "profile", "hi") + expect(keyA).toBe("user%253A123:456:profile:hi") + expect(keyB).toBe("user%3A123:456:profile:hi") + expect(keyA).not.toBe(keyB) + }) +}) From 977b2fcd0aae7a268c5685959e779349b726997f Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:10:25 +0530 Subject: [PATCH 12/21] fix(tools): enforce configured container scope --- packages/tools/src/ai-sdk.ts | 9 ++- packages/tools/src/openai/tools.ts | 9 ++- packages/tools/src/tool-operations.test.ts | 92 ++++++++++++++++++++++ packages/tools/src/tools-shared.test.ts | 27 +++++++ packages/tools/src/tools-shared.ts | 28 +++++++ 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 97d6a36b..c7536d51 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -10,6 +10,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" import type { SupermemoryToolsConfig } from "./types" @@ -146,7 +147,7 @@ export const getProfileTool = ( }), execute: async ({ containerTag, query }) => { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -199,7 +200,7 @@ export const documentListTool = ( execute: async ({ containerTag, limit, page }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -339,12 +340,12 @@ export const memoryForgetTool = ( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1be29084..ad8c15f3 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -9,6 +9,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" import type { SupermemoryToolsConfig } from "../types" @@ -333,7 +334,7 @@ export function createGetProfileFunction( query?: string }): Promise { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -374,7 +375,7 @@ export function createDocumentListFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -502,12 +503,12 @@ export function createMemoryForgetFunction( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index b3212ca4..b973411c 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const documentsDeleteBulk = vi.fn() const documentsGet = vi.fn() const documentsList = vi.fn() +const profileRequest = vi.fn() const clientAdd = vi.fn() const clientSearch = vi.fn() const clientOptions: unknown[] = [] @@ -15,6 +16,7 @@ vi.mock("supermemory", () => { constructor(options: unknown) { clientOptions.push(options) } + profile = profileRequest add = clientAdd search = clientSearch documents = { @@ -55,6 +57,10 @@ beforeEach(() => { memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) + profileRequest.mockReset().mockResolvedValue({ + profile: { static: [], dynamic: [] }, + searchResults: { results: [] }, + }) clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) clientSearch.mockReset().mockResolvedValue({ results: [] }) clientOptions.length = 0 @@ -112,6 +118,92 @@ describe("searchMemories", () => { }) }) +describe("configured container scope", () => { + it("rejects out-of-scope tags across both tool surfaces before I/O", async () => { + const config = { containerTags: ["tenant-a"] } + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + + const results = (await Promise.all([ + executeTool(aiSdk.getProfileTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createGetProfileFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.documentListTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createDocumentListFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.memoryForgetTool(API_KEY, config), { + containerTag: "tenant-b", + memoryId: "mem_1", + }), + openAi.createMemoryForgetFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + memoryId: "mem_1", + }), + ])) as Array<{ success: boolean; error?: string }> + + expect(results).toHaveLength(6) + for (const result of results) { + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + } + expect(profileRequest).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + const getProfile = openAi.createGetProfileFunction(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }) + + const result = await getProfile({ containerTag: "tenant-b" }) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledWith({ + containerTag: "tenant-b", + }) + }) + + it("does not let model input override implicit or project scopes", async () => { + const implicitResult = await openAi.createDocumentListFunction(API_KEY)({ + containerTag: "tenant-b", + }) + const projectResult = (await executeTool( + aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }), + { containerTag: "tenant-b" }, + )) as { success: boolean; error?: string } + + expect(implicitResult.success).toBe(false) + expect(implicitResult.error).toContain("outside the configured scope") + expect(projectResult.success).toBe(false) + expect(projectResult.error).toContain("outside the configured scope") + expect(documentsList).not.toHaveBeenCalled() + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("fails closed when the configured scope is empty", () => { + expect(() => + openAi.createGetProfileFunction(API_KEY, { containerTags: [] }), + ).toThrow("at least one non-empty containerTag") + expect(profileRequest).not.toHaveBeenCalled() + }) +}) + describe("documentDelete", () => { it("ai-sdk variant passes the document id string to the SDK", async () => { const tool = aiSdk.documentDeleteTool(API_KEY) diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 718b286b..b6db8acb 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -6,6 +6,7 @@ import { clampSearchLimit, deduplicateMemoriesForMode, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" describe("clampSearchLimit", () => { @@ -59,6 +60,32 @@ describe("getContainerTags", () => { }) }) +describe("resolveConfiguredContainerTag", () => { + it("defaults to the first configured tag", () => { + expect(resolveConfiguredContainerTag(["tenant-a", "tenant-b"])).toBe( + "tenant-a", + ) + }) + + it("allows selection within a multi-tag scope", () => { + expect( + resolveConfiguredContainerTag(["tenant-a", "tenant-b"], "tenant-b"), + ).toBe("tenant-b") + }) + + it("rejects tags outside the configured scope", () => { + expect(() => + resolveConfiguredContainerTag(["tenant-a"], "tenant-b"), + ).toThrow('Container tag "tenant-b" is outside the configured scope') + }) + + it("rejects an empty configured scope", () => { + expect(() => resolveConfiguredContainerTag([])).toThrow( + "require at least one configured container tag", + ) + }) +}) + describe("deduplicateMemoriesForMode", () => { // The profile is not injected in "query" mode, so a memory that is both a // profile fact and a search hit must survive in the search results — diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index f4d150ca..eca95ce5 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -287,6 +287,34 @@ function hasCompleteContainerTagScope( ) } +/** + * Resolves a model-supplied container tag without allowing it to escape the + * developer-configured scope. + */ +export function resolveConfiguredContainerTag( + configuredTags: readonly string[], + requestedTag?: string, +): string { + const defaultTag = configuredTags[0] + if (defaultTag === undefined) { + throw new Error( + "Supermemory tools require at least one configured container tag.", + ) + } + + if (requestedTag === undefined) { + return defaultTag + } + + if (!configuredTags.includes(requestedTag)) { + throw new Error( + `Container tag "${requestedTag}" is outside the configured scope.`, + ) + } + + return requestedTag +} + /** * Memory item interface representing a single memory with optional metadata */ From 8a1cf31af88a7d75e35e4f2a3aa21d3207ee7c73 Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:14:26 +0530 Subject: [PATCH 13/21] test(tools): type scoped operation results --- packages/tools/src/tool-operations.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index b973411c..9e990435 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -36,9 +36,15 @@ import * as openAi from "./openai/tools" const API_KEY = "sm_test_key" -type ToolWithExecute = { execute: (args: Record) => unknown } +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} -function executeTool(tool: unknown, args: Record) { +function executeTool( + tool: unknown, + args: Record, +): Promise { return (tool as ToolWithExecute).execute(args) } @@ -124,7 +130,7 @@ describe("configured container scope", () => { const fetchMock = vi.fn() vi.stubGlobal("fetch", fetchMock) - const results = (await Promise.all([ + const results: ToolExecutionResult[] = await Promise.all([ executeTool(aiSdk.getProfileTool(API_KEY, config), { containerTag: "tenant-b", }), @@ -154,7 +160,7 @@ describe("configured container scope", () => { containerTag: "tenant-b", memoryId: "mem_1", }), - ])) as Array<{ success: boolean; error?: string }> + ]) expect(results).toHaveLength(6) for (const result of results) { From 44869502b10695cd6ed748c09504bc1533d12ca0 Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 08:17:45 +0530 Subject: [PATCH 14/21] test(tools): remove redundant result assertions --- packages/tools/src/tool-operations.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 9e990435..519d7302 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -189,10 +189,10 @@ describe("configured container scope", () => { const implicitResult = await openAi.createDocumentListFunction(API_KEY)({ containerTag: "tenant-b", }) - const projectResult = (await executeTool( + const projectResult = await executeTool( aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }), { containerTag: "tenant-b" }, - )) as { success: boolean; error?: string } + ) expect(implicitResult.success).toBe(false) expect(implicitResult.error).toContain("outside the configured scope") @@ -213,9 +213,7 @@ describe("configured container scope", () => { describe("documentDelete", () => { it("ai-sdk variant passes the document id string to the SDK", async () => { const tool = aiSdk.documentDeleteTool(API_KEY) - const result = (await executeTool(tool, { documentId: "doc_123" })) as { - success: boolean - } + const result = await executeTool(tool, { documentId: "doc_123" }) expect(result.success).toBe(true) expect(documentsGet).toHaveBeenCalledWith("doc_123") @@ -339,9 +337,9 @@ describe("memoryForget", () => { containerTags: ["user_2"], }) - const result = (await executeTool(tool, { + const result = await executeTool(tool, { memoryContent: "stale fact", - })) as { success: boolean } + }) expect(result.success).toBe(true) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] From b89ed824a6e8cc3d7fc78e275c9b75cb06ff0829 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:25:19 +0530 Subject: [PATCH 15/21] fix(tools): accept zero-argument OpenAI tool calls and reject non-object args `getProfile`, `documentList` and `memoryForget` all declare `required: []`, so a model may legitimately call them with no arguments at all. The OpenAI API serialises that as `arguments: ""`, and `parseToolArguments` handed the empty string straight to `JSON.parse`, so every no-argument call came back as {"success":false,"error":"Invalid JSON arguments for getProfile"} Those three tools were unreachable in their documented no-argument form. The same gate also lets non-object JSON through. `"null"` parses cleanly and then rejects in the destructuring parameter of every tool function -- `TypeError: Cannot destructure property 'containerTag' of 'object null'` -- which escapes `executeToolCall`, since it has no catch, and fails the whole request. That is precisely the throw #1488 added this gate to contain. `"5"` and `"\"text\""` are quieter but worse: they destructure to `undefined` and call the API with no container tag at all. Treat blank arguments as `{}`, and require the parsed value to be a non-null, non-array object. Malformed JSON still returns the tool error #1488 added. Adds eight regression tests. Six of them fail against the current implementation -- two on the blank-argument path and four on the non-object path, one carrying the raw TypeError. The two guard tests, malformed JSON and an ordinary well-formed call, pass both before and after, so the behaviour --- packages/tools/src/openai/tools.ts | 15 +++- packages/tools/src/tool-operations.test.ts | 81 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index ad8c15f3..c0371fa9 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -571,8 +571,21 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool } function parseToolArguments(argumentsJson: string) { + // getProfile, documentList and memoryForget all declare `required: []`, so a model + // may legitimately call them with no arguments. OpenAI serialises that as `""`, + // which is "no arguments" rather than malformed JSON — parse it as `{}`. + const source = argumentsJson?.trim() || "{}" + try { - return { success: true as const, value: JSON.parse(argumentsJson) } + const value = JSON.parse(source) + + // `"null"`, `"5"` and `"[]"` parse cleanly, then throw in the destructuring + // parameter of every tool function — the throw this gate exists to contain. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { success: false as const } + } + + return { success: true as const, value } } catch { return { success: false as const } } diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 519d7302..ddd68d8d 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -370,6 +370,87 @@ describe("memoryForget", () => { }) }) +describe("openai executeToolCall argument parsing", () => { + type ExecutorToolCall = Parameters< + ReturnType + >[0] + + function toolCall(name: string, args: string) { + return { + id: "call_1", + type: "function", + function: { name, arguments: args }, + } as ExecutorToolCall + } + + // getProfile, documentList and memoryForget declare `required: []`, so the model + // is allowed to call them with no arguments. OpenAI serialises that as "". + it.each([ + "", + " ", + ])("runs a zero-argument tool when arguments are %p", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY, { + containerTags: ["user_1"], + }) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledTimes(1) + expect(profileRequest).toHaveBeenCalledWith({ containerTag: "user_1" }) + }) + + // These parse cleanly, so the JSON guard lets them through to a destructuring + // parameter that rejects — the throw the guard exists to contain. + it.each([ + "null", + "5", + "[]", + '"text"', + ])("rejects non-object arguments %p as a tool result rather than throwing", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for getProfile/) + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("still reports malformed JSON as a tool error", async () => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse( + await execute(toolCall("searchMemories", "{not json")), + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for searchMemories/) + expect(searchExecute).not.toHaveBeenCalled() + }) + + it("still passes well-formed arguments through", async () => { + searchExecute.mockResolvedValue({ results: [{ id: "mem_1" }] }) + const execute = openAi.createToolCallExecutor(API_KEY, { + containerTags: ["user_1"], + }) + + const result = JSON.parse( + await execute( + toolCall( + "searchMemories", + JSON.stringify({ informationToGet: "tea", limit: 3 }), + ), + ), + ) + + expect(result.success).toBe(true) + expect(searchExecute).toHaveBeenCalledWith( + expect.objectContaining({ q: "tea", limit: 3 }), + ) + }) +}) + describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const CUSTOM_ID = "memories_prefs_txt" From f7e5bf2f773fda37f3d2551404b30135db349805 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:05:29 +0530 Subject: [PATCH 16/21] fix(tools): handle trailing slashes and whitespace in normalizeBaseUrl and addConversation --- packages/tools/src/conversations-client.ts | 14 ++++++- packages/tools/src/shared/context.ts | 4 +- packages/tools/src/tools-shared.test.ts | 48 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 4a458e3a..b83e725d 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -6,6 +6,8 @@ * diffing and append detection on the backend. */ +import { normalizeBaseUrl } from "./shared/context" + export interface ConversationMessage { role: "user" | "assistant" | "system" | "tool" content: string | ContentPart[] @@ -52,6 +54,16 @@ export const toConversationImageUrl = ( : `data:${mediaType};base64,${trimmed}` } + if (typeof value === "object" && value !== null && "url" in value) { + const rawUrl = (value as { url: unknown }).url + if ( + typeof rawUrl === "string" || + (typeof URL !== "undefined" && rawUrl instanceof URL) + ) { + return toConversationImageUrl(rawUrl, mediaType) + } + } + const bytes = value instanceof Uint8Array ? value @@ -117,7 +129,7 @@ const CONVERSATION_REQUEST_TIMEOUT_MS = 30_000 export async function addConversation( params: AddConversationParams, ): Promise { - const baseUrl = params.baseUrl || "https://api.supermemory.ai" + const baseUrl = normalizeBaseUrl(params.baseUrl) const url = `${baseUrl}/v4/conversations` const response = await fetch(url, { diff --git a/packages/tools/src/shared/context.ts b/packages/tools/src/shared/context.ts index b4bf3eb1..ff3035a2 100644 --- a/packages/tools/src/shared/context.ts +++ b/packages/tools/src/shared/context.ts @@ -8,8 +8,8 @@ import Supermemory from "supermemory" */ export const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" - if (!url) return defaultUrl - return url.endsWith("/") ? url.slice(0, -1) : url + const trimmed = url?.trim().replace(/\/+$/, "") + return trimmed || defaultUrl } /** diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index b6db8acb..034f3cce 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest" import { makeTurnKey } from "./shared/cache" +import { toConversationImageUrl } from "./conversations-client" +import { normalizeBaseUrl } from "./shared/context" import { DEFAULT_VALUES, SEARCH_LIMIT_BOUNDS, @@ -194,3 +196,49 @@ describe("makeTurnKey", () => { expect(keyA).not.toBe(keyB) }) }) + +describe("normalizeBaseUrl", () => { + it("returns default URL when input is missing or empty", () => { + expect(normalizeBaseUrl()).toBe("https://api.supermemory.ai") + expect(normalizeBaseUrl("")).toBe("https://api.supermemory.ai") + expect(normalizeBaseUrl(" ")).toBe("https://api.supermemory.ai") + }) + + it("collapses single and multiple trailing slashes", () => { + expect(normalizeBaseUrl("http://localhost:6768/")).toBe( + "http://localhost:6768", + ) + expect(normalizeBaseUrl("http://localhost:6768///")).toBe( + "http://localhost:6768", + ) + expect(normalizeBaseUrl("https://api.supermemory.ai/")).toBe( + "https://api.supermemory.ai", + ) + }) + + it("trims whitespace around URLs", () => { + expect(normalizeBaseUrl(" http://localhost:6768/ ")).toBe( + "http://localhost:6768", + ) + }) +}) + +describe("toConversationImageUrl", () => { + it("handles string URLs and trims whitespace", () => { + expect(toConversationImageUrl("https://example.com/image.png")).toBe( + "https://example.com/image.png", + ) + }) + + it("handles object representations containing url", () => { + expect( + toConversationImageUrl({ url: "https://example.com/avatar.jpg" }), + ).toBe("https://example.com/avatar.jpg") + }) + + it("returns null for invalid or empty inputs", () => { + expect(toConversationImageUrl("")).toBeNull() + expect(toConversationImageUrl(null)).toBeNull() + expect(toConversationImageUrl(undefined)).toBeNull() + }) +}) From 81f1f5ccf1847579598b7a6d5cf0087c17f3884c Mon Sep 17 00:00:00 2001 From: rajashidattapy Date: Thu, 20 Aug 2026 00:27:14 +0530 Subject: [PATCH 17/21] fix(tools): clear every type error under packages/tools/test `claude-memory.ts` moved to src/ and five files in test/ kept importing `./claude-memory`; the Mastra and AI-SDK fixtures drifted behind their installed types. Together these left `bunx tsc --noEmit` unusable for the package. - repoint the five `./claude-memory` imports at `../src/claude-memory` - add the `state` property Mastra now requires on ProcessInputArgs and ProcessOutputResultArgs (35 fixtures) - add `totalTokens` to the two LanguageModelV2Usage fixtures and drop the `rawCall` property the type no longer has - iterate with `.entries()` instead of indexing, which was tripping noUncheckedIndexedAccess once the files started resolving - pass containerTag/customId through options in test-supermemory.ts, matching the current `withSupermemory` signature - exclude test/chatapp: a standalone Next.js demo with its own package.json, lockfile and tsconfig that has no business in this package's program Repointing the import also made test/claude-memory.test.ts loadable again, and it turned out to be a live-API suite: gate it behind SUPERMEMORY_API_KEY the same way the other integration suites are, so `vitest run` no longer collects a dozen 401s. --- packages/tools/test-supermemory.ts | 4 ++- packages/tools/test/anthropic-example.ts | 31 ++++++++++--------- packages/tools/test/claude-memory-examples.ts | 8 +++-- .../tools/test/claude-memory-real-example.ts | 5 ++- packages/tools/test/claude-memory.test.ts | 15 ++++++--- .../tools/test/mastra/integration.test.ts | 16 ++++++++++ packages/tools/test/mastra/unit.test.ts | 19 ++++++++++++ packages/tools/test/test-memory-tool.ts | 8 +++-- .../tools/test/with-supermemory/unit.test.ts | 4 +-- packages/tools/tsconfig.json | 3 ++ 10 files changed, 85 insertions(+), 28 deletions(-) diff --git a/packages/tools/test-supermemory.ts b/packages/tools/test-supermemory.ts index b3863c7e..628968fe 100644 --- a/packages/tools/test-supermemory.ts +++ b/packages/tools/test-supermemory.ts @@ -10,7 +10,9 @@ const openai = new OpenAI({ }) // Wrap OpenAI client with supermemory -const openaiWithSupermemory = withSupermemory(openai, "test_user_123", { +const openaiWithSupermemory = withSupermemory(openai, { + containerTag: "test_user_123", + customId: "test_user_123_chat", verbose: true, // Enable logging to see what's happening mode: "full", // Search both profile and query memories addMemory: "always", // Auto-save conversations as memories diff --git a/packages/tools/test/anthropic-example.ts b/packages/tools/test/anthropic-example.ts index 0da95b77..a9855eb7 100644 --- a/packages/tools/test/anthropic-example.ts +++ b/packages/tools/test/anthropic-example.ts @@ -5,7 +5,10 @@ */ import Anthropic from "@anthropic-ai/sdk" -import { createClaudeMemoryTool } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" /** @@ -37,7 +40,7 @@ async function chatWithMemoryTool() { }) // Conversation messages - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ { role: "user", content: @@ -45,7 +48,7 @@ async function chatWithMemoryTool() { }, ] - console.log("💬 User:", messages[0].content) + console.log("💬 User:", messages[0]?.content) console.log("\n🔄 Sending to Claude with memory tool...") try { @@ -66,20 +69,21 @@ async function chatWithMemoryTool() { console.log("📥 Claude responded:") // Process the response - const toolResults: Anthropic.Messages.ToolResultBlockParam[] = [] + const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = [] for (const block of response.content) { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { + const command = block.input as MemoryCommand console.log("🔧 Claude is using memory tool:") - console.log(" Command:", block.input.command) - console.log(" Path:", block.input.path) + console.log(" Command:", command.command) + console.log(" Path:", command.path) // Handle the memory tool call - const memoryResult = await memoryTool.handleCommand(block.input as any) + const memoryResult = await memoryTool.handleCommand(command) - const toolResult: Anthropic.Messages.ToolResultBlockParam = { + const toolResult: Anthropic.Beta.Messages.BetaToolResultBlockParam = { type: "tool_result", tool_use_id: block.id, content: memoryResult.success @@ -138,14 +142,13 @@ async function chatWithMemoryTool() { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { + const command = block.input as MemoryCommand console.log("🔧 Claude is using memory tool again:") - console.log(" Command:", block.input.command) - console.log(" Path:", block.input.path) + console.log(" Command:", command.command) + console.log(" Path:", command.path) // Handle additional memory tool calls - const memoryResult = await memoryTool.handleCommand( - block.input as any, - ) + const memoryResult = await memoryTool.handleCommand(command) console.log( "📊 Memory operation result:", memoryResult.success ? "✅ Success" : "❌ Failed", @@ -239,7 +242,7 @@ async function testMemoryOperations() { command: { command: "view" as const, path: "/memories/project-notes.txt", - view_range: [4, 8], + view_range: [4, 8] as [number, number], }, }, ] diff --git a/packages/tools/test/claude-memory-examples.ts b/packages/tools/test/claude-memory-examples.ts index f37a47fa..4336f1e9 100644 --- a/packages/tools/test/claude-memory-examples.ts +++ b/packages/tools/test/claude-memory-examples.ts @@ -6,7 +6,10 @@ * 2. Anthropic SDK integration */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" // ===================================================== // Example 1: Direct TypeScript/fetch Integration @@ -67,8 +70,7 @@ export async function directFetchExample() { ] // Execute each command - for (let i = 0; i < commands.length; i++) { - const command = commands[i] + for (const [i, command] of commands.entries()) { console.log( `\n📝 Step ${i + 1}: ${command.command.toUpperCase()} ${command.path}`, ) diff --git a/packages/tools/test/claude-memory-real-example.ts b/packages/tools/test/claude-memory-real-example.ts index dbd03b21..1a925669 100644 --- a/packages/tools/test/claude-memory-real-example.ts +++ b/packages/tools/test/claude-memory-real-example.ts @@ -4,7 +4,10 @@ * This shows actual tool call handling based on real Claude API responses */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" // ===================================================== // Real Claude API Integration diff --git a/packages/tools/test/claude-memory.test.ts b/packages/tools/test/claude-memory.test.ts index 97bccd90..f00c9507 100644 --- a/packages/tools/test/claude-memory.test.ts +++ b/packages/tools/test/claude-memory.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach } from "vitest" -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" // Test configuration @@ -10,6 +13,10 @@ const TEST_CONFIG = { memoryContainerTag: "claude_memory_test", } +// Same gate the other integration suites use: these hit the live API, so they +// only run when a key is present. Without one every request comes back 401. +const shouldRunIntegration = !!process.env.SUPERMEMORY_API_KEY + describe("Claude Memory Tool", () => { let memoryTool: ReturnType @@ -62,7 +69,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("File operations", () => { + describe.skipIf(!shouldRunIntegration)("File operations", () => { const testFilePath = "/memories/test-file.txt" const testContent = "Hello, World!\nThis is a test file.\nLine 3 here." @@ -219,7 +226,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("Directory operations", () => { + describe.skipIf(!shouldRunIntegration)("Directory operations", () => { it("should list empty directory", async () => { const result = await memoryTool.handleCommand({ command: "view", @@ -263,7 +270,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("Error handling", () => { + describe.skipIf(!shouldRunIntegration)("Error handling", () => { it("should handle missing file", async () => { const result = await memoryTool.handleCommand({ command: "view", diff --git a/packages/tools/test/mastra/integration.test.ts b/packages/tools/test/mastra/integration.test.ts index cf6402a2..7dda1307 100644 --- a/packages/tools/test/mastra/integration.test.ts +++ b/packages/tools/test/mastra/integration.test.ts @@ -121,6 +121,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -153,6 +154,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -192,6 +194,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -234,6 +237,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -248,6 +252,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -283,6 +288,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -314,6 +320,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -347,6 +354,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -384,6 +392,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -418,6 +427,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await input.processInput(inputArgs) @@ -431,6 +441,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await output.processOutputResult(outputArgs) @@ -471,6 +482,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await inputProcessor.processInput(args) @@ -535,6 +547,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -559,6 +572,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -593,6 +607,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -617,6 +632,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await expect(processor.processOutputResult(args)).resolves.toBeDefined() diff --git a/packages/tools/test/mastra/unit.test.ts b/packages/tools/test/mastra/unit.test.ts index e4fd8263..4100fc3c 100644 --- a/packages/tools/test/mastra/unit.test.ts +++ b/packages/tools/test/mastra/unit.test.ts @@ -191,6 +191,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -227,6 +228,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -238,6 +240,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -270,6 +273,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -281,6 +285,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -302,6 +307,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -333,6 +339,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -360,6 +367,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -389,6 +397,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -432,6 +441,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -499,6 +509,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -539,6 +550,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -567,6 +579,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -602,6 +615,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -636,6 +650,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -672,6 +687,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -727,6 +743,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -760,6 +777,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } // Should not throw @@ -779,6 +797,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) diff --git a/packages/tools/test/test-memory-tool.ts b/packages/tools/test/test-memory-tool.ts index c0395a79..65bfaa1c 100644 --- a/packages/tools/test/test-memory-tool.ts +++ b/packages/tools/test/test-memory-tool.ts @@ -4,7 +4,10 @@ * Run with: bun run src/test-memory-tool.ts */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" async function testMemoryTool() { @@ -140,8 +143,7 @@ async function testMemoryTool() { let passed = 0 let failed = 0 - for (let i = 0; i < testCases.length; i++) { - const testCase = testCases[i] + for (const [i, testCase] of testCases.entries()) { console.log(`\\n🔄 Test ${i + 1}/${testCases.length}: ${testCase.name}`) try { diff --git a/packages/tools/test/with-supermemory/unit.test.ts b/packages/tools/test/with-supermemory/unit.test.ts index 50900a6f..8c3d68af 100644 --- a/packages/tools/test/with-supermemory/unit.test.ts +++ b/packages/tools/test/with-supermemory/unit.test.ts @@ -561,8 +561,8 @@ describe("Unit: withSupermemory", () => { usage: { inputTokens: 1, outputTokens: 1, + totalTokens: 2, }, - rawCall: { rawPrompt: [], rawSettings: {} }, warnings: [], }) @@ -626,8 +626,8 @@ describe("Unit: withSupermemory", () => { usage: { inputTokens: 1, outputTokens: 1, + totalTokens: 2, }, - rawCall: { rawPrompt: [], rawSettings: {} }, warnings: [], }) diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index d40ba072..61e121d9 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@total-typescript/tsconfig/bundler/dom/library-monorepo", + // test/chatapp is a standalone Next.js demo with its own package.json, + // lockfile and tsconfig; it is not part of this package's program. + "exclude": ["node_modules", "test/chatapp"], "compilerOptions": { "baseUrl": ".", "paths": { From b5db9a725b8a2abff3dd18a3427cae508657e932 Mon Sep 17 00:00:00 2001 From: rajashidattapy Date: Thu, 20 Aug 2026 00:52:58 +0530 Subject: [PATCH 18/21] refactor(tools): narrow memory tool input with a type guard Replace the bare `block.input as MemoryCommand` assertions in the Anthropic example with an isMemoryCommand type guard, so unexpected tool input is skipped instead of silently mistyped. --- packages/tools/test/anthropic-example.ts | 33 ++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/tools/test/anthropic-example.ts b/packages/tools/test/anthropic-example.ts index a9855eb7..a4d1d9fe 100644 --- a/packages/tools/test/anthropic-example.ts +++ b/packages/tools/test/anthropic-example.ts @@ -11,6 +11,27 @@ import { } from "../src/claude-memory" import "dotenv/config" +const MEMORY_COMMANDS: readonly string[] = [ + "view", + "create", + "str_replace", + "insert", + "delete", + "rename", +] + +function isMemoryCommand(input: unknown): input is MemoryCommand { + return ( + typeof input === "object" && + input !== null && + "command" in input && + "path" in input && + typeof input.command === "string" && + MEMORY_COMMANDS.includes(input.command) && + typeof input.path === "string" + ) +} + /** * Handle Claude's memory tool calls using the Anthropic SDK */ @@ -75,7 +96,11 @@ async function chatWithMemoryTool() { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { - const command = block.input as MemoryCommand + const command = block.input + if (!isMemoryCommand(command)) { + console.log("Skipping unrecognized memory tool input:", command) + continue + } console.log("🔧 Claude is using memory tool:") console.log(" Command:", command.command) console.log(" Path:", command.path) @@ -142,7 +167,11 @@ async function chatWithMemoryTool() { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { - const command = block.input as MemoryCommand + const command = block.input + if (!isMemoryCommand(command)) { + console.log("Skipping unrecognized memory tool input:", command) + continue + } console.log("🔧 Claude is using memory tool again:") console.log(" Command:", command.command) console.log(" Path:", command.path) From e3d32b7d52e73e037a8d5d1879eadaa5f7c4f785 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:23:50 -0700 Subject: [PATCH 19/21] fix(tools): resolve openai-middleware and voltagent test type errors (#1689) --- packages/tools/test/openai-middleware.unit.test.ts | 9 ++++++--- packages/tools/test/voltagent.unit.test.ts | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/tools/test/openai-middleware.unit.test.ts b/packages/tools/test/openai-middleware.unit.test.ts index e3249c8d..ce04e1c0 100644 --- a/packages/tools/test/openai-middleware.unit.test.ts +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -26,7 +26,7 @@ describe("OpenAI middleware memory context", () => { }), }), ) - const originalCreate = vi.fn(() => + const originalCreate = vi.fn((_body?: unknown) => Object.assign(Promise.resolve({ choices: [] }), { asResponse: async () => new Response(), }), @@ -53,8 +53,11 @@ describe("OpenAI middleware memory context", () => { ], }) - const forwarded = originalCreate.mock.calls[0]?.[0] - const content = String(forwarded.messages[0].content) + const forwarded = originalCreate.mock.calls[0]?.[0] as + | { messages: Array<{ role: string; content: unknown }> } + | undefined + expect(forwarded).toBeDefined() + const content = String(forwarded?.messages[0]?.content) expect(content).toContain("Be helpful.") expect(content).toContain("Fresh profile fact") expect(content).not.toContain("Stale profile fact") diff --git a/packages/tools/test/voltagent.unit.test.ts b/packages/tools/test/voltagent.unit.test.ts index 559f1fa1..4c9f8a8f 100644 --- a/packages/tools/test/voltagent.unit.test.ts +++ b/packages/tools/test/voltagent.unit.test.ts @@ -36,10 +36,13 @@ describe("VoltAgent memory context", () => { parts: [], }, ], - } as Parameters>[0] + } as unknown as Parameters>[0] const result = await hooks.onPrepareMessages?.(args) - const content = String(result?.messages?.[0]?.content ?? "") + const firstMessage = result?.messages?.[0] as + | Record + | undefined + const content = String(firstMessage?.content ?? "") expect(content).toContain("Be helpful.") expect(content).toContain("Fresh profile fact") expect(content).not.toContain("Stale profile fact") From 45fe24e85848ed69bf294897aed35d2931af52d7 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Fri, 2 Oct 2026 18:41:24 -0700 Subject: [PATCH 20/21] fix(tools): adapt community fixes to current main Port contributor tests to the current SDK mocks, keep str_replace new_str required, merge the #1504 scope check with main's multi-tag default, and drop the unrelated image-url change from #1678. --- packages/tools/src/claude-memory.test.ts | 28 +-- packages/tools/src/claude-memory.ts | 16 +- packages/tools/src/conversations-client.ts | 10 - packages/tools/src/tool-operations.test.ts | 6 +- packages/tools/src/tools-shared.test.ts | 21 --- .../tools/test/claude-memory-commands.test.ts | 171 +++++------------- 6 files changed, 73 insertions(+), 179 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 3509541c..efd993c0 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -186,7 +186,8 @@ describe("ClaudeMemoryTool insert line semantics", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() addMock.mockReset() mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") @@ -298,19 +299,20 @@ describe("ClaudeMemoryTool path traversal", () => { tool = new ClaudeMemoryTool("test-api-key") }) - it.each(["/memories/..", "/memories/foo/..", "/memories/../secrets.txt"])( - "rejects parent-directory path %s", - async (path) => { - const result = await tool.handleCommand({ - command: "view", - path, - }) + it.each([ + "/memories/..", + "/memories/foo/..", + "/memories/../secrets.txt", + ])("rejects parent-directory path %s", async (path) => { + const result = await tool.handleCommand({ + command: "view", + path, + }) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid path") - expect(documentsListMock).not.toHaveBeenCalled() - }, - ) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") + expect(documentsListMock).not.toHaveBeenCalled() + }) }) describe("ClaudeMemoryTool path normalization collision resistance", () => { diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 5c36981f..bd169879 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -126,20 +126,14 @@ export class ClaudeMemoryTool { } return await this.create(path, command.file_text) case "str_replace": - // new_str may be omitted or "" — both mean "delete old_str". - // old_str must be non-empty — replacing the empty string would - // prepend instead of replacing. - if (!command.old_str) { + // new_str may be "" (deleting text) but must be present. + if (!command.old_str || command.new_str === undefined) { return { success: false, - error: "old_str is required for str_replace command", + error: "old_str and new_str are required for str_replace command", } } - return await this.strReplace( - path, - command.old_str, - command.new_str ?? "", - ) + return await this.strReplace(path, command.old_str, command.new_str) case "insert": // insert_text may be "" (inserting a blank line). if ( @@ -798,7 +792,7 @@ export class ClaudeMemoryTool { success: true, document: { documentId: candidate.id, - customId: document.customId ?? candidate.customId, + customId: document.customId ?? candidate.customId ?? undefined, content, metadata, }, diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index b83e725d..981d0bf6 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -54,16 +54,6 @@ export const toConversationImageUrl = ( : `data:${mediaType};base64,${trimmed}` } - if (typeof value === "object" && value !== null && "url" in value) { - const rawUrl = (value as { url: unknown }).url - if ( - typeof rawUrl === "string" || - (typeof URL !== "undefined" && rawUrl instanceof URL) - ) { - return toConversationImageUrl(rawUrl, mediaType) - } - } - const bytes = value instanceof Uint8Array ? value diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index ddd68d8d..7723b8d2 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -426,11 +426,11 @@ describe("openai executeToolCall argument parsing", () => { expect(result.success).toBe(false) expect(result.error).toMatch(/Invalid JSON arguments for searchMemories/) - expect(searchExecute).not.toHaveBeenCalled() + expect(clientSearch).not.toHaveBeenCalled() }) it("still passes well-formed arguments through", async () => { - searchExecute.mockResolvedValue({ results: [{ id: "mem_1" }] }) + clientSearch.mockResolvedValue({ results: [{ id: "mem_1" }] }) const execute = openAi.createToolCallExecutor(API_KEY, { containerTags: ["user_1"], }) @@ -445,7 +445,7 @@ describe("openai executeToolCall argument parsing", () => { ) expect(result.success).toBe(true) - expect(searchExecute).toHaveBeenCalledWith( + expect(clientSearch).toHaveBeenCalledWith( expect.objectContaining({ q: "tea", limit: 3 }), ) }) diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 034f3cce..81aab493 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest" import { makeTurnKey } from "./shared/cache" -import { toConversationImageUrl } from "./conversations-client" import { normalizeBaseUrl } from "./shared/context" import { DEFAULT_VALUES, @@ -222,23 +221,3 @@ describe("normalizeBaseUrl", () => { ) }) }) - -describe("toConversationImageUrl", () => { - it("handles string URLs and trims whitespace", () => { - expect(toConversationImageUrl("https://example.com/image.png")).toBe( - "https://example.com/image.png", - ) - }) - - it("handles object representations containing url", () => { - expect( - toConversationImageUrl({ url: "https://example.com/avatar.jpg" }), - ).toBe("https://example.com/avatar.jpg") - }) - - it("returns null for invalid or empty inputs", () => { - expect(toConversationImageUrl("")).toBeNull() - expect(toConversationImageUrl(null)).toBeNull() - expect(toConversationImageUrl(undefined)).toBeNull() - }) -}) diff --git a/packages/tools/test/claude-memory-commands.test.ts b/packages/tools/test/claude-memory-commands.test.ts index fd72a83c..ef1d92ef 100644 --- a/packages/tools/test/claude-memory-commands.test.ts +++ b/packages/tools/test/claude-memory-commands.test.ts @@ -1,162 +1,91 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -// Unit tests for the command handling in ClaudeMemoryTool, with the -// supermemory client mocked out so they run without an API key. They pin the -// wire format documented at -// https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool -// (rename uses old_path/new_path, insert_line means "insert after this line" -// with 0 = top of file, str_replace without new_str deletes old_str). - -const { addMock, deleteMock, executeMock } = vi.hoisted(() => ({ +const { addMock, listMock, getMock, deleteBulkMock } = vi.hoisted(() => ({ addMock: vi.fn(), - deleteMock: vi.fn(), - executeMock: vi.fn(), + listMock: vi.fn(), + getMock: vi.fn(), + deleteBulkMock: vi.fn(), })) vi.mock("supermemory", () => ({ default: class MockSupermemory { add = addMock - search = { execute: executeMock } - documents = { delete: deleteMock } + memories = { forget: vi.fn() } + documents = { list: listMock, get: getMock, deleteBulk: deleteBulkMock } }, })) import { createClaudeMemoryTool } from "../src/claude-memory" -// Matches ClaudeMemoryTool's normalizePathToCustomId -function customIdFor(path: string): string { - return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_") -} - function stubFile(path: string, content: string) { - executeMock.mockResolvedValue({ - results: [ + const customId = createClaudeMemoryTool("k").normalizePathToCustomId(path) + const metadata = { claude_memory_type: "file", file_path: path } + listMock.mockResolvedValue({ + memories: [ { - documentId: customIdFor(path), - raw: content, - metadata: { file_path: path }, + id: "doc_src", + customId, + containerTags: ["claude_memory"], + metadata, }, ], + pagination: { totalPages: 1 }, + }) + getMock.mockResolvedValue({ + id: "doc_src", + customId, + containerTags: ["sm_project_default", "claude_memory"], + metadata, + content, }) } -describe("ClaudeMemoryTool command handling", () => { +describe("ClaudeMemoryTool rename", () => { let tool: ReturnType beforeEach(() => { vi.clearAllMocks() addMock.mockResolvedValue({ id: "doc_1" }) - deleteMock.mockResolvedValue({}) - executeMock.mockResolvedValue({ results: [] }) + deleteBulkMock.mockResolvedValue({ success: true, deletedCount: 1 }) + listMock.mockResolvedValue({ memories: [], pagination: { totalPages: 1 } }) tool = createClaudeMemoryTool("test-api-key") }) - describe("rename", () => { - it("handles the old_path/new_path shape Claude actually sends", async () => { - stubFile("/memories/draft.txt", "file body") + it("handles the old_path/new_path shape Claude actually sends", async () => { + stubFile("/memories/draft.txt", "file body") - const result = await tool.handleCommand({ - command: "rename", - old_path: "/memories/draft.txt", - new_path: "/memories/final.txt", - }) - - expect(result.success).toBe(true) - expect(addMock).toHaveBeenCalledWith( - expect.objectContaining({ - customId: customIdFor("/memories/final.txt"), - content: "file body", - }), - ) - expect(deleteMock).toHaveBeenCalledWith( - customIdFor("/memories/draft.txt"), - ) + const result = await tool.handleCommand({ + command: "rename", + old_path: "/memories/draft.txt", + new_path: "/memories/final.txt", }) - it("still accepts path as the source for older callers", async () => { - stubFile("/memories/draft.txt", "file body") - - const result = await tool.handleCommand({ - command: "rename", - path: "/memories/draft.txt", - new_path: "/memories/final.txt", - }) - - expect(result.success).toBe(true) - }) - - it("validates old_path like any other path", async () => { - const result = await tool.handleCommand({ - command: "rename", - old_path: "/etc/passwd", - new_path: "/memories/final.txt", - }) - - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid path") - }) + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledWith( + expect.objectContaining({ content: "file body" }), + ) }) - describe("insert", () => { - const path = "/memories/notes.txt" + it("still accepts path as the source for older callers", async () => { + stubFile("/memories/draft.txt", "file body") - async function insertAt(line: number, text: string) { - stubFile(path, "one\ntwo\nthree") - return await tool.handleCommand({ - command: "insert", - path, - insert_line: line, - insert_text: text, - }) - } - - function savedContent(): string { - return addMock.mock.calls[0]?.[0]?.content - } - - it("inserts at the top of the file for insert_line 0", async () => { - const result = await insertAt(0, "zero") - - expect(result.success).toBe(true) - expect(savedContent()).toBe("zero\none\ntwo\nthree") + const result = await tool.handleCommand({ + command: "rename", + path: "/memories/draft.txt", + new_path: "/memories/final.txt", }) - it("inserts after the given line, not before it", async () => { - const result = await insertAt(2, "new") - - expect(result.success).toBe(true) - expect(savedContent()).toBe("one\ntwo\nnew\nthree") - }) - - it("appends when insert_line equals the line count", async () => { - const result = await insertAt(3, "four") - - expect(result.success).toBe(true) - expect(savedContent()).toBe("one\ntwo\nthree\nfour") - }) - - it("rejects insert_line past the end of the file", async () => { - const result = await insertAt(4, "too far") - - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line number") - }) + expect(result.success).toBe(true) }) - describe("str_replace", () => { - it("deletes old_str when new_str is omitted", async () => { - stubFile("/memories/prefs.txt", "keep this remove this") - - const result = await tool.handleCommand({ - command: "str_replace", - path: "/memories/prefs.txt", - old_str: " remove this", - }) - - expect(result.success).toBe(true) - expect(addMock).toHaveBeenCalledWith( - expect.objectContaining({ content: "keep this" }), - ) + it("validates old_path like any other path", async () => { + const result = await tool.handleCommand({ + command: "rename", + old_path: "/etc/passwd", + new_path: "/memories/final.txt", }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") }) }) From 9fd2e989bb612695f4d3f0b05fd01ce8ddad8d7b Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Fri, 2 Oct 2026 18:41:24 -0700 Subject: [PATCH 21/21] chore(tools): bump to 2.4.0 --- packages/tools/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/package.json b/packages/tools/package.json index 49d957d7..bfa91a54 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.3.0", + "version": "2.4.0", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown",