src / tools / agent.ts
src / tools / agent.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { appendFile, readFile, stat, writeFile } from "fs/promises";
import { join } from "path";
import { z } from "zod";
import { listBackups, restoreBackup, snapshot } from "../backup";
import { formatResult, runCommand } from "../shell";
import { clamp, type Workspace } from "../workspace";
const NOTES_FILE = ".lmstudio-notes.md";
/** Output that means "the tool is missing", not "your code is broken". */
const MISSING_TOOL =
/is not recognized as an internal or external|command not found|not the tsc command you are looking for|No such file or directory|ENOENT|is not installed|cannot find module '(typescript|jest|vitest)'/i;
interface Check {
label: string;
command: string;
}
/**
* Works out how to build, typecheck and test this project so the model does not
* have to guess a command. Order matters: cheapest and most diagnostic first.
*/
async function detectChecks(ws: Workspace): Promise<Check[]> {
const checks: Check[] = [];
const has = (name: string): boolean => existsSync(join(ws.root, name));
if (has("package.json")) {
try {
const pkg: unknown = JSON.parse(await readFile(join(ws.root, "package.json"), "utf-8"));
const scripts =
typeof pkg === "object" && pkg !== null
? ((pkg as { scripts?: Record<string, string> }).scripts ?? {})
: {};
const runner = has("pnpm-lock.yaml")
? "pnpm"
: has("yarn.lock")
? "yarn"
: has("bun.lockb")
? "bun"
: "npm";
const call = (script: string): string =>
runner === "npm" ? `npm run ${script} --silent` : `${runner} run ${script}`;
for (const name of ["typecheck", "type-check", "tsc"]) {
if (scripts[name] !== undefined) {
checks.push({ label: "typecheck", command: call(name) });
break;
}
}
if (checks.length === 0 && has("tsconfig.json")) {
checks.push({ label: "typecheck", command: "npx tsc --noEmit" });
}
if (scripts.lint !== undefined) checks.push({ label: "lint", command: call("lint") });
if (scripts.build !== undefined) checks.push({ label: "build", command: call("build") });
if (scripts.test !== undefined) checks.push({ label: "test", command: call("test") });
} catch {
// Unreadable package.json: fall through to the other detectors.
}
} else if (has("tsconfig.json")) {
checks.push({ label: "typecheck", command: "npx tsc --noEmit" });
}
if (has("Cargo.toml")) {
checks.push({ label: "check", command: "cargo check" }, { label: "test", command: "cargo test" });
}
if (has("go.mod")) {
checks.push({ label: "build", command: "go build ./..." }, { label: "test", command: "go test ./..." });
}
if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) {
if (existsSync(join(ws.root, "tests")) || existsSync(join(ws.root, "test"))) {
checks.push({ label: "test", command: "python -m pytest -q" });
}
}
if (has("Makefile")) {
const body = await readFile(join(ws.root, "Makefile"), "utf-8").catch(() => "");
if (/^test:/m.test(body)) checks.push({ label: "test", command: "make test" });
else if (/^all:/m.test(body)) checks.push({ label: "build", command: "make" });
}
return checks;
}
/**
* Build output names a file and line but never shows the code there, leaving a
* small model to fix something it cannot see. This pulls the actual source in.
*/
async function attachSource(ws: Workspace, output: string): Promise<string> {
// path/to/file.ts:42:9 or path/to/file.ts(42,9) or File "x.py", line 42
const pattern =
/(?:^|[\s"'(])([\w./\\-]+\.[A-Za-z]{1,5})(?::(\d+)(?::\d+)?|\((\d+),\d+\)|",\s*line\s+(\d+))/gm;
const seen = new Set<string>();
const blocks: string[] = [];
for (const match of output.matchAll(pattern)) {
if (blocks.length >= 3) break;
const rel = match[1];
const line = Number(match[2] ?? match[3] ?? match[4]);
if (!Number.isFinite(line) || line < 1) continue;
const id = `${rel}:${line}`;
if (seen.has(id)) continue;
seen.add(id);
let absPath: string;
try {
absPath = ws.resolveInRoot(rel);
} catch {
continue; // Path outside the workspace, e.g. a dependency's own file.
}
if (!existsSync(absPath)) continue;
try {
const lines = (await readFile(absPath, "utf-8")).split(/\r?\n/);
const from = Math.max(1, line - 3);
const to = Math.min(lines.length, line + 3);
const body = lines
.slice(from - 1, to)
.map((text, i) => `${from + i === line ? ">" : " "} ${String(from + i).padStart(5)} ${text}`)
.join("\n");
blocks.push(`${rel} around line ${line}:\n${body}`);
} catch {
// Unreadable; the error text alone will have to do.
}
}
return blocks.length === 0 ? "" : `\n\nSource at the reported locations:\n\n${blocks.join("\n\n")}`;
}
/** Pulls the lines a human would actually look at out of a wall of build output. */
function extractProblems(output: string, limit = 25): string {
const lines = output.split(/\r?\n/);
const interesting = lines.filter((line) =>
/\b(error|failed|failure|cannot find|undefined reference|panic|traceback|assert)\b/i.test(line),
);
const chosen = interesting.length > 0 ? interesting : lines.filter((l) => l.trim() !== "").slice(-limit);
const shown = chosen.slice(0, limit);
const extra = chosen.length - shown.length;
return shown.join("\n") + (extra > 0 ? `\n...and ${extra} more line(s)` : "");
}
export function agentTools(ws: Workspace): Tool[] {
const tools: Tool[] = [];
if (ws.allowShell) {
tools.push(
tool({
name: "verify",
description:
"Check that the project still works: finds and runs this project's typecheck, lint, " +
"build and test commands automatically. Run this after every batch of edits, and " +
"before telling the user you are done. Reports which checks passed and the actual error " +
"lines from any that failed.",
parameters: {
only: z
.string()
.default("")
.describe(
"Optionally run just one kind of check: typecheck, lint, build or test. Empty runs all of them.",
),
},
implementation: async ({ only }, ctx) => {
const all = await detectChecks(ws);
if (all.length === 0) {
return (
"No build, test or typecheck command could be detected for this project. " +
"Look for a package.json, Makefile or CI config with run_command, then use " +
"run_command directly."
);
}
const wanted = only.trim() === "" ? all : all.filter((c) => c.label === only.trim());
if (wanted.length === 0) {
return `Error: no "${only}" check exists here. Available: ${all.map((c) => c.label).join(", ")}.`;
}
const passed: string[] = [];
const failed: string[] = [];
let environmentProblem = false;
for (const check of wanted) {
ctx.status(`${check.label}: ${check.command}`);
const result = await runCommand(check.command, ws.runOptions);
if (result.ok) {
passed.push(`${check.label} (${check.command})`);
continue;
}
const combined = `${result.stdout}\n${result.stderr}`.trim();
// A missing toolchain is not a code defect, and telling the model to
// "fix the errors" would send it editing perfectly good source.
if (MISSING_TOOL.test(combined) || MISSING_TOOL.test(result.error)) {
environmentProblem = true;
failed.push(
`--- ${check.label} COULD NOT RUN (${check.command}) ---\n` +
`The tool itself is not installed or not on PATH. This is an environment problem, ` +
`not a bug in the code -- do not edit source files to try to fix it. Tell the user ` +
`what is missing and how to install it (for a Node project, usually 'npm install').`,
);
break;
}
const problems = result.timedOut
? `Timed out after ${ws.commandTimeoutSec}s.`
: extractProblems(
combined === "" ? formatResult(result, ws.commandTimeoutSec) : combined,
);
const source = result.timedOut ? "" : await attachSource(ws, problems);
failed.push(`--- ${check.label} FAILED (${check.command}) ---\n${problems}${source}`);
// A broken typecheck makes later stages noise, so stop at the first failure.
break;
}
const head =
failed.length === 0
? `All ${passed.length} check(s) passed: ${passed.join(", ")}.`
: environmentProblem
? "A check could not run because its tool is missing. Nothing is known to be wrong with the code."
: `${failed.length} check FAILED. Fix the errors below, then run verify again.`;
const body = [
passed.length > 0 && failed.length > 0 ? `Passed first: ${passed.join(", ")}` : "",
...failed,
]
.filter((s) => s !== "")
.join("\n\n");
return clamp(body === "" ? head : `${head}\n\n${body}`, 12000, "verify output");
},
}),
);
}
if (ws.allowWrite) {
tools.push(
tool({
name: "undo_last_edit",
description:
"Undo the most recent file change you made, restoring the file from its automatic " +
"backup. Use this the moment you realise an edit was wrong, instead of trying to patch " +
"the damage.",
parameters: {},
implementation: async (_params, ctx) => {
const backups = await listBackups(ws);
if (backups.length === 0) {
return "Nothing to undo -- no file has been modified through these tools yet.";
}
const newest = backups[0];
ctx.status(`Restoring ${newest.originalPath}`);
try {
const restored = await restoreBackup(ws, newest.fileName);
return (
`Restored ${restored} to its state before the last edit. ` +
`The version you just undid was itself backed up, so this is reversible. ` +
`Read the file again before editing it further.`
);
} catch (error) {
return `Error: could not undo -- ${(error as Error).message}`;
}
},
}),
);
tools.push(
tool({
name: "apply_patch",
description:
"Apply a unified diff (the output of `git diff` or `diff -u`) to the workspace. Use this " +
"when you already have a patch; for ordinary edits prefer edit_file, which is harder to " +
"get wrong. Applies all hunks or none.",
parameters: {
patch: z
.string()
.describe("The unified diff text, including ---/+++ headers and @@ hunk markers."),
},
implementation: async ({ patch }, ctx) => {
const files = parsePatch(patch);
if (files.length === 0) {
return (
"Error: no valid unified-diff hunks found. The patch needs '--- a/path', " +
"'+++ b/path' and '@@ -start,count +start,count @@' markers."
);
}
// Validate every file before touching any of them.
const staged: { absPath: string; rel: string; content: string; hunks: number }[] = [];
for (const file of files) {
const absPath = ws.resolveInRoot(file.path);
if (!existsSync(absPath)) {
return `Error: patch targets "${file.path}", which does not exist. No changes were made.`;
}
const original = await readFile(absPath, "utf-8");
const applied = applyHunks(original, file.hunks);
if (applied === undefined) {
return (
`Error: hunk did not match the current contents of "${file.path}". ` +
`No changes were made to any file. Read the file and use edit_file instead.`
);
}
staged.push({ absPath, rel: file.path, content: applied, hunks: file.hunks.length });
}
for (const item of staged) {
ctx.status(`Patching ${item.rel}`);
await snapshot(ws, item.absPath);
await writeFile(item.absPath, item.content, "utf-8");
}
return (
`Applied ${staged.reduce((n, s) => n + s.hunks, 0)} hunk(s) across ${staged.length} file(s): ` +
`${staged.map((s) => s.rel).join(", ")}. Run verify to confirm nothing broke.`
);
},
}),
);
tools.push(
tool({
name: "remember",
description:
"Save a durable note about this project -- a convention, a gotcha, a decision the user " +
"made -- so it survives into later conversations. Use it when you learn something that " +
"would be expensive to rediscover. Do not use it for the current task's steps; that is " +
"what set_tasks is for.",
parameters: {
note: z.string().describe("One or two sentences. Be specific and self-contained."),
},
implementation: async ({ note }, ctx) => {
const trimmed = note.trim();
if (trimmed === "") return "Error: the note is empty.";
const target = join(ws.root, NOTES_FILE);
ctx.status("Saving note");
const header = existsSync(target) ? "" : `# Project notes\n\nNotes kept by the assistant.\n`;
await appendFile(target, `${header}\n- ${trimmed}\n`, "utf-8");
return `Saved to ${NOTES_FILE}. It will be available in later sessions via recall.`;
},
}),
);
}
tools.push(
tool({
name: "recall",
description:
"Read the durable project notes saved earlier with remember. Worth calling once at the " +
"start of a session in a project you have worked on before.",
parameters: {},
implementation: async (_params, ctx) => {
const target = join(ws.root, NOTES_FILE);
if (!existsSync(target)) {
return "No project notes yet. Use remember to save something worth keeping.";
}
ctx.status("Reading notes");
const body = await readFile(target, "utf-8");
return clamp(body, 6000, "notes");
},
}),
);
tools.push(
tool({
name: "changed_files",
description:
"List the files you have modified through these tools in this session, newest first. Use " +
"it to summarise your work, or to check you have not touched something you did not intend to.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Checking modified files");
const backups = await listBackups(ws);
if (backups.length === 0) return "No files have been modified through these tools.";
const seen = new Map<string, string>();
for (const entry of backups) {
if (!seen.has(entry.originalPath)) seen.set(entry.originalPath, entry.takenAt);
}
const lines: string[] = [];
for (const [path, takenAt] of seen) {
const abs = join(ws.root, path);
let size = "missing";
try {
size = `${(await stat(abs)).size} bytes`;
} catch {
size = "deleted or moved";
}
lines.push(`${path} -- first backed up ${takenAt}, now ${size}`);
}
return `${seen.size} file(s) modified:\n${lines.join("\n")}`;
},
}),
);
return tools;
}
interface Hunk {
oldStart: number;
lines: string[];
}
interface PatchFile {
path: string;
hunks: Hunk[];
}
function parsePatch(patch: string): PatchFile[] {
const files: PatchFile[] = [];
const lines = patch.split(/\r?\n/);
let current: PatchFile | undefined;
let hunk: Hunk | undefined;
for (const line of lines) {
const plus = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
if (plus !== null) {
const path = plus[1].trim();
if (path !== "/dev/null") {
current = { path, hunks: [] };
files.push(current);
}
hunk = undefined;
continue;
}
if (line.startsWith("--- ")) continue;
const at = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
if (at !== null) {
if (current === undefined) continue;
hunk = { oldStart: Number(at[1]), lines: [] };
current.hunks.push(hunk);
continue;
}
if (hunk !== undefined && /^[ +\-\\]/.test(line)) hunk.lines.push(line);
}
return files.filter((file) => file.hunks.length > 0);
}
/** Applies hunks to `original`, returning undefined if any context fails to match. */
function applyHunks(original: string, hunks: Hunk[]): string | undefined {
const lines = original.split(/\r?\n/);
// Apply bottom-up so earlier hunk offsets stay valid.
const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
for (const hunk of ordered) {
const expected: string[] = [];
const replacement: string[] = [];
for (const entry of hunk.lines) {
if (entry.startsWith("\\")) continue;
const body = entry.slice(1);
if (entry.startsWith(" ")) {
expected.push(body);
replacement.push(body);
} else if (entry.startsWith("-")) {
expected.push(body);
} else if (entry.startsWith("+")) {
replacement.push(body);
}
}
const start = hunk.oldStart - 1;
const found = locate(lines, expected, start);
if (found === undefined) return undefined;
lines.splice(found, expected.length, ...replacement);
}
return lines.join("\n");
}
/** Finds `expected` at `hint`, else the nearest match, so slightly stale line numbers still apply. */
function locate(lines: string[], expected: string[], hint: number): number | undefined {
const matches = (at: number): boolean =>
at >= 0 &&
at + expected.length <= lines.length &&
expected.every((line, i) => lines[at + i] === line);
if (matches(hint)) return hint;
for (let drift = 1; drift <= 200; drift++) {
if (matches(hint - drift)) return hint - drift;
if (matches(hint + drift)) return hint + drift;
}
return undefined;
}
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { appendFile, readFile, stat, writeFile } from "fs/promises";
import { join } from "path";
import { z } from "zod";
import { listBackups, restoreBackup, snapshot } from "../backup";
import { formatResult, runCommand } from "../shell";
import { clamp, type Workspace } from "../workspace";
const NOTES_FILE = ".lmstudio-notes.md";
/** Output that means "the tool is missing", not "your code is broken". */
const MISSING_TOOL =
/is not recognized as an internal or external|command not found|not the tsc command you are looking for|No such file or directory|ENOENT|is not installed|cannot find module '(typescript|jest|vitest)'/i;
interface Check {
label: string;
command: string;
}
/**
* Works out how to build, typecheck and test this project so the model does not
* have to guess a command. Order matters: cheapest and most diagnostic first.
*/
async function detectChecks(ws: Workspace): Promise<Check[]> {
const checks: Check[] = [];
const has = (name: string): boolean => existsSync(join(ws.root, name));
if (has("package.json")) {
try {
const pkg: unknown = JSON.parse(await readFile(join(ws.root, "package.json"), "utf-8"));
const scripts =
typeof pkg === "object" && pkg !== null
? ((pkg as { scripts?: Record<string, string> }).scripts ?? {})
: {};
const runner = has("pnpm-lock.yaml")
? "pnpm"
: has("yarn.lock")
? "yarn"
: has("bun.lockb")
? "bun"
: "npm";
const call = (script: string): string =>
runner === "npm" ? `npm run ${script} --silent` : `${runner} run ${script}`;
for (const name of ["typecheck", "type-check", "tsc"]) {
if (scripts[name] !== undefined) {
checks.push({ label: "typecheck", command: call(name) });
break;
}
}
if (checks.length === 0 && has("tsconfig.json")) {
checks.push({ label: "typecheck", command: "npx tsc --noEmit" });
}
if (scripts.lint !== undefined) checks.push({ label: "lint", command: call("lint") });
if (scripts.build !== undefined) checks.push({ label: "build", command: call("build") });
if (scripts.test !== undefined) checks.push({ label: "test", command: call("test") });
} catch {
// Unreadable package.json: fall through to the other detectors.
}
} else if (has("tsconfig.json")) {
checks.push({ label: "typecheck", command: "npx tsc --noEmit" });
}
if (has("Cargo.toml")) {
checks.push({ label: "check", command: "cargo check" }, { label: "test", command: "cargo test" });
}
if (has("go.mod")) {
checks.push({ label: "build", command: "go build ./..." }, { label: "test", command: "go test ./..." });
}
if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) {
if (existsSync(join(ws.root, "tests")) || existsSync(join(ws.root, "test"))) {
checks.push({ label: "test", command: "python -m pytest -q" });
}
}
if (has("Makefile")) {
const body = await readFile(join(ws.root, "Makefile"), "utf-8").catch(() => "");
if (/^test:/m.test(body)) checks.push({ label: "test", command: "make test" });
else if (/^all:/m.test(body)) checks.push({ label: "build", command: "make" });
}
return checks;
}
/**
* Build output names a file and line but never shows the code there, leaving a
* small model to fix something it cannot see. This pulls the actual source in.
*/
async function attachSource(ws: Workspace, output: string): Promise<string> {
// path/to/file.ts:42:9 or path/to/file.ts(42,9) or File "x.py", line 42
const pattern =
/(?:^|[\s"'(])([\w./\\-]+\.[A-Za-z]{1,5})(?::(\d+)(?::\d+)?|\((\d+),\d+\)|",\s*line\s+(\d+))/gm;
const seen = new Set<string>();
const blocks: string[] = [];
for (const match of output.matchAll(pattern)) {
if (blocks.length >= 3) break;
const rel = match[1];
const line = Number(match[2] ?? match[3] ?? match[4]);
if (!Number.isFinite(line) || line < 1) continue;
const id = `${rel}:${line}`;
if (seen.has(id)) continue;
seen.add(id);
let absPath: string;
try {
absPath = ws.resolveInRoot(rel);
} catch {
continue; // Path outside the workspace, e.g. a dependency's own file.
}
if (!existsSync(absPath)) continue;
try {
const lines = (await readFile(absPath, "utf-8")).split(/\r?\n/);
const from = Math.max(1, line - 3);
const to = Math.min(lines.length, line + 3);
const body = lines
.slice(from - 1, to)
.map((text, i) => `${from + i === line ? ">" : " "} ${String(from + i).padStart(5)} ${text}`)
.join("\n");
blocks.push(`${rel} around line ${line}:\n${body}`);
} catch {
// Unreadable; the error text alone will have to do.
}
}
return blocks.length === 0 ? "" : `\n\nSource at the reported locations:\n\n${blocks.join("\n\n")}`;
}
/** Pulls the lines a human would actually look at out of a wall of build output. */
function extractProblems(output: string, limit = 25): string {
const lines = output.split(/\r?\n/);
const interesting = lines.filter((line) =>
/\b(error|failed|failure|cannot find|undefined reference|panic|traceback|assert)\b/i.test(line),
);
const chosen = interesting.length > 0 ? interesting : lines.filter((l) => l.trim() !== "").slice(-limit);
const shown = chosen.slice(0, limit);
const extra = chosen.length - shown.length;
return shown.join("\n") + (extra > 0 ? `\n...and ${extra} more line(s)` : "");
}
export function agentTools(ws: Workspace): Tool[] {
const tools: Tool[] = [];
if (ws.allowShell) {
tools.push(
tool({
name: "verify",
description:
"Check that the project still works: finds and runs this project's typecheck, lint, " +
"build and test commands automatically. Run this after every batch of edits, and " +
"before telling the user you are done. Reports which checks passed and the actual error " +
"lines from any that failed.",
parameters: {
only: z
.string()
.default("")
.describe(
"Optionally run just one kind of check: typecheck, lint, build or test. Empty runs all of them.",
),
},
implementation: async ({ only }, ctx) => {
const all = await detectChecks(ws);
if (all.length === 0) {
return (
"No build, test or typecheck command could be detected for this project. " +
"Look for a package.json, Makefile or CI config with run_command, then use " +
"run_command directly."
);
}
const wanted = only.trim() === "" ? all : all.filter((c) => c.label === only.trim());
if (wanted.length === 0) {
return `Error: no "${only}" check exists here. Available: ${all.map((c) => c.label).join(", ")}.`;
}
const passed: string[] = [];
const failed: string[] = [];
let environmentProblem = false;
for (const check of wanted) {
ctx.status(`${check.label}: ${check.command}`);
const result = await runCommand(check.command, ws.runOptions);
if (result.ok) {
passed.push(`${check.label} (${check.command})`);
continue;
}
const combined = `${result.stdout}\n${result.stderr}`.trim();
// A missing toolchain is not a code defect, and telling the model to
// "fix the errors" would send it editing perfectly good source.
if (MISSING_TOOL.test(combined) || MISSING_TOOL.test(result.error)) {
environmentProblem = true;
failed.push(
`--- ${check.label} COULD NOT RUN (${check.command}) ---\n` +
`The tool itself is not installed or not on PATH. This is an environment problem, ` +
`not a bug in the code -- do not edit source files to try to fix it. Tell the user ` +
`what is missing and how to install it (for a Node project, usually 'npm install').`,
);
break;
}
const problems = result.timedOut
? `Timed out after ${ws.commandTimeoutSec}s.`
: extractProblems(
combined === "" ? formatResult(result, ws.commandTimeoutSec) : combined,
);
const source = result.timedOut ? "" : await attachSource(ws, problems);
failed.push(`--- ${check.label} FAILED (${check.command}) ---\n${problems}${source}`);
// A broken typecheck makes later stages noise, so stop at the first failure.
break;
}
const head =
failed.length === 0
? `All ${passed.length} check(s) passed: ${passed.join(", ")}.`
: environmentProblem
? "A check could not run because its tool is missing. Nothing is known to be wrong with the code."
: `${failed.length} check FAILED. Fix the errors below, then run verify again.`;
const body = [
passed.length > 0 && failed.length > 0 ? `Passed first: ${passed.join(", ")}` : "",
...failed,
]
.filter((s) => s !== "")
.join("\n\n");
return clamp(body === "" ? head : `${head}\n\n${body}`, 12000, "verify output");
},
}),
);
}
if (ws.allowWrite) {
tools.push(
tool({
name: "undo_last_edit",
description:
"Undo the most recent file change you made, restoring the file from its automatic " +
"backup. Use this the moment you realise an edit was wrong, instead of trying to patch " +
"the damage.",
parameters: {},
implementation: async (_params, ctx) => {
const backups = await listBackups(ws);
if (backups.length === 0) {
return "Nothing to undo -- no file has been modified through these tools yet.";
}
const newest = backups[0];
ctx.status(`Restoring ${newest.originalPath}`);
try {
const restored = await restoreBackup(ws, newest.fileName);
return (
`Restored ${restored} to its state before the last edit. ` +
`The version you just undid was itself backed up, so this is reversible. ` +
`Read the file again before editing it further.`
);
} catch (error) {
return `Error: could not undo -- ${(error as Error).message}`;
}
},
}),
);
tools.push(
tool({
name: "apply_patch",
description:
"Apply a unified diff (the output of `git diff` or `diff -u`) to the workspace. Use this " +
"when you already have a patch; for ordinary edits prefer edit_file, which is harder to " +
"get wrong. Applies all hunks or none.",
parameters: {
patch: z
.string()
.describe("The unified diff text, including ---/+++ headers and @@ hunk markers."),
},
implementation: async ({ patch }, ctx) => {
const files = parsePatch(patch);
if (files.length === 0) {
return (
"Error: no valid unified-diff hunks found. The patch needs '--- a/path', " +
"'+++ b/path' and '@@ -start,count +start,count @@' markers."
);
}
// Validate every file before touching any of them.
const staged: { absPath: string; rel: string; content: string; hunks: number }[] = [];
for (const file of files) {
const absPath = ws.resolveInRoot(file.path);
if (!existsSync(absPath)) {
return `Error: patch targets "${file.path}", which does not exist. No changes were made.`;
}
const original = await readFile(absPath, "utf-8");
const applied = applyHunks(original, file.hunks);
if (applied === undefined) {
return (
`Error: hunk did not match the current contents of "${file.path}". ` +
`No changes were made to any file. Read the file and use edit_file instead.`
);
}
staged.push({ absPath, rel: file.path, content: applied, hunks: file.hunks.length });
}
for (const item of staged) {
ctx.status(`Patching ${item.rel}`);
await snapshot(ws, item.absPath);
await writeFile(item.absPath, item.content, "utf-8");
}
return (
`Applied ${staged.reduce((n, s) => n + s.hunks, 0)} hunk(s) across ${staged.length} file(s): ` +
`${staged.map((s) => s.rel).join(", ")}. Run verify to confirm nothing broke.`
);
},
}),
);
tools.push(
tool({
name: "remember",
description:
"Save a durable note about this project -- a convention, a gotcha, a decision the user " +
"made -- so it survives into later conversations. Use it when you learn something that " +
"would be expensive to rediscover. Do not use it for the current task's steps; that is " +
"what set_tasks is for.",
parameters: {
note: z.string().describe("One or two sentences. Be specific and self-contained."),
},
implementation: async ({ note }, ctx) => {
const trimmed = note.trim();
if (trimmed === "") return "Error: the note is empty.";
const target = join(ws.root, NOTES_FILE);
ctx.status("Saving note");
const header = existsSync(target) ? "" : `# Project notes\n\nNotes kept by the assistant.\n`;
await appendFile(target, `${header}\n- ${trimmed}\n`, "utf-8");
return `Saved to ${NOTES_FILE}. It will be available in later sessions via recall.`;
},
}),
);
}
tools.push(
tool({
name: "recall",
description:
"Read the durable project notes saved earlier with remember. Worth calling once at the " +
"start of a session in a project you have worked on before.",
parameters: {},
implementation: async (_params, ctx) => {
const target = join(ws.root, NOTES_FILE);
if (!existsSync(target)) {
return "No project notes yet. Use remember to save something worth keeping.";
}
ctx.status("Reading notes");
const body = await readFile(target, "utf-8");
return clamp(body, 6000, "notes");
},
}),
);
tools.push(
tool({
name: "changed_files",
description:
"List the files you have modified through these tools in this session, newest first. Use " +
"it to summarise your work, or to check you have not touched something you did not intend to.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Checking modified files");
const backups = await listBackups(ws);
if (backups.length === 0) return "No files have been modified through these tools.";
const seen = new Map<string, string>();
for (const entry of backups) {
if (!seen.has(entry.originalPath)) seen.set(entry.originalPath, entry.takenAt);
}
const lines: string[] = [];
for (const [path, takenAt] of seen) {
const abs = join(ws.root, path);
let size = "missing";
try {
size = `${(await stat(abs)).size} bytes`;
} catch {
size = "deleted or moved";
}
lines.push(`${path} -- first backed up ${takenAt}, now ${size}`);
}
return `${seen.size} file(s) modified:\n${lines.join("\n")}`;
},
}),
);
return tools;
}
interface Hunk {
oldStart: number;
lines: string[];
}
interface PatchFile {
path: string;
hunks: Hunk[];
}
function parsePatch(patch: string): PatchFile[] {
const files: PatchFile[] = [];
const lines = patch.split(/\r?\n/);
let current: PatchFile | undefined;
let hunk: Hunk | undefined;
for (const line of lines) {
const plus = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
if (plus !== null) {
const path = plus[1].trim();
if (path !== "/dev/null") {
current = { path, hunks: [] };
files.push(current);
}
hunk = undefined;
continue;
}
if (line.startsWith("--- ")) continue;
const at = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
if (at !== null) {
if (current === undefined) continue;
hunk = { oldStart: Number(at[1]), lines: [] };
current.hunks.push(hunk);
continue;
}
if (hunk !== undefined && /^[ +\-\\]/.test(line)) hunk.lines.push(line);
}
return files.filter((file) => file.hunks.length > 0);
}
/** Applies hunks to `original`, returning undefined if any context fails to match. */
function applyHunks(original: string, hunks: Hunk[]): string | undefined {
const lines = original.split(/\r?\n/);
// Apply bottom-up so earlier hunk offsets stay valid.
const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
for (const hunk of ordered) {
const expected: string[] = [];
const replacement: string[] = [];
for (const entry of hunk.lines) {
if (entry.startsWith("\\")) continue;
const body = entry.slice(1);
if (entry.startsWith(" ")) {
expected.push(body);
replacement.push(body);
} else if (entry.startsWith("-")) {
expected.push(body);
} else if (entry.startsWith("+")) {
replacement.push(body);
}
}
const start = hunk.oldStart - 1;
const found = locate(lines, expected, start);
if (found === undefined) return undefined;
lines.splice(found, expected.length, ...replacement);
}
return lines.join("\n");
}
/** Finds `expected` at `hint`, else the nearest match, so slightly stale line numbers still apply. */
function locate(lines: string[], expected: string[], hint: number): number | undefined {
const matches = (at: number): boolean =>
at >= 0 &&
at + expected.length <= lines.length &&
expected.every((line, i) => lines[at + i] === line);
if (matches(hint)) return hint;
for (let drift = 1; drift <= 200; drift++) {
if (matches(hint - drift)) return hint - drift;
if (matches(hint + drift)) return hint + drift;
}
return undefined;
}