From 049a50ee0ac60d78a4ccde4e85901d0e1020f874 Mon Sep 17 00:00:00 2001 From: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:43:39 +0530 Subject: [PATCH] fix(tools): make claude memory customId injective to stop silent overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePathToCustomId flattened / and . to _ non-injectively, so /memories/notes.txt, /memories/notes_txt and /memories/notes/txt all mapped to memories_notes_txt — create/delete/view on one path silently hit another file's document (#1547). pathToCustomId now keeps the exact legacy id only for the canonical /dir/file.ext shape (no underscore, one dot, one slash) so existing documents keep resolving, and appends an 8-hex sha256 digest of the original path for every other shape, making distinct paths map to distinct ids. Round-trip collision tests included. --- packages/tools/src/claude-memory.test.ts | 37 +++++++++++++++++++++++- packages/tools/src/claude-memory.ts | 33 ++++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) 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) {