diff --git a/src/agent/approval-execution.ts b/src/agent/approval-execution.ts index cf2dd46..c53d4a7 100644 --- a/src/agent/approval-execution.ts +++ b/src/agent/approval-execution.ts @@ -114,6 +114,8 @@ const GLOBAL_LOCK_TOOLS = new Set([ "KillBash", "run_shell_command", "RunShellCommand", + // Memory tool (file + git side effects) + "memory", "shell_command", "shell", "ShellCommand", diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index f13377a..d2a288a 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -727,6 +727,8 @@ function getDefaultDecision( "SearchFileContent", "WriteTodos", "ReadManyFiles", + // client-side memory tool is mutating + git side effects + // and should require approval by default ]; if (autoAllowTools.includes(toolName)) { diff --git a/src/tests/tools/memory-tool.test.ts b/src/tests/tools/memory-tool.test.ts new file mode 100644 index 0000000..2d3e9d0 --- /dev/null +++ b/src/tests/tools/memory-tool.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { execFile as execFileCb } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); + +const TEST_AGENT_ID = "agent-test-memory-tool"; +const TEST_AGENT_NAME = "Bob"; + +mock.module("../../agent/context", () => ({ + getCurrentAgentId: () => TEST_AGENT_ID, +})); + +mock.module("../../agent/client", () => ({ + getClient: mock(() => + Promise.resolve({ + agents: { + retrieve: mock(() => Promise.resolve({ name: TEST_AGENT_NAME })), + }, + }), + ), +})); + +const { memory } = await import("../../tools/impl/Memory"); + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFile("git", args, { cwd }); + return String(stdout ?? "").trim(); +} + +describe("memory tool", () => { + let tempRoot: string; + let memoryDir: string; + let remoteDir: string; + + const originalMemoryDir = process.env.MEMORY_DIR; + const originalAgentId = process.env.AGENT_ID; + const originalAgentName = process.env.AGENT_NAME; + + beforeEach(async () => { + tempRoot = mkdtempSync(join(tmpdir(), "letta-memory-tool-")); + memoryDir = join(tempRoot, "memory"); + remoteDir = join(tempRoot, "remote.git"); + + // Bare remote + await execFile("git", ["init", "--bare", remoteDir]); + + // Local memory repo + await execFile("git", ["init", "-b", "main", memoryDir]); + await runGit(memoryDir, ["config", "user.name", "setup"]); + await runGit(memoryDir, ["config", "user.email", "setup@example.com"]); + await runGit(memoryDir, ["remote", "add", "origin", remoteDir]); + + writeFileSync(join(memoryDir, ".gitkeep"), "", "utf8"); + await runGit(memoryDir, ["add", ".gitkeep"]); + await runGit(memoryDir, ["commit", "-m", "initial"]); + await runGit(memoryDir, ["push", "-u", "origin", "main"]); + + process.env.MEMORY_DIR = memoryDir; + process.env.AGENT_ID = TEST_AGENT_ID; + process.env.AGENT_NAME = TEST_AGENT_NAME; + }); + + afterEach(async () => { + if (originalMemoryDir === undefined) delete process.env.MEMORY_DIR; + else process.env.MEMORY_DIR = originalMemoryDir; + + if (originalAgentId === undefined) delete process.env.AGENT_ID; + else process.env.AGENT_ID = originalAgentId; + + if (originalAgentName === undefined) delete process.env.AGENT_NAME; + else process.env.AGENT_NAME = originalAgentName; + + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + test("requires reason", async () => { + await expect( + memory({ + command: "create", + path: "system/test.md", + description: "test desc", + } as Parameters[0]), + ).rejects.toThrow(/missing required parameter/i); + }); + + test("uses reason as commit message and agent identity as commit author", async () => { + const reason = "Create coding preferences block"; + + await memory({ + command: "create", + reason, + path: "system/human/prefs/coding.md", + description: "The user's coding preferences.", + file_text: "The user likes explicit types.", + }); + + const logOutput = await runGit(memoryDir, [ + "log", + "-1", + "--pretty=format:%s%n%an%n%ae", + ]); + const [subject, authorName, authorEmail] = logOutput.split("\n"); + + expect(subject).toBe(reason); + expect(authorName).toBe(TEST_AGENT_NAME); + expect(authorEmail).toBe(`${TEST_AGENT_ID}@letta.com`); + + const remoteSubject = await execFile( + "git", + ["--git-dir", remoteDir, "log", "-1", "--pretty=format:%s", "main"], + {}, + ).then((r) => String(r.stdout ?? "").trim()); + expect(remoteSubject).toBe(reason); + }); + + test("returns error when push fails but keeps local commit", async () => { + await memory({ + command: "create", + reason: "Seed notes", + path: "reference/history/notes.md", + description: "Notes block", + file_text: "old value", + }); + + await runGit(memoryDir, [ + "remote", + "set-url", + "origin", + join(tempRoot, "missing-remote.git"), + ]); + + const reason = "Update notes after remote failure"; + + await expect( + memory({ + command: "str_replace", + reason, + path: "reference/history/notes.md", + old_string: "old value", + new_string: "new value", + }), + ).rejects.toThrow(/committed .* but push failed/i); + + const subject = await runGit(memoryDir, [ + "log", + "-1", + "--pretty=format:%s", + ]); + expect(subject).toBe(reason); + }); + + test("falls back to context agent id when AGENT_ID env is missing", async () => { + delete process.env.AGENT_ID; + delete process.env.LETTA_AGENT_ID; + + const reason = "Create identity via context fallback"; + await memory({ + command: "create", + reason, + path: "system/human/identity.md", + description: "Identity block", + file_text: "Name: Bob", + }); + + const authorEmail = await runGit(memoryDir, [ + "log", + "-1", + "--pretty=format:%ae", + ]); + expect(authorEmail).toBe(`${TEST_AGENT_ID}@letta.com`); + }); + + test("accepts relative file paths like system/contacts.md", async () => { + const reason = "Create contacts via relative path"; + + await memory({ + command: "create", + reason, + path: "system/contacts.md", + description: "Contacts memory", + file_text: "Sarah: +1-555-0100", + }); + + const content = await runGit(memoryDir, [ + "show", + "HEAD:system/contacts.md", + ]); + expect(content).toContain("description: Contacts memory"); + expect(content).toContain("Sarah: +1-555-0100"); + }); + + test("rejects /memories-style paths", async () => { + await expect( + memory({ + command: "create", + reason: "should fail", + path: "/memories/contacts", + description: "Contacts memory", + }), + ).rejects.toThrow(/relative path like system\/contacts\.md/i); + }); +}); diff --git a/src/tools/descriptions/Memory.md b/src/tools/descriptions/Memory.md new file mode 100644 index 0000000..a6bc3c1 --- /dev/null +++ b/src/tools/descriptions/Memory.md @@ -0,0 +1,39 @@ +# Memory +A convinience tool for memories stored in the memory directory (`$MEMORY_DIR`) that automatically commits and pushes changes. + +Files stored inside of `system/` eventually become part of the agent's system prompt, so are always in the context window and do not need to be re-read. Other files only have metadata in the system prompt, so may need to be explicitly read to be updated. + +Supported operations on memory files: +- `str_replace` +- `insert` +- `delete` +- `rename` (path rename or description update mode) +- `create` +More general operations can be performanced through directory modifying the files. + +Path formats accepted: +- relative memory file paths (e.g. `system/contacts.md`, `reference/project/team.md`) + +Note: absolute paths and `/memories/...` paths are not supported by this client-side tool. + +Examples: + +```python +# Replace text in a memory file +memory(command="str_replace", reason="Update theme preference", path="system/human/preferences.md", old_string="theme: dark", new_string="theme: light") + +# Insert text at line 5 +memory(command="insert", reason="Add note about meeting", path="reference/history/meeting-notes.md", insert_line=5, insert_text="New note here") + +# Delete a memory file +memory(command="delete", reason="Remove stale notes", path="reference/history/old_notes.md") + +# Rename a memory file +memory(command="rename", reason="Promote temp notes", old_path="reference/history/temp.md", new_path="reference/history/permanent.md") + +# Create a block with starting text +memory(command="create", reason="Track coding preferences", path="system/human/prefs/coding.md", description="The user's coding preferences.", file_text="The user seems to add type hints to all of their Python code.") + +# Create an empty block +memory(command="create", reason="Create coding preferences block", path="reference/history/coding_preferences.md", description="The user's coding preferences.") +``` diff --git a/src/tools/impl/Memory.ts b/src/tools/impl/Memory.ts new file mode 100644 index 0000000..14475a6 --- /dev/null +++ b/src/tools/impl/Memory.ts @@ -0,0 +1,582 @@ +import { execFile as execFileCb } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { promisify } from "node:util"; +import { getClient } from "../../agent/client"; +import { getCurrentAgentId } from "../../agent/context"; +import { validateRequiredParams } from "./validation"; + +const execFile = promisify(execFileCb); + +type MemoryCommand = "str_replace" | "insert" | "delete" | "rename" | "create"; + +interface MemoryArgs { + command: MemoryCommand; + reason: string; + path?: string; + old_path?: string; + new_path?: string; + old_string?: string; + new_string?: string; + insert_line?: number; + insert_text?: string; + description?: string; + file_text?: string; + limit?: number; +} + +async function getAgentIdentity(): Promise<{ + agentId: string; + agentName: string; +}> { + const envAgentId = ( + process.env.AGENT_ID || + process.env.LETTA_AGENT_ID || + "" + ).trim(); + const contextAgentId = (() => { + try { + return getCurrentAgentId().trim(); + } catch { + return ""; + } + })(); + const agentId = contextAgentId || envAgentId; + + if (!agentId) { + throw new Error("memory: unable to resolve agent id for git author email"); + } + + let agentName = ""; + try { + const client = await getClient(); + const agent = await client.agents.retrieve(agentId); + agentName = (agent.name || "").trim(); + } catch { + // Keep best-effort fallback below + } + + if (!agentName) { + agentName = (process.env.AGENT_NAME || "").trim() || agentId; + } + + return { agentId, agentName }; +} + +interface MemoryResult { + message: string; +} + +interface ParsedMemoryFile { + frontmatter: { + description: string; + limit: number; + read_only?: string; + }; + body: string; +} + +const DEFAULT_LIMIT = 2000; + +export async function memory(args: MemoryArgs): Promise { + validateRequiredParams(args, ["command", "reason"], "memory"); + + const reason = args.reason.trim(); + if (!reason) { + throw new Error("memory: 'reason' must be a non-empty string"); + } + + const memoryDir = resolveMemoryDir(); + ensureMemoryRepo(memoryDir); + + let affectedPaths: string[] = []; + const command = args.command; + + if (command === "create") { + const pathArg = requireString(args.path, "path", "create"); + const description = requireString( + args.description, + "description", + "create", + ); + const label = normalizeMemoryLabel(pathArg, "path"); + const filePath = resolveMemoryFilePath(memoryDir, label); + const relPath = toRepoRelative(memoryDir, filePath); + + if (existsSync(filePath)) { + throw new Error(`memory create: block already exists at ${pathArg}`); + } + + const limit = args.limit ?? DEFAULT_LIMIT; + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error("memory create: 'limit' must be a positive integer"); + } + + const body = args.file_text ?? ""; + const rendered = renderMemoryFile( + { + description, + limit, + }, + body, + ); + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, rendered, "utf8"); + affectedPaths = [relPath]; + } else if (command === "str_replace") { + const pathArg = requireString(args.path, "path", "str_replace"); + const oldString = requireString( + args.old_string, + "old_string", + "str_replace", + ); + const newString = requireString( + args.new_string, + "new_string", + "str_replace", + ); + + const label = normalizeMemoryLabel(pathArg, "path"); + const filePath = resolveMemoryFilePath(memoryDir, label); + const relPath = toRepoRelative(memoryDir, filePath); + const file = await loadEditableMemoryFile(filePath, pathArg); + + const idx = file.body.indexOf(oldString); + if (idx === -1) { + throw new Error( + "memory str_replace: old_string was not found in the target memory block", + ); + } + + const nextBody = `${file.body.slice(0, idx)}${newString}${file.body.slice(idx + oldString.length)}`; + const rendered = renderMemoryFile(file.frontmatter, nextBody); + await writeFile(filePath, rendered, "utf8"); + affectedPaths = [relPath]; + } else if (command === "insert") { + const pathArg = requireString(args.path, "path", "insert"); + const insertText = requireString(args.insert_text, "insert_text", "insert"); + + if ( + typeof args.insert_line !== "number" || + Number.isNaN(args.insert_line) + ) { + throw new Error("memory insert: 'insert_line' must be a number"); + } + + const label = normalizeMemoryLabel(pathArg, "path"); + const filePath = resolveMemoryFilePath(memoryDir, label); + const relPath = toRepoRelative(memoryDir, filePath); + const file = await loadEditableMemoryFile(filePath, pathArg); + + const lineNumber = Math.max(1, Math.floor(args.insert_line)); + const existingLines = file.body.length > 0 ? file.body.split("\n") : []; + const insertion = insertText.split("\n"); + const insertionIndex = Math.min( + Math.max(lineNumber - 1, 0), + existingLines.length, + ); + + existingLines.splice(insertionIndex, 0, ...insertion); + const nextBody = existingLines.join("\n"); + + const rendered = renderMemoryFile(file.frontmatter, nextBody); + await writeFile(filePath, rendered, "utf8"); + affectedPaths = [relPath]; + } else if (command === "delete") { + const pathArg = requireString(args.path, "path", "delete"); + const label = normalizeMemoryLabel(pathArg, "path"); + const filePath = resolveMemoryFilePath(memoryDir, label); + const relPath = toRepoRelative(memoryDir, filePath); + + await loadEditableMemoryFile(filePath, pathArg); + await unlink(filePath); + affectedPaths = [relPath]; + } else if (command === "rename") { + const hasDescriptionUpdate = + typeof args.path === "string" && + args.path.trim().length > 0 && + typeof args.description === "string" && + args.description.trim().length > 0 && + !args.old_path && + !args.new_path; + + if (hasDescriptionUpdate) { + const pathArg = requireString(args.path, "path", "rename"); + const newDescription = requireString( + args.description, + "description", + "rename description update", + ); + + const label = normalizeMemoryLabel(pathArg, "path"); + const filePath = resolveMemoryFilePath(memoryDir, label); + const relPath = toRepoRelative(memoryDir, filePath); + const file = await loadEditableMemoryFile(filePath, pathArg); + + const rendered = renderMemoryFile( + { + ...file.frontmatter, + description: newDescription, + }, + file.body, + ); + await writeFile(filePath, rendered, "utf8"); + affectedPaths = [relPath]; + } else { + const oldPathArg = requireString(args.old_path, "old_path", "rename"); + const newPathArg = requireString(args.new_path, "new_path", "rename"); + + const oldLabel = normalizeMemoryLabel(oldPathArg, "old_path"); + const newLabel = normalizeMemoryLabel(newPathArg, "new_path"); + + const oldFilePath = resolveMemoryFilePath(memoryDir, oldLabel); + const newFilePath = resolveMemoryFilePath(memoryDir, newLabel); + + const oldRelPath = toRepoRelative(memoryDir, oldFilePath); + const newRelPath = toRepoRelative(memoryDir, newFilePath); + + if (existsSync(newFilePath)) { + throw new Error( + `memory rename: destination already exists at ${newPathArg}`, + ); + } + + await loadEditableMemoryFile(oldFilePath, oldPathArg); + await mkdir(dirname(newFilePath), { recursive: true }); + await rename(oldFilePath, newFilePath); + affectedPaths = [oldRelPath, newRelPath]; + } + } else { + throw new Error(`Unsupported memory command: ${command}`); + } + + affectedPaths = Array.from(new Set(affectedPaths)).filter( + (p) => p.length > 0, + ); + if (affectedPaths.length === 0) { + return { message: `Memory ${command} completed with no changed paths.` }; + } + + const commitResult = await commitAndPush(memoryDir, affectedPaths, reason); + if (!commitResult.committed) { + return { + message: `Memory ${command} made no effective changes; skipped commit and push.`, + }; + } + + return { + message: `Memory ${command} applied and pushed (${commitResult.sha?.slice(0, 7) ?? "unknown"}).`, + }; +} + +function resolveMemoryDir(): string { + const direct = process.env.MEMORY_DIR || process.env.LETTA_MEMORY_DIR; + if (direct && direct.trim().length > 0) { + return resolve(direct); + } + + const contextAgentId = (() => { + try { + return getCurrentAgentId().trim(); + } catch { + return ""; + } + })(); + + const agentId = + contextAgentId || + (process.env.AGENT_ID || process.env.LETTA_AGENT_ID || "").trim(); + if (agentId && agentId.trim().length > 0) { + return resolve(homedir(), ".letta", "agents", agentId, "memory"); + } + + throw new Error( + "memory: unable to resolve memory directory. Ensure MEMORY_DIR (or AGENT_ID) is available.", + ); +} + +function ensureMemoryRepo(memoryDir: string): void { + if (!existsSync(memoryDir)) { + throw new Error(`memory: memory directory does not exist: ${memoryDir}`); + } + if (!existsSync(resolve(memoryDir, ".git"))) { + throw new Error( + `memory: ${memoryDir} is not a git repository. This tool requires a git-backed memory filesystem.`, + ); + } +} + +function normalizeMemoryLabel(inputPath: string, fieldName: string): string { + const raw = inputPath.trim(); + if (!raw) { + throw new Error(`memory: '${fieldName}' must be a non-empty string`); + } + + const normalized = raw.replace(/\\/g, "/"); + + if (/^[a-zA-Z]:\//.test(normalized)) { + throw new Error( + `memory: '${fieldName}' must be a memory-relative file path, not an absolute host path`, + ); + } + + if (normalized.startsWith("~/") || normalized.startsWith("$HOME/")) { + throw new Error( + `memory: '${fieldName}' must be a memory-relative file path, not a home-relative filesystem path`, + ); + } + + if (normalized.startsWith("/")) { + throw new Error( + `memory: '${fieldName}' must be a relative path like system/contacts.md`, + ); + } + + let label = normalized; + // Accept optional leading `memory/` directory segment. + label = label.replace(/^memory\//, ""); + + // Normalize away a trailing .md extension for all input styles. + label = label.replace(/\.md$/, ""); + + if (!label) { + throw new Error(`memory: '${fieldName}' resolves to an empty memory label`); + } + + const segments = label.split("/").filter(Boolean); + if (segments.length === 0) { + throw new Error(`memory: '${fieldName}' resolves to an empty memory label`); + } + + for (const segment of segments) { + if (segment === "." || segment === "..") { + throw new Error( + `memory: '${fieldName}' contains invalid path traversal segment`, + ); + } + if (segment.includes("\0")) { + throw new Error(`memory: '${fieldName}' contains invalid null bytes`); + } + } + + return segments.join("/"); +} + +function resolveMemoryFilePath(memoryDir: string, label: string): string { + const absolute = resolve(memoryDir, `${label}.md`); + const rel = relative(memoryDir, absolute); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error("memory: resolved path escapes memory directory"); + } + return absolute; +} + +function toRepoRelative(memoryDir: string, absolutePath: string): string { + const rel = relative(memoryDir, absolutePath); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error("memory: path is outside memory repository"); + } + return rel.replace(/\\/g, "/"); +} + +async function loadEditableMemoryFile( + filePath: string, + sourcePath: string, +): Promise { + const content = await readFile(filePath, "utf8").catch((error) => { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`memory: failed to read ${sourcePath}: ${message}`); + }); + + const parsed = parseMemoryFile(content); + if (parsed.frontmatter.read_only === "true") { + throw new Error( + `memory: ${sourcePath} is read_only and cannot be modified`, + ); + } + return parsed; +} + +function parseMemoryFile(content: string): ParsedMemoryFile { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) { + throw new Error("memory: target file is missing required frontmatter"); + } + + const frontmatterText = match[1] ?? ""; + const body = match[2] ?? ""; + + let description: string | undefined; + let limit: number | undefined; + let readOnly: string | undefined; + + for (const line of frontmatterText.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + + if (key === "description") { + description = value; + } else if (key === "limit") { + const parsedLimit = Number.parseInt(value, 10); + if (!Number.isNaN(parsedLimit)) { + limit = parsedLimit; + } + } else if (key === "read_only") { + readOnly = value; + } + } + + if (!description || !description.trim()) { + throw new Error("memory: target file frontmatter is missing 'description'"); + } + if (!limit || !Number.isInteger(limit) || limit <= 0) { + throw new Error( + "memory: target file frontmatter is missing a valid positive 'limit'", + ); + } + + return { + frontmatter: { + description, + limit, + ...(readOnly !== undefined ? { read_only: readOnly } : {}), + }, + body, + }; +} + +function renderMemoryFile( + frontmatter: { description: string; limit: number; read_only?: string }, + body: string, +): string { + const description = frontmatter.description.trim(); + if (!description) { + throw new Error("memory: 'description' must not be empty"); + } + if (!Number.isInteger(frontmatter.limit) || frontmatter.limit <= 0) { + throw new Error("memory: 'limit' must be a positive integer"); + } + + const lines = [ + "---", + `description: ${sanitizeFrontmatterValue(description)}`, + `limit: ${frontmatter.limit}`, + ]; + + if (frontmatter.read_only !== undefined) { + lines.push(`read_only: ${frontmatter.read_only}`); + } + + lines.push("---"); + + const header = lines.join("\n"); + if (!body) { + return `${header}\n`; + } + return `${header}\n${body}`; +} + +function sanitizeFrontmatterValue(value: string): string { + return value.replace(/\r?\n/g, " ").trim(); +} + +async function runGit( + memoryDir: string, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + try { + const result = await execFile("git", args, { + cwd: memoryDir, + maxBuffer: 10 * 1024 * 1024, + env: { + ...process.env, + PAGER: "cat", + GIT_PAGER: "cat", + }, + }); + + return { + stdout: result.stdout?.toString() ?? "", + stderr: result.stderr?.toString() ?? "", + }; + } catch (error) { + const stderr = + typeof error === "object" && error !== null && "stderr" in error + ? String((error as { stderr?: string }).stderr ?? "") + : ""; + const stdout = + typeof error === "object" && error !== null && "stdout" in error + ? String((error as { stdout?: string }).stdout ?? "") + : ""; + const message = error instanceof Error ? error.message : String(error); + + throw new Error( + `git ${args.join(" ")} failed: ${stderr || stdout || message}`.trim(), + ); + } +} + +async function commitAndPush( + memoryDir: string, + pathspecs: string[], + reason: string, +): Promise<{ committed: boolean; sha?: string }> { + await runGit(memoryDir, ["add", "-A", "--", ...pathspecs]); + + const status = await runGit(memoryDir, [ + "status", + "--porcelain", + "--", + ...pathspecs, + ]); + if (!status.stdout.trim()) { + return { committed: false }; + } + + const { agentId, agentName } = await getAgentIdentity(); + const authorName = agentName.trim() || agentId; + const authorEmail = `${agentId}@letta.com`; + + await runGit(memoryDir, [ + "-c", + `user.name=${authorName}`, + "-c", + `user.email=${authorEmail}`, + "commit", + "-m", + reason, + ]); + + const head = await runGit(memoryDir, ["rev-parse", "HEAD"]); + const sha = head.stdout.trim(); + + try { + await runGit(memoryDir, ["push"]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Memory changes were committed (${sha.slice(0, 7)}) but push failed: ${message}`, + ); + } + + return { + committed: true, + sha, + }; +} + +function requireString( + value: string | undefined, + field: string, + command: string, +): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`memory ${command}: '${field}' must be a non-empty string`); + } + return value; +} diff --git a/src/tools/manager.ts b/src/tools/manager.ts index ef0a466..e67fd6d 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -103,6 +103,7 @@ export const ANTHROPIC_DEFAULT_TOOLS: ToolName[] = [ "TaskStop", // "MultiEdit", // "LS", + "memory", "Read", "Skill", "Task", @@ -115,6 +116,7 @@ export const OPENAI_DEFAULT_TOOLS: ToolName[] = [ // TODO(codex-parity): add once request_user_input tool exists in raw codex path. // "request_user_input", "apply_patch", + "memory", "update_plan", "view_image", ]; @@ -125,6 +127,7 @@ export const GEMINI_DEFAULT_TOOLS: ToolName[] = [ "list_directory", "glob_gemini", "search_file_content", + "memory", "replace", "write_file_gemini", "write_todos", @@ -139,6 +142,7 @@ export const OPENAI_PASCAL_TOOLS: ToolName[] = [ "AskUserQuestion", "EnterPlanMode", "ExitPlanMode", + "memory", "Task", "TaskOutput", "TaskStop", @@ -155,6 +159,7 @@ export const GEMINI_PASCAL_TOOLS: ToolName[] = [ "AskUserQuestion", "EnterPlanMode", "ExitPlanMode", + "memory", "Skill", "Task", // Standard Gemini tools @@ -183,6 +188,7 @@ const TOOL_PERMISSIONS: Record = { KillBash: { requiresApproval: true }, TaskStop: { requiresApproval: true }, LS: { requiresApproval: false }, + memory: { requiresApproval: true }, MultiEdit: { requiresApproval: true }, Read: { requiresApproval: false }, view_image: { requiresApproval: false }, diff --git a/src/tools/schemas/Memory.json b/src/tools/schemas/Memory.json new file mode 100644 index 0000000..89e6fed --- /dev/null +++ b/src/tools/schemas/Memory.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["str_replace", "insert", "delete", "rename", "create"], + "description": "Memory operation to perform" + }, + "reason": { + "type": "string", + "description": "Required commit message for this memory change. Used as the git commit message." + }, + "path": { + "type": "string", + "description": "Target memory file path relative to memory root (e.g. system/contacts.md)" + }, + "old_path": { + "type": "string", + "description": "Source memory file path for rename operations (e.g. system/temp.md)" + }, + "new_path": { + "type": "string", + "description": "Destination memory file path for rename operations (e.g. system/permanent.md)" + }, + "old_string": { + "type": "string", + "description": "Text to replace in str_replace" + }, + "new_string": { + "type": "string", + "description": "Replacement text for str_replace" + }, + "insert_line": { + "type": "number", + "description": "1-indexed line number for insert" + }, + "insert_text": { + "type": "string", + "description": "Text to insert" + }, + "description": { + "type": "string", + "description": "Block description (required for create, or used with rename + path to update description)" + }, + "file_text": { + "type": "string", + "description": "Initial block content for create" + }, + "limit": { + "type": "number", + "description": "Optional positive integer limit for create" + } + }, + "required": ["command", "reason"], + "additionalProperties": false +} diff --git a/src/tools/toolDefinitions.ts b/src/tools/toolDefinitions.ts index d8189e8..5879ebd 100644 --- a/src/tools/toolDefinitions.ts +++ b/src/tools/toolDefinitions.ts @@ -14,6 +14,7 @@ import KillBashDescription from "./descriptions/KillBash.md"; import ListDirCodexDescription from "./descriptions/ListDirCodex.md"; import ListDirectoryGeminiDescription from "./descriptions/ListDirectoryGemini.md"; import LSDescription from "./descriptions/LS.md"; +import MemoryDescription from "./descriptions/Memory.md"; import MultiEditDescription from "./descriptions/MultiEdit.md"; import ReadDescription from "./descriptions/Read.md"; import ReadFileCodexDescription from "./descriptions/ReadFileCodex.md"; @@ -51,6 +52,7 @@ import { kill_bash } from "./impl/KillBash"; import { list_dir } from "./impl/ListDirCodex"; import { list_directory } from "./impl/ListDirectoryGemini"; import { ls } from "./impl/LS"; +import { memory } from "./impl/Memory"; import { multi_edit } from "./impl/MultiEdit"; import { read } from "./impl/Read"; import { read_file } from "./impl/ReadFileCodex"; @@ -88,6 +90,7 @@ import KillBashSchema from "./schemas/KillBash.json"; import ListDirCodexSchema from "./schemas/ListDirCodex.json"; import ListDirectoryGeminiSchema from "./schemas/ListDirectoryGemini.json"; import LSSchema from "./schemas/LS.json"; +import MemorySchema from "./schemas/Memory.json"; import MultiEditSchema from "./schemas/MultiEdit.json"; import ReadSchema from "./schemas/Read.json"; import ReadFileCodexSchema from "./schemas/ReadFileCodex.json"; @@ -179,6 +182,11 @@ const toolDefinitions = { description: LSDescription.trim(), impl: ls as unknown as ToolImplementation, }, + memory: { + schema: MemorySchema, + description: MemoryDescription.trim(), + impl: memory as unknown as ToolImplementation, + }, MultiEdit: { schema: MultiEditSchema, description: MultiEditDescription.trim(),