mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
3487666481
commit
049a50ee0a
2 changed files with 65 additions and 5 deletions
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue