diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7337441a..f51247ce 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -210,3 +210,42 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { expect(stored).toContain(`price is ${dollarSequence} today`) }) }) + +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 867c4d82..a419fee9 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) { @@ -650,8 +659,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 8efef91d..0439c5ca 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] }) })