mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(tools): make claude-memory file operations act on the exact file, literally (#1285)
Some checks failed
Publish Tools / publish (push) Has been cancelled
Some checks failed
Publish Tools / publish (push) Has been cancelled
This commit is contained in:
parent
d8796277b0
commit
86854efee6
3 changed files with 118 additions and 25 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
// can be exercised deterministically without any network access. We only need
|
||||
// `search.execute` to return a single document with known multi-line content.
|
||||
const searchExecute = vi.fn()
|
||||
const addMock = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = { execute: searchExecute }
|
||||
add = vi.fn()
|
||||
add = addMock
|
||||
memories = { forget: vi.fn() }
|
||||
},
|
||||
}
|
||||
|
|
@ -83,3 +84,100 @@ describe("ClaudeMemoryTool view_range", () => {
|
|||
expect(result.content).not.toContain("line5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ClaudeMemoryTool exact-file matching", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
addMock.mockReset()
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it("view finds the exact file even when a neighbour ranks first", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
{ documentId: "memories_notes_txt", content: FILE_CONTENT },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
path: FILE_PATH,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toContain("line1")
|
||||
expect(result.content).not.toContain("backup stuff")
|
||||
})
|
||||
|
||||
it("view reports not-found instead of returning a different file", async () => {
|
||||
// Semantic search can surface a similarly-named file; that must not
|
||||
// be served as the requested one.
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
path: FILE_PATH,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("File not found")
|
||||
})
|
||||
|
||||
it("str_replace refuses to modify a different file than requested", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "backup",
|
||||
new_str: "primary",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(addMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
||||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
addMock.mockReset()
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }],
|
||||
})
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
||||
it.each([
|
||||
"$&",
|
||||
"$'",
|
||||
"$`",
|
||||
"$$",
|
||||
])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => {
|
||||
const result = await tool.handleCommand({
|
||||
command: "str_replace",
|
||||
path: FILE_PATH,
|
||||
old_str: "line3",
|
||||
new_str: `price is ${dollarSequence} today`,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(addMock).toHaveBeenCalledTimes(1)
|
||||
const stored = addMock.mock.calls[0]?.[0]?.content as string
|
||||
expect(stored).toContain(`price is ${dollarSequence} today`)
|
||||
expect(stored).not.toContain("line3")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -262,29 +262,21 @@ export class ClaudeMemoryTool {
|
|||
viewRange?: [number, number],
|
||||
): Promise<MemoryResponse> {
|
||||
try {
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
|
||||
const response = await this.client.search.execute({
|
||||
q: normalizedId,
|
||||
containerTags: this.containerTags,
|
||||
limit: 1,
|
||||
includeFullDocs: true,
|
||||
})
|
||||
|
||||
// Try to find exact match by customId
|
||||
const exactMatch = response.results?.find(
|
||||
(r) => r.documentId === normalizedId,
|
||||
)
|
||||
const document = exactMatch || response.results?.[0]
|
||||
|
||||
if (!document) {
|
||||
// Same lookup as every mutating command: limit 5 so the exact
|
||||
// customId match is findable among semantic near-neighbours.
|
||||
// With the old limit of 1, a similarly-named file ranking first
|
||||
// made this return the wrong file's contents as a success.
|
||||
const readResult = await this.getFileDocument(filePath)
|
||||
if (!readResult.success || !readResult.document) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
error: readResult.error || `File not found: ${filePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
let content = document.content || ""
|
||||
const document = readResult.document
|
||||
|
||||
let content: string = document.raw || document.content || ""
|
||||
|
||||
// Apply line range if specified
|
||||
if (viewRange) {
|
||||
|
|
@ -393,8 +385,10 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
// Replace the string
|
||||
const newContent = originalContent.replace(oldStr, newStr)
|
||||
// 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.
|
||||
const newContent = originalContent.replace(oldStr, () => newStr)
|
||||
|
||||
// Update the document
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
|
|
@ -590,11 +584,12 @@ export class ClaudeMemoryTool {
|
|||
includeFullDocs: true,
|
||||
})
|
||||
|
||||
// Try to find exact match by customId first
|
||||
const exactMatch = response.results?.find(
|
||||
// Only accept the exact customId match. Falling back to the top
|
||||
// semantic hit would let callers read — and worse, modify or
|
||||
// delete — a different file than the one they asked for.
|
||||
const document = response.results?.find(
|
||||
(r) => r.documentId === normalizedId,
|
||||
)
|
||||
const document = exactMatch || response.results?.[0]
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue