mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(tools): make Claude memory tool exact-file lookup and listing deterministic
This commit is contained in:
parent
f11d8c4620
commit
8342ffa18e
2 changed files with 56 additions and 38 deletions
|
|
@ -2,14 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
|
||||
// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path
|
||||
// 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()
|
||||
// `documents.list` and `documents.get` to return a single document with known multi-line content.
|
||||
const listMock = vi.fn()
|
||||
const getMock = vi.fn()
|
||||
const addMock = vi.fn()
|
||||
|
||||
vi.mock("supermemory", () => {
|
||||
return {
|
||||
default: class MockSupermemory {
|
||||
search = { execute: searchExecute }
|
||||
documents = { list: listMock, get: getMock }
|
||||
add = addMock
|
||||
memories = { forget: vi.fn() }
|
||||
},
|
||||
|
|
@ -23,10 +24,13 @@ const FILE_PATH = "/memories/notes.txt"
|
|||
const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5"
|
||||
|
||||
function mockDocument(content: string) {
|
||||
// `readFile` matches by `documentId === normalizePathToCustomId(path)`.
|
||||
// normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content }],
|
||||
listMock.mockResolvedValue({
|
||||
memories: [{ id: "doc_12345", customId: "memories_notes_txt" }],
|
||||
})
|
||||
getMock.mockResolvedValue({
|
||||
id: "doc_12345",
|
||||
customId: "memories_notes_txt",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +38,8 @@ describe("ClaudeMemoryTool view_range", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
listMock.mockReset()
|
||||
getMock.mockReset()
|
||||
mockDocument(FILE_CONTENT)
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
|
@ -89,18 +94,25 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
listMock.mockReset()
|
||||
getMock.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 },
|
||||
listMock.mockResolvedValue({
|
||||
memories: [
|
||||
{ id: "doc_backup", customId: "memories_notes_backup_txt" },
|
||||
{ id: "doc_primary", customId: "memories_notes_txt" },
|
||||
],
|
||||
})
|
||||
getMock.mockImplementation(async (id: string) => {
|
||||
if (id === "doc_primary") {
|
||||
return { id: "doc_primary", customId: "memories_notes_txt", content: FILE_CONTENT }
|
||||
}
|
||||
return { id: "doc_backup", customId: "memories_notes_backup_txt", content: "backup stuff" }
|
||||
})
|
||||
|
||||
const result = await tool.handleCommand({
|
||||
command: "view",
|
||||
|
|
@ -115,9 +127,9 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
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" },
|
||||
listMock.mockResolvedValue({
|
||||
memories: [
|
||||
{ id: "doc_backup", customId: "memories_notes_backup_txt" },
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -131,9 +143,9 @@ describe("ClaudeMemoryTool exact-file matching", () => {
|
|||
})
|
||||
|
||||
it("str_replace refuses to modify a different file than requested", async () => {
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [
|
||||
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
|
||||
listMock.mockResolvedValue({
|
||||
memories: [
|
||||
{ id: "doc_backup", customId: "memories_notes_backup_txt" },
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -153,10 +165,16 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
|
|||
let tool: ClaudeMemoryTool
|
||||
|
||||
beforeEach(() => {
|
||||
searchExecute.mockReset()
|
||||
listMock.mockReset()
|
||||
getMock.mockReset()
|
||||
addMock.mockReset()
|
||||
searchExecute.mockResolvedValue({
|
||||
results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }],
|
||||
listMock.mockResolvedValue({
|
||||
memories: [{ id: "doc_12345", customId: "memories_notes_txt" }],
|
||||
})
|
||||
getMock.mockResolvedValue({
|
||||
id: "doc_12345",
|
||||
customId: "memories_notes_txt",
|
||||
content: FILE_CONTENT,
|
||||
})
|
||||
tool = new ClaudeMemoryTool("test-api-key")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -194,14 +194,13 @@ export class ClaudeMemoryTool {
|
|||
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
|
||||
try {
|
||||
// Search for all memory files
|
||||
const response = await this.client.search.execute({
|
||||
q: "*", // Search for all
|
||||
const response = await this.client.documents.list({
|
||||
containerTags: this.containerTags,
|
||||
limit: 100, // Get many files (max allowed)
|
||||
includeFullDocs: false,
|
||||
includeContent: false,
|
||||
})
|
||||
|
||||
if (!response.results) {
|
||||
if (!response.memories) {
|
||||
return {
|
||||
success: true,
|
||||
content: `Directory: ${dirPath}\n(empty)`,
|
||||
|
|
@ -212,7 +211,7 @@ export class ClaudeMemoryTool {
|
|||
const files: string[] = []
|
||||
const dirs = new Set<string>()
|
||||
|
||||
for (const result of response.results) {
|
||||
for (const result of response.memories) {
|
||||
// Get the file path from metadata (since customId is normalized)
|
||||
const filePath = result.metadata?.file_path as string
|
||||
if (!filePath || !filePath.startsWith(dirPath)) continue
|
||||
|
|
@ -577,30 +576,31 @@ export class ClaudeMemoryTool {
|
|||
try {
|
||||
const normalizedId = this.normalizePathToCustomId(filePath)
|
||||
|
||||
const response = await this.client.search.execute({
|
||||
q: normalizedId,
|
||||
const response = await this.client.documents.list({
|
||||
containerTags: this.containerTags,
|
||||
limit: 5,
|
||||
includeFullDocs: true,
|
||||
limit: 100,
|
||||
includeContent: false,
|
||||
})
|
||||
|
||||
// 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 matchedDoc = response.memories?.find(
|
||||
(d) => d.customId === normalizedId,
|
||||
)
|
||||
|
||||
if (!document) {
|
||||
if (!matchedDoc) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found: ${filePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
const document = await this.client.documents.get(matchedDoc.id)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
document,
|
||||
document: {
|
||||
...document,
|
||||
documentId: document.id,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue