From 8342ffa18e1cbbdc86e87845a9509707f6c73a8a Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:14:21 +0530 Subject: [PATCH] fix(tools): make Claude memory tool exact-file lookup and listing deterministic --- packages/tools/src/claude-memory.test.ts | 62 +++++++++++++++--------- packages/tools/src/claude-memory.ts | 32 ++++++------ 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index c4f62f16..bc4c9978 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -2,14 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path // can be exercised deterministically without any network access. We only need -// `search.execute` to return a single document with known multi-line content. -const searchExecute = vi.fn() +// `documents.list` and `documents.get` to return a single document with known multi-line content. +const listMock = vi.fn() +const getMock = vi.fn() const addMock = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = { execute: searchExecute } + documents = { list: listMock, get: getMock } add = addMock memories = { forget: vi.fn() } }, @@ -23,10 +24,13 @@ const FILE_PATH = "/memories/notes.txt" const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5" function mockDocument(content: string) { - // `readFile` matches by `documentId === normalizePathToCustomId(path)`. - // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt" - searchExecute.mockResolvedValue({ - results: [{ documentId: "memories_notes_txt", content }], + listMock.mockResolvedValue({ + memories: [{ id: "doc_12345", customId: "memories_notes_txt" }], + }) + getMock.mockResolvedValue({ + id: "doc_12345", + customId: "memories_notes_txt", + content, }) } @@ -34,7 +38,8 @@ describe("ClaudeMemoryTool view_range", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + listMock.mockReset() + getMock.mockReset() mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) @@ -89,18 +94,25 @@ describe("ClaudeMemoryTool exact-file matching", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + listMock.mockReset() + getMock.mockReset() addMock.mockReset() tool = new ClaudeMemoryTool("test-api-key") }) it("view finds the exact file even when a neighbour ranks first", async () => { - searchExecute.mockResolvedValue({ - results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, - { documentId: "memories_notes_txt", content: FILE_CONTENT }, + listMock.mockResolvedValue({ + memories: [ + { id: "doc_backup", customId: "memories_notes_backup_txt" }, + { id: "doc_primary", customId: "memories_notes_txt" }, ], }) + getMock.mockImplementation(async (id: string) => { + if (id === "doc_primary") { + return { id: "doc_primary", customId: "memories_notes_txt", content: FILE_CONTENT } + } + return { id: "doc_backup", customId: "memories_notes_backup_txt", content: "backup stuff" } + }) const result = await tool.handleCommand({ command: "view", @@ -115,9 +127,9 @@ describe("ClaudeMemoryTool exact-file matching", () => { it("view reports not-found instead of returning a different file", async () => { // Semantic search can surface a similarly-named file; that must not // be served as the requested one. - searchExecute.mockResolvedValue({ - results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + listMock.mockResolvedValue({ + memories: [ + { id: "doc_backup", customId: "memories_notes_backup_txt" }, ], }) @@ -131,9 +143,9 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("str_replace refuses to modify a different file than requested", async () => { - searchExecute.mockResolvedValue({ - results: [ - { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + listMock.mockResolvedValue({ + memories: [ + { id: "doc_backup", customId: "memories_notes_backup_txt" }, ], }) @@ -153,10 +165,16 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchExecute.mockReset() + listMock.mockReset() + getMock.mockReset() addMock.mockReset() - searchExecute.mockResolvedValue({ - results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }], + listMock.mockResolvedValue({ + memories: [{ id: "doc_12345", customId: "memories_notes_txt" }], + }) + getMock.mockResolvedValue({ + id: "doc_12345", + customId: "memories_notes_txt", + content: FILE_CONTENT, }) tool = new ClaudeMemoryTool("test-api-key") }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 8c665701..c71b95db 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -194,14 +194,13 @@ export class ClaudeMemoryTool { private async listDirectory(dirPath: string): Promise { try { // Search for all memory files - const response = await this.client.search.execute({ - q: "*", // Search for all + const response = await this.client.documents.list({ containerTags: this.containerTags, limit: 100, // Get many files (max allowed) - includeFullDocs: false, + includeContent: false, }) - if (!response.results) { + if (!response.memories) { return { success: true, content: `Directory: ${dirPath}\n(empty)`, @@ -212,7 +211,7 @@ export class ClaudeMemoryTool { const files: string[] = [] const dirs = new Set() - for (const result of response.results) { + for (const result of response.memories) { // Get the file path from metadata (since customId is normalized) const filePath = result.metadata?.file_path as string if (!filePath || !filePath.startsWith(dirPath)) continue @@ -577,30 +576,31 @@ export class ClaudeMemoryTool { try { const normalizedId = this.normalizePathToCustomId(filePath) - const response = await this.client.search.execute({ - q: normalizedId, + const response = await this.client.documents.list({ containerTags: this.containerTags, - limit: 5, - includeFullDocs: true, + limit: 100, + includeContent: false, }) - // Only accept the exact customId match. Falling back to the top - // semantic hit would let callers read — and worse, modify or - // delete — a different file than the one they asked for. - const document = response.results?.find( - (r) => r.documentId === normalizedId, + const matchedDoc = response.memories?.find( + (d) => d.customId === normalizedId, ) - if (!document) { + if (!matchedDoc) { return { success: false, error: `File not found: ${filePath}`, } } + const document = await this.client.documents.get(matchedDoc.id) + return { success: true, - document, + document: { + ...document, + documentId: document.id, + }, } } catch (error) { return {