diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index c4f62f16c..d5abbba12 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -16,7 +16,7 @@ vi.mock("supermemory", () => { } }) -import { ClaudeMemoryTool } from "./claude-memory" +import { ClaudeMemoryTool, pathToCustomId } from "./claude-memory" const FILE_PATH = "/memories/notes.txt" // 5 distinct lines so an off-by-one at either end is observable. @@ -181,3 +181,38 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { expect(stored).not.toContain("line3") }) }) + +describe("pathToCustomId", () => { + it("keeps the legacy flattening for canonical single-segment paths", () => { + // Backward compatibility: documents already stored under the legacy id + // (no underscores, one dot, one slash) must keep resolving. + expect(pathToCustomId("/memories/file.txt")).toBe("memories_file_txt") + expect(pathToCustomId("/projects/spec.md")).toBe("projects_spec_md") + // Shapes outside the canonical /dir/file.ext form are digested even + // when they look flattenable. + expect(pathToCustomId("notes.txt")).toBe("notes_txt_e39538e7") + }) + + it("disambiguates paths that would collide under plain flattening", () => { + // "notes.txt", "notes_txt", and "notes/txt" all flattened to the same + // id, so one file could silently overwrite another (#1547). + const a = pathToCustomId("/memories/notes.txt") + const b = pathToCustomId("/memories/notes_txt") + const c = pathToCustomId("/memories/notes/txt") + + expect(new Set([a, b, c]).size).toBe(3) + for (const id of [a, b, c]) { + expect(id.startsWith("memories_notes_txt")).toBe(true) + } + }) + + it("is deterministic for the same input", () => { + expect(pathToCustomId("/memories/my_notes/v1.txt")).toBe( + pathToCustomId("/memories/my_notes/v1.txt"), + ) + }) + + it("differs for distinct underscore paths sharing a flattening", () => { + expect(pathToCustomId("/a_b/c.txt")).not.toBe(pathToCustomId("/a/b_c.txt")) + }) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 8c665701a..989c9b58a 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import Supermemory from "supermemory" import { getContainerTags } from "./tools-shared" import type { SupermemoryToolsConfig } from "./types" @@ -37,6 +38,33 @@ export interface MemoryToolResult { is_error: boolean } +/** + * Normalize a memory file path to the customId used for document identity. + * + * Paths in the canonical single-segment shape (`/memories/file.txt`: no + * underscores, exactly one dot, exactly one slash) keep the exact legacy id, + * so documents already stored under it continue to resolve. + * + * Plain flattening is otherwise non-injective — `notes.txt`, `notes_txt`, + * and `notes/txt` all collapse to the same id, so creating one file could + * silently overwrite another (#1547). Every other shape therefore gets a + * short digest of the original path appended, making distinct paths map to + * distinct ids again. + */ +export function pathToCustomId(path: string): string { + const stripped = path.replace(/^\//, "") + const legacyCompatible = + !stripped.includes("_") && + stripped.split(".").length === 2 && + stripped.split("/").length === 2 + if (legacyCompatible) { + return stripped.replace(/\//g, "_").replace(/\./g, "_") + } + const flattened = stripped.replace(/\//g, "_").replace(/\./g, "_") + const digest = createHash("sha256").update(stripped).digest("hex").slice(0, 8) + return `${flattened}_${digest}` +} + /** * Claude Memory Tool - Client-side implementation * Maps Claude's memory tool commands to supermemory document operations @@ -51,10 +79,7 @@ export class ClaudeMemoryTool { * Converts /memories/file.txt -> memories_file_txt */ private normalizePathToCustomId(path: string): string { - return path - .replace(/^\//, "") // Remove leading slash - .replace(/\//g, "_") // Replace / with _ - .replace(/\./g, "_") // Replace . with _ + return pathToCustomId(path) } constructor(apiKey: string, config?: ClaudeMemoryConfig) {