Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion packages/tools/src/claude-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"))
})
})
33 changes: 29 additions & 4 deletions packages/tools/src/claude-memory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import Supermemory from "supermemory"
import { getContainerTags } from "./tools-shared"
import type { SupermemoryToolsConfig } from "./types"
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down