fix(tools): make claude memory customId injective to stop silent overwrites

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.
This commit is contained in:
Sravanjangam 2026-08-23 03:43:39 +05:30
parent 3487666481
commit 049a50ee0a
2 changed files with 65 additions and 5 deletions

View file

@ -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"))
})
})

View file

@ -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) {