feat(tools): add client-side memory tool with git-backed sync (#1363)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
209
src/tests/tools/memory-tool.test.ts
Normal file
209
src/tests/tools/memory-tool.test.ts
Normal file
@@ -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<string> {
|
||||
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<typeof memory>[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);
|
||||
});
|
||||
});
|
||||
39
src/tools/descriptions/Memory.md
Normal file
39
src/tools/descriptions/Memory.md
Normal file
@@ -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.")
|
||||
```
|
||||
582
src/tools/impl/Memory.ts
Normal file
582
src/tools/impl/Memory.ts
Normal file
@@ -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<MemoryResult> {
|
||||
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<ParsedMemoryFile> {
|
||||
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;
|
||||
}
|
||||
@@ -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<ToolName, { requiresApproval: boolean }> = {
|
||||
KillBash: { requiresApproval: true },
|
||||
TaskStop: { requiresApproval: true },
|
||||
LS: { requiresApproval: false },
|
||||
memory: { requiresApproval: true },
|
||||
MultiEdit: { requiresApproval: true },
|
||||
Read: { requiresApproval: false },
|
||||
view_image: { requiresApproval: false },
|
||||
|
||||
57
src/tools/schemas/Memory.json
Normal file
57
src/tools/schemas/Memory.json
Normal file
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user