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
2 changes: 1 addition & 1 deletion apps/docs/integrations/claude-memory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Paths are normalized for storage: `/memories/preferences` is stored as `--memori
```typescript
{
command: "rename",
path: "/memories/old-name.txt",
old_path: "/memories/old-name.txt",
new_path: "/memories/new-name.txt"
}
```
Expand Down
50 changes: 30 additions & 20 deletions packages/tools/src/claude-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export interface ClaudeMemoryConfig extends SupermemoryToolsConfig {

export interface MemoryCommand {
command: "view" | "create" | "str_replace" | "insert" | "delete" | "rename"
path: string
// every command except rename addresses the file via path
path?: string
// view specific
view_range?: [number, number]
// create specific
Expand All @@ -20,7 +21,9 @@ export interface MemoryCommand {
// insert specific
insert_line?: number
insert_text?: string
// rename specific
// rename specific: Claude sends old_path/new_path (path is accepted too
// for backwards compatibility with earlier callers)
old_path?: string
new_path?: string
}

Expand Down Expand Up @@ -76,39 +79,46 @@ export class ClaudeMemoryTool {
*/
async handleCommand(command: MemoryCommand): Promise<MemoryResponse> {
try {
// rename is the one command that doesn't use `path`: Claude sends
// old_path/new_path. Fall back to `path` so older callers keep working.
const path =
command.command === "rename"
? (command.old_path ?? command.path)
: command.path

// Validate path security
if (!this.isValidPath(command.path)) {
if (path === undefined || !this.isValidPath(path)) {
return {
success: false,
error: `Invalid path: ${command.path}. All paths must start with /memories/`,
error: `Invalid path: ${path}. All paths must start with /memories/`,
}
}

switch (command.command) {
case "view":
return await this.view(command.path, command.view_range)
return await this.view(path, command.view_range)
case "create":
if (!command.file_text) {
return {
success: false,
error: "file_text is required for create command",
}
}
return await this.create(command.path, command.file_text)
return await this.create(path, command.file_text)
case "str_replace":
// new_str may legitimately be "" (deleting text), so only reject
// when it is missing entirely. old_str must be non-empty — replacing
// the empty string would prepend instead of replacing.
if (!command.old_str || command.new_str === undefined) {
// new_str may be omitted or "" — both mean "delete old_str".
// old_str must be non-empty — replacing the empty string would
// prepend instead of replacing.
if (!command.old_str) {
return {
success: false,
error: "old_str and new_str are required for str_replace command",
error: "old_str is required for str_replace command",
}
}
return await this.strReplace(
command.path,
path,
command.old_str,
command.new_str,
command.new_str ?? "",
)
case "insert":
// insert_text may be "" (inserting a blank line).
Expand All @@ -123,20 +133,20 @@ export class ClaudeMemoryTool {
}
}
return await this.insert(
command.path,
path,
command.insert_line,
command.insert_text,
)
case "delete":
return await this.delete(command.path)
return await this.delete(path)
case "rename":
if (!command.new_path) {
return {
success: false,
error: "new_path is required for rename command",
}
}
return await this.rename(command.path, command.new_path)
return await this.rename(path, command.new_path)
default:
return {
success: false,
Expand Down Expand Up @@ -437,16 +447,16 @@ export class ClaudeMemoryTool {
readResult.document.raw || readResult.document.content || ""
const lines = originalContent.split("\n")

// Validate line number
if (insertLine < 1 || insertLine > lines.length + 1) {
// insert_line is the line the text goes after: 0 means the beginning
// of the file and lines.length appends at the end.
if (insertLine < 0 || insertLine > lines.length) {
return {
success: false,
error: `Invalid line number: ${insertLine}. File has ${lines.length} lines.`,
}
}

// Insert the text (insertLine is 1-based)
lines.splice(insertLine - 1, 0, insertText)
lines.splice(insertLine, 0, insertText)
const newContent = lines.join("\n")

// Update the document
Expand Down
162 changes: 162 additions & 0 deletions packages/tools/test/claude-memory-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

// Unit tests for the command handling in ClaudeMemoryTool, with the
// supermemory client mocked out so they run without an API key. They pin the
// wire format documented at
// https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool
// (rename uses old_path/new_path, insert_line means "insert after this line"
// with 0 = top of file, str_replace without new_str deletes old_str).

const { addMock, deleteMock, executeMock } = vi.hoisted(() => ({
addMock: vi.fn(),
deleteMock: vi.fn(),
executeMock: vi.fn(),
}))

vi.mock("supermemory", () => ({
default: class MockSupermemory {
add = addMock
search = { execute: executeMock }
documents = { delete: deleteMock }
},
}))

import { createClaudeMemoryTool } from "../src/claude-memory"

// Matches ClaudeMemoryTool's normalizePathToCustomId
function customIdFor(path: string): string {
return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_")
}

function stubFile(path: string, content: string) {
executeMock.mockResolvedValue({
results: [
{
documentId: customIdFor(path),
raw: content,
metadata: { file_path: path },
},
],
})
}

describe("ClaudeMemoryTool command handling", () => {
let tool: ReturnType<typeof createClaudeMemoryTool>

beforeEach(() => {
vi.clearAllMocks()
addMock.mockResolvedValue({ id: "doc_1" })
deleteMock.mockResolvedValue({})
executeMock.mockResolvedValue({ results: [] })
tool = createClaudeMemoryTool("test-api-key")
})

describe("rename", () => {
it("handles the old_path/new_path shape Claude actually sends", async () => {
stubFile("/memories/draft.txt", "file body")

const result = await tool.handleCommand({
command: "rename",
old_path: "/memories/draft.txt",
new_path: "/memories/final.txt",
})

expect(result.success).toBe(true)
expect(addMock).toHaveBeenCalledWith(
expect.objectContaining({
customId: customIdFor("/memories/final.txt"),
content: "file body",
}),
)
expect(deleteMock).toHaveBeenCalledWith(
customIdFor("/memories/draft.txt"),
)
})

it("still accepts path as the source for older callers", async () => {
stubFile("/memories/draft.txt", "file body")

const result = await tool.handleCommand({
command: "rename",
path: "/memories/draft.txt",
new_path: "/memories/final.txt",
})

expect(result.success).toBe(true)
})

it("validates old_path like any other path", async () => {
const result = await tool.handleCommand({
command: "rename",
old_path: "/etc/passwd",
new_path: "/memories/final.txt",
})

expect(result.success).toBe(false)
expect(result.error).toContain("Invalid path")
})
})

describe("insert", () => {
const path = "/memories/notes.txt"

async function insertAt(line: number, text: string) {
stubFile(path, "one\ntwo\nthree")
return await tool.handleCommand({
command: "insert",
path,
insert_line: line,
insert_text: text,
})
}

function savedContent(): string {
return addMock.mock.calls[0]?.[0]?.content
}

it("inserts at the top of the file for insert_line 0", async () => {
const result = await insertAt(0, "zero")

expect(result.success).toBe(true)
expect(savedContent()).toBe("zero\none\ntwo\nthree")
})

it("inserts after the given line, not before it", async () => {
const result = await insertAt(2, "new")

expect(result.success).toBe(true)
expect(savedContent()).toBe("one\ntwo\nnew\nthree")
})

it("appends when insert_line equals the line count", async () => {
const result = await insertAt(3, "four")

expect(result.success).toBe(true)
expect(savedContent()).toBe("one\ntwo\nthree\nfour")
})

it("rejects insert_line past the end of the file", async () => {
const result = await insertAt(4, "too far")

expect(result.success).toBe(false)
expect(result.error).toContain("Invalid line number")
})
})

describe("str_replace", () => {
it("deletes old_str when new_str is omitted", async () => {
stubFile("/memories/prefs.txt", "keep this remove this")

const result = await tool.handleCommand({
command: "str_replace",
path: "/memories/prefs.txt",
old_str: " remove this",
})

expect(result.success).toBe(true)
expect(addMock).toHaveBeenCalledWith(
expect.objectContaining({ content: "keep this" }),
)
})
})
})