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
62 changes: 40 additions & 22 deletions packages/tools/src/claude-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
},
Expand All @@ -23,18 +24,22 @@ 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,
})
}

describe("ClaudeMemoryTool view_range", () => {
let tool: ClaudeMemoryTool

beforeEach(() => {
searchExecute.mockReset()
listMock.mockReset()
getMock.mockReset()
mockDocument(FILE_CONTENT)
tool = new ClaudeMemoryTool("test-api-key")
})
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
],
})

Expand All @@ -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" },
],
})

Expand All @@ -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")
})
Expand Down
32 changes: 16 additions & 16 deletions packages/tools/src/claude-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down