This commit is contained in:
RIKIN SHAH , CSM 2026-08-26 09:02:34 -07:00 committed by GitHub
commit 52a832bb38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 193 additions and 21 deletions

View file

@ -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"
}
```

View file

@ -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
@ -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
}
@ -76,17 +79,24 @@ 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 {
@ -94,21 +104,21 @@ export class ClaudeMemoryTool {
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).
@ -123,12 +133,12 @@ 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 {
@ -136,7 +146,7 @@ export class ClaudeMemoryTool {
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,
@ -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

View file

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