diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 477b0481..a5a11e1c 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -292,6 +292,57 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { }) }) +describe("ClaudeMemoryTool str_replace uniqueness", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + documentsGetMock.mockReset() + updateMock.mockReset().mockResolvedValue({ id: "doc", status: "queued" }) + mockDocument("status: todo\nnotes\nstatus: todo") + tool = new ClaudeMemoryTool("test-api-key") + }) + + it("refuses to replace an old_str that occurs more than once", async () => { + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "status: todo", + new_str: "status: done", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Multiple occurrences") + expect(result.error).toContain("lines: 1, 3") + expect(updateMock).not.toHaveBeenCalled() + }) + + it("counts overlapping occurrences", async () => { + mockDocument("aaa") + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "aa", + new_str: "b", + }) + + expect(result.success).toBe(false) + expect(updateMock).not.toHaveBeenCalled() + }) + + it("still replaces a unique old_str", async () => { + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "notes", + new_str: "updated notes", + }) + + expect(result.success).toBe(true) + const stored = updateMock.mock.calls[0]?.[2]?.content as string + expect(stored).toBe("status: todo\nupdated notes\nstatus: todo") + }) +}) + describe("ClaudeMemoryTool path traversal", () => { let tool: ClaudeMemoryTool diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 6ea73f6e..2f1255c7 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -389,6 +389,23 @@ export class ClaudeMemoryTool { } } + // old_str must be unique, like Anthropic's memory tool: replacing + // only the first of several matches would silently edit the wrong spot. + const matchingLines: number[] = [] + for ( + let index = originalContent.indexOf(oldStr); + index !== -1; + index = originalContent.indexOf(oldStr, index + 1) + ) { + matchingLines.push(originalContent.slice(0, index).split("\n").length) + } + if (matchingLines.length > 1) { + return { + success: false, + error: `No replacement was performed. Multiple occurrences of old_str "${oldStr}" in lines: ${matchingLines.join(", ")}. Please ensure it is unique`, + } + } + // Replace the string. The function replacer keeps `$` sequences // in the replacement literal — a bare string here would expand // patterns like $&, $', and $` and silently corrupt the file.