feat(tools): add client-side memory tool with git-backed sync (#1363)

This commit is contained in:
Sarah Wooders
2026-03-15 13:08:11 -07:00
committed by GitHub
parent c60363a25d
commit d6856fa5da
8 changed files with 905 additions and 0 deletions

View File

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

View File

@@ -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)) {

View 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);
});
});

View 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
View 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;
}

View File

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

View 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
}

View File

@@ -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(),