agent-from-zero 0.1.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import fs from "node:fs"; // filesystem reads and writes
2
+ import os from "node:os"; // temp dir for spilled tool output
2
3
  import path from "node:path"; // path resolution and joining
3
- import { execSync } from "node:child_process"; // runs a shell command and waits for it
4
+ import { spawn } from "node:child_process"; // async process execution (does NOT block the event loop)
5
+ import { validateArgs } from "./validate.js"; // schema validation for tool arguments
6
+ import { setPlanMode } from "./permissions.js"; // plan mode (Day 20): exit_plan_mode flips it off on approval
7
+ import { recordMutation } from "./undo.js"; // capture the before-state so /undo (Day 22) can restore it
4
8
  // ============ Session state ============
5
9
  // The foundation of "read before edit": which files has this session actually read?
6
10
  // Files that were never read must not be edited — otherwise the model edits from
@@ -15,6 +19,9 @@ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering
15
19
  const READ_LIMIT = 16000; // read_file gets a larger cap of its own
16
20
  const SEARCH_MAX_MATCHES = 50; // max matches returned by search
17
21
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
22
+ const BASH_TIMEOUT_MS = 30_000; // a single command may run this long before we kill it
23
+ const BASH_OUTPUT_LIMIT = 8_000; // chars of command output kept inline; beyond this we spill to a file
24
+ const SPILL_NOTE_SLACK = 400; // room for the "[...saved to FILE]" note so dispatch's cap never eats it
18
25
  // Errors are not exceptions — they are text that tells the model what to do next.
19
26
  const fail = (msg) => `[error] ${msg}`;
20
27
  // Small helper to build a tool definition without repeating boilerplate.
@@ -63,6 +70,7 @@ On error: follow the error message — read the file first, then retry.`, {
63
70
  return fail(`${p} already exists but you have not read it. Use read_file to check its current content before overwriting.`); // no blind overwrites
64
71
  }
65
72
  fs.mkdirSync(path.dirname(p), { recursive: true }); // create parent directories as needed
73
+ recordMutation(p); // snapshot the previous state (or "didn't exist") for /undo, before we overwrite
66
74
  fs.writeFileSync(p, args.content, "utf8"); // write the new content
67
75
  readFiles.set(p, ++touchCounter); // you wrote it, so you know its content — counts as read
68
76
  return `Wrote ${p} (${args.content.length} chars)`; // confirm with the size as a sanity check
@@ -98,6 +106,7 @@ ALWAYS use this tool to edit files. NEVER use sed/awk or shell redirection via r
98
106
  }
99
107
  // Note: content.replace(old, newStr) would expand $& $' etc. in newStr as special
100
108
  // replacement patterns — pass a function so the replacement is taken literally.
109
+ recordMutation(p); // snapshot the pre-edit content for /undo, before we change it
101
110
  fs.writeFileSync(p, content.replace(args.old_string, () => args.new_string), "utf8"); // apply the single replacement
102
111
  return `Edited ${p}: 1 replacement made.`; // confirm exactly what happened
103
112
  },
@@ -165,39 +174,118 @@ ALWAYS use this tool to search. NEVER run grep/rg/find via run_bash.`, {
165
174
  },
166
175
  };
167
176
  // ============ run_bash ============
177
+ // Spill oversized output to a file and hand the model a preview + the path, so
178
+ // it can read_file deeper if it wants. Returning megabytes inline would blow up
179
+ // the context; truncating silently would hide the part that matters.
180
+ function spillIfHuge(out, what) {
181
+ if (out.length <= BASH_OUTPUT_LIMIT)
182
+ return out; // fits inline — return as-is
183
+ const file = path.join(os.tmpdir(), `mini-agent-output-${Date.now()}.txt`); // a stable place to find it
184
+ fs.writeFileSync(file, out, "utf8"); // the full output lives on disk
185
+ const head = out.slice(0, BASH_OUTPUT_LIMIT); // a generous preview
186
+ return `${head}\n\n[...${what} truncated: ${out.length} chars total. Full output saved to ${file} — use read_file to inspect more.]`;
187
+ }
168
188
  const runBash = {
169
189
  definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
170
190
  Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
171
191
  To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
172
192
  Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
173
193
  On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
174
- run: (args) => {
175
- try {
176
- // stderr is piped (not inherited): it belongs in the tool result for the
177
- // model to read, not splattered onto the user's terminal.
178
- const out = execSync(args.command, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
179
- return out || "(command succeeded, no output)"; // never return an empty string
180
- }
181
- catch (err) {
182
- const e = err; // execSync attaches the captured stderr on failure
183
- return fail(`Command failed: ${e.message}${e.stderr ? `\nstderr: ${e.stderr.slice(0, 1000)}` : ""}`); // failure becomes guidance, not a crash
184
- }
194
+ // Async + spawn: the event loop stays responsive while the command runs, so
195
+ // the spinner keeps spinning and Ctrl+C is heard. execSync would freeze
196
+ // everything — the whole process, the UI, the signal handler — until it
197
+ // returned. The AbortSignal lets the loop kill the child on interruption
198
+ // instead of waiting out the 30s timeout.
199
+ run: (args, signal) => new Promise((resolve) => {
200
+ const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics
201
+ let stdout = ""; // captured stdout
202
+ let stderr = ""; // captured stderr (for the model, not the user's terminal)
203
+ let settled = false; // resolve exactly once
204
+ const done = (text) => {
205
+ if (settled)
206
+ return; // guard against timeout/exit/abort racing
207
+ settled = true;
208
+ clearTimeout(timer); // stop the watchdog
209
+ signal?.removeEventListener("abort", onAbort); // unhook
210
+ resolve(text); // hand the result back
211
+ };
212
+ // Hard timeout: SIGKILL the whole process group, never hang the agent.
213
+ const timer = setTimeout(() => {
214
+ child.kill("SIGKILL");
215
+ done(fail(`Command timed out after ${BASH_TIMEOUT_MS / 1000}s and was killed.${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
216
+ }, BASH_TIMEOUT_MS);
217
+ // Ctrl+C while a command runs: kill the child immediately.
218
+ const onAbort = () => {
219
+ child.kill("SIGKILL");
220
+ done(fail("Command was interrupted by the user."));
221
+ };
222
+ signal?.addEventListener("abort", onAbort);
223
+ child.stdout.on("data", (d) => (stdout += d)); // accumulate
224
+ child.stderr.on("data", (d) => (stderr += d));
225
+ child.on("error", (err) => done(fail(`Could not start command: ${err.message}`))); // e.g. command not found at the spawn level
226
+ child.on("close", (code) => {
227
+ if (code === 0) {
228
+ done(spillIfHuge(stdout, "output") || "(command succeeded, no output)"); // success
229
+ }
230
+ else {
231
+ // Non-zero exit: combine streams so the model sees the actual error.
232
+ const combined = [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`].filter(Boolean).join("\n");
233
+ done(fail(`Command exited with code ${code}.\n${spillIfHuge(combined, "error output")}`));
234
+ }
235
+ });
236
+ }),
237
+ };
238
+ // ============ exit_plan_mode ============
239
+ // Plan mode (Day 20) makes the agent research-only: the permission gate blocks
240
+ // every mutating tool until the model presents a plan the user approves. The
241
+ // model calls this when its plan is ready; the gate turns it into an "ask", so
242
+ // the user sees the plan first. run() executes ONLY after the user says yes —
243
+ // so reaching this code IS the approval, and the one thing it does is leave plan
244
+ // mode. Outside plan mode the gate makes it a harmless no-op allow.
245
+ const exitPlanMode = {
246
+ definition: def("exit_plan_mode", `Leave plan mode and start implementing. Call this ONLY when you are in plan mode and have finished investigating with read-only tools.
247
+ Pass your plan as markdown in "plan"; the user is shown it and asked to approve. Do NOT call it to ask a question, and not before you have a concrete, ordered plan.
248
+ On approval, plan mode turns off and writing/executing tools become available. If declined, you stay in plan mode — refine the plan and try again.`, { plan: { type: "string", description: "The implementation plan, as concise ordered markdown steps" } }, ["plan"]),
249
+ run: () => {
250
+ setPlanMode(false); // run() only happens after the user approved — leave plan mode so implementation can begin
251
+ return "Plan approved by the user. Plan mode is OFF — implement the plan now; writing and executing tools are available again.";
185
252
  },
186
253
  };
187
254
  // ============ Registry & dispatch ============
188
- // One place that knows every tool. Adding a tool = adding one entry here.
255
+ // The built-in tools. External tools (MCP servers, Day 15) are registered into
256
+ // the same record at startup, so they flow through the same dispatch and the
257
+ // same permission gate — there is exactly one execution path, no matter where a
258
+ // tool came from.
189
259
  export const tools = {
190
260
  read_file: readFile,
191
261
  write_file: writeFile,
192
262
  edit_file: editFile,
193
263
  search,
194
264
  run_bash: runBash,
265
+ exit_plan_mode: exitPlanMode,
195
266
  };
196
- // The list of manuals sent to the model on every API call.
197
- export const toolDefinitions = Object.values(tools).map((t) => t.definition);
267
+ // Register an external tool (e.g. one discovered from an MCP server). Kept in
268
+ // the same `tools` record so dispatch, the permission gate, and the tool-list
269
+ // sent to the model all treat it identically to a built-in.
270
+ export function registerExternalTool(tool) {
271
+ tools[tool.definition.function.name] = tool;
272
+ }
273
+ // The manuals sent to the model. A function (not a const) since Day 15: MCP
274
+ // tools register after module load, so the list must be computed when asked.
275
+ export function toolDefinitions() {
276
+ return Object.values(tools).map((t) => t.definition);
277
+ }
278
+ // Read-only tools never touch the filesystem in a conflicting way, so the loop
279
+ // is free to run a batch of them concurrently. Anything that writes or executes
280
+ // (including any external/MCP tool, which we cannot reason about) runs alone, in
281
+ // order. (See loop.ts for the batching.)
282
+ const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
283
+ export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
198
284
  // Single entry point: every failure becomes text fed back to the model.
199
- // It never throws — the main loop must never die because of a tool.
200
- export function dispatch(name, argsJson) {
285
+ // It never throws — the main loop must never die because of a tool. Async since
286
+ // Day 13 so run_bash can spawn without blocking; the signal lets Ctrl+C kill a
287
+ // running command.
288
+ export async function dispatch(name, argsJson, signal) {
201
289
  const tool = tools[name]; // look the tool up by name
202
290
  if (!tool)
203
291
  return fail(`Unknown tool: ${name}. Available tools: ${Object.keys(tools).join(", ")}`); // model hallucinated a tool → list the real ones
@@ -208,8 +296,22 @@ export function dispatch(name, argsJson) {
208
296
  catch {
209
297
  return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
210
298
  }
299
+ // Validate against the tool's declared schema BEFORE running it. The model's
300
+ // arguments are probabilistic — a missing required field or a wrong type gets
301
+ // a precise repair message here instead of a cryptic crash inside the tool
302
+ // (or, for MCP tools, a wasted round-trip to the server).
303
+ const problems = validateArgs(tool.definition.function.parameters, args);
304
+ if (problems.length) {
305
+ return fail(`Invalid arguments for ${name}: ${problems.join("; ")}. Fix and call again.`);
306
+ }
307
+ // Each tool gets a result cap sized to its job. run_bash already manages its
308
+ // own output (preview + spill-to-disk via spillIfHuge); its cap must leave
309
+ // room for the spill note, or dispatch would chop off the very pointer the
310
+ // model needs to read the rest.
311
+ const cap = name === "read_file" ? READ_LIMIT + 100 : name === "run_bash" ? BASH_OUTPUT_LIMIT + SPILL_NOTE_SLACK : TOOL_RESULT_LIMIT;
211
312
  try {
212
- return tool.run(args).slice(0, name === "read_file" ? READ_LIMIT + 100 : TOOL_RESULT_LIMIT); // run it, cap the result size
313
+ const result = await tool.run(args, signal); // await covers both sync and async tools
314
+ return result.slice(0, cap); // cap the result size
213
315
  }
214
316
  catch (err) {
215
317
  return fail(`Tool crashed: ${err.message}`); // even a crashing tool becomes a readable result
package/dist/tui.js ADDED
@@ -0,0 +1,67 @@
1
+ // Terminal UI helpers that need state or the filesystem: the git branch for the
2
+ // status line, and collapsible tool output. The pure formatting (status line,
3
+ // spinner) lives in ui.ts; this is the side-effecting half.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import chalk from "chalk";
7
+ // ---- git branch for the status line -----------------------------------------
8
+ // Walk up from the cwd to the nearest .git and read HEAD. A branch name, a short
9
+ // detached SHA, or null if this isn't a repo. Never throws.
10
+ export function gitBranch() {
11
+ let dir = process.cwd();
12
+ for (let i = 0; i < 40; i++) {
13
+ try {
14
+ const gitPath = path.join(dir, ".git");
15
+ const stat = fs.statSync(gitPath);
16
+ let headFile = path.join(gitPath, "HEAD");
17
+ if (stat.isFile()) {
18
+ // A worktree: .git is a file "gitdir: <path>" pointing elsewhere.
19
+ const gitdir = fs.readFileSync(gitPath, "utf8").replace(/^gitdir:\s*/, "").trim();
20
+ headFile = path.join(path.resolve(dir, gitdir), "HEAD");
21
+ }
22
+ const head = fs.readFileSync(headFile, "utf8").trim();
23
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
24
+ return m ? m[1] : head.slice(0, 7); // branch name, or a short detached SHA
25
+ }
26
+ catch {
27
+ /* not a repo here — keep walking up */
28
+ }
29
+ const parent = path.dirname(dir);
30
+ if (parent === dir)
31
+ break; // reached the filesystem root
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+ const collapsed = [];
37
+ const MAX_COLLAPSED = 50; // bound the memory — old blocks fall off
38
+ // Print the one-line summary now; stash the full text for Tab to reveal later.
39
+ export function printToolSummary(summary, full) {
40
+ process.stdout.write(summary + "\n");
41
+ collapsed.push({ full, expanded: false });
42
+ if (collapsed.length > MAX_COLLAPSED)
43
+ collapsed.shift();
44
+ }
45
+ // Tab handler: reveal (or re-hide) the full text of the most recent tool block.
46
+ // Returns false if there's nothing to toggle.
47
+ export function toggleLastCollapsed() {
48
+ const block = collapsed[collapsed.length - 1];
49
+ if (!block)
50
+ return false;
51
+ block.expanded = !block.expanded;
52
+ if (block.expanded) {
53
+ process.stdout.write(chalk.dim(block.full.replace(/^/gm, " ⎿ ")) + "\n");
54
+ process.stdout.write(chalk.dim(" (Tab to collapse)\n"));
55
+ }
56
+ else {
57
+ process.stdout.write(chalk.dim(" (collapsed)\n"));
58
+ }
59
+ return true;
60
+ }
61
+ // ---- cleanup -----------------------------------------------------------------
62
+ // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
63
+ // of the screen), but keep the hook so callers don't have to change if that
64
+ // changes again.
65
+ export function cleanup() {
66
+ process.stdout.write("\n");
67
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,134 @@
1
+ import chalk from "chalk"; // the one place color lives
2
+ // The app's visual theme, in one place. Before this, emoji and colors were
3
+ // sprinkled across agent.ts and loop.ts — no coherent look, and impossible to
4
+ // restyle without hunting. This module owns "what the tool looks like": the
5
+ // welcome box, the prompt, the activity markers, the spinner. Still no TUI
6
+ // framework — we print line by line — just with one consistent vocabulary.
7
+ //
8
+ // Glyphs over emoji: ⏺ (an action or answer), ⎿ (a nested detail), ❯ (the
9
+ // prompt). They're width-1 in terminals (emoji like 🔧 are width-2 and break
10
+ // alignment), and they read as "a real tool" rather than "a toy".
11
+ // Draw a rounded box around plain-text lines. Width is measured on the PLAIN
12
+ // text, so callers pass plain strings; an optional per-line style wraps the
13
+ // already-padded cell (ANSI sits outside the padding, so alignment survives).
14
+ // Keep box content ASCII-ish — String.length miscounts CJK/emoji width.
15
+ function box(lines) {
16
+ const width = Math.max(...lines.map((l) => l.text.length));
17
+ const top = chalk.dim(`╭${"─".repeat(width + 2)}╮`);
18
+ const bottom = chalk.dim(`╰${"─".repeat(width + 2)}╯`);
19
+ const body = lines.map((l) => {
20
+ const cell = l.text.padEnd(width); // pad the plain text to a uniform width
21
+ return chalk.dim("│ ") + (l.style ? l.style(cell) : cell) + chalk.dim(" │"); // style wraps the padded cell
22
+ });
23
+ return [top, ...body, bottom].join("\n");
24
+ }
25
+ // The welcome banner, shown once at startup.
26
+ export function banner(version, model, host) {
27
+ return box([
28
+ { text: `mini-agent v${version}`, style: chalk.bold.cyan },
29
+ { text: `${model} · ${host}`, style: chalk.dim },
30
+ { text: "" },
31
+ { text: "/help commands @file attach a file Ctrl+C interrupt", style: chalk.dim },
32
+ ]);
33
+ }
34
+ // The input prompt. A styled chevron, not a bare ">". Plan mode (Day 20) gets a
35
+ // distinct marker so you always know writes are blocked.
36
+ export function promptString(planMode) {
37
+ return planMode ? chalk.yellow.bold("\n⏸ plan ❯ ") : chalk.cyan.bold("\n❯ ");
38
+ }
39
+ // The activity log vocabulary. ⏺ marks a thing happening, ⎿ marks a detail
40
+ // hanging off it. Indentation/nesting is the caller's job (it knows sub-agent
41
+ // depth), so these are just the styled pieces.
42
+ export const mark = {
43
+ tool: (name, args) => chalk.cyan("⏺ ") + chalk.bold(name) + (args ? chalk.dim(` ${args}`) : ""),
44
+ answer: chalk.green("⏺ "), // prefix for the model's streamed answer
45
+ denied: (reason) => chalk.red(" ⎿ ") + chalk.red(`denied — ${reason}`),
46
+ declined: chalk.yellow(" ⎿ declined"),
47
+ judge: chalk.dim(" ⎿ judge: clearly safe, auto-allowed"),
48
+ hookBlock: (event) => chalk.red(` ⎿ blocked by ${event} hook`),
49
+ subAgentStart: (desc, tier) => chalk.blue(" ⎿ sub-agent") + (tier ? chalk.dim(` [${tier}]`) : "") + chalk.blue(`: ${desc}`),
50
+ subAgentDone: chalk.blue(" ⎿ sub-agent done"),
51
+ note: (s) => chalk.dim(s), // dim asides (resumed, attached, cleared…)
52
+ warn: (s) => chalk.yellow(s),
53
+ };
54
+ // Playful "thinking" words, cycled while waiting for the first token — the same
55
+ // touch Claude Code uses so a wait feels alive, not frozen.
56
+ const WORDS = ["Thinking", "Pondering", "Cogitating", "Reasoning", "Working", "Scheming", "Brewing", "Crunching", "Mulling", "Noodling"];
57
+ // Pick a word by a seed (a call counter) rather than Math.random — deterministic,
58
+ // testable, and still varies turn to turn.
59
+ export function thinkingWord(seed) {
60
+ return WORDS[((seed % WORDS.length) + WORDS.length) % WORDS.length];
61
+ }
62
+ // Compact a token count: 1234 → "1.2k", 980 → "980".
63
+ export function formatTokens(n) {
64
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
65
+ }
66
+ // Human elapsed time: 7s / 1m32s / 1h05m.
67
+ export function formatElapsed(ms) {
68
+ const s = Math.floor(ms / 1000);
69
+ if (s < 60)
70
+ return `${s}s`;
71
+ const m = Math.floor(s / 60);
72
+ if (m < 60)
73
+ return `${m}m${String(s % 60).padStart(2, "0")}s`;
74
+ const h = Math.floor(m / 60);
75
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
76
+ }
77
+ // The live spinner text: the model (so you can SEE which one is answering — proof
78
+ // a /model switch took effect) + a thinking word + elapsed + tokens streamed so
79
+ // far + how to bail. Mirrors Claude Code's "Mulling… (1m32s · ↓ 4.0k tokens)".
80
+ export function spinnerText(word, elapsedSec, subAgent, model, tokens) {
81
+ const head = subAgent ? "sub-agent" : word;
82
+ const tag = model && !subAgent ? chalk.dim(`${model} · `) : ""; // show the model on top-level calls
83
+ const bits = [`${elapsedSec}s`];
84
+ if (tokens && tokens > 0)
85
+ bits.push(`↓ ${formatTokens(tokens)} tokens`);
86
+ bits.push("Ctrl+C to interrupt");
87
+ return `${tag}${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
88
+ }
89
+ // The persistent status line — model, project, branch, context use, spend, time.
90
+ // Pure (dir + branch passed in) so it's testable without a filesystem or git.
91
+ export function statusLine(model, dir, branch, ctxPct, costUsd, elapsedMs) {
92
+ const parts = [
93
+ chalk.cyan(`[${model}]`),
94
+ chalk.dim(`📁 ${dir}`),
95
+ branch ? chalk.green(`🌿 ${branch}`) : "",
96
+ chalk.dim(`ctx ${ctxPct}%`),
97
+ chalk.yellow(`$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`),
98
+ chalk.dim(`⏱ ${formatElapsed(elapsedMs)}`),
99
+ ].filter(Boolean);
100
+ return parts.join(chalk.dim(" · "));
101
+ }
102
+ // ---- the input area ---------------------------------------------------------
103
+ // Like Claude Code: two horizontal rules (top + bottom) with the user's input
104
+ // between them. Long input is truncated to keep the box compact.
105
+ function frameWidth() {
106
+ return Math.max(8, Math.min(process.stdout.columns || 80, 100) - 1); // fit the terminal, but cap the rule
107
+ }
108
+ export function inputRule() {
109
+ return chalk.dim("─".repeat(frameWidth())); // a plain separator line
110
+ }
111
+ // The prompt under the rule. Plan mode (Day 20) is marked so you know writes are blocked.
112
+ export function framedPrompt(planMode) {
113
+ return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
114
+ }
115
+ // ---- the selection menu -----------------------------------------------------
116
+ // Render a numbered list of options with one highlighted. The interactive part
117
+ // (raw-mode arrow keys) lives in menu.ts; this is just the pure drawing, so it's
118
+ // testable. Selecting beats typing: an approval is one keystroke, not "type y/N".
119
+ // Numbered so the user can also see "option 2" at a glance, like Claude Code.
120
+ export function renderMenu(options, selected) {
121
+ return options
122
+ .map((o, i) => {
123
+ const label = `${i + 1}. ${o}`;
124
+ return i === selected ? chalk.cyan.bold(`❯ ${label}`) : chalk.dim(` ${label}`);
125
+ })
126
+ .join("\n");
127
+ }
128
+ // The hint line under a menu — how to drive it.
129
+ export const MENU_HINT = chalk.dim("↑↓ to move · Enter to select · Esc to cancel");
130
+ // Label a model list for the picker, marking the one in use. Pure, so the "which
131
+ // is current" logic is testable without hitting the network for the list.
132
+ export function formatModelChoices(models, current) {
133
+ return models.map((m) => (m === current ? `${m} (current)` : m));
134
+ }
package/dist/undo.js ADDED
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs"; // read the "before" state and restore it
2
+ import path from "node:path"; // only for display shortening
3
+ const MAX_UNDO = 50; // remember at most this many writes — older ones drop off
4
+ const stack = []; // most recent change on top
5
+ // The session-origin baseline: for each file touched this session, what it
6
+ // looked like the FIRST time we changed it (null = it didn't exist yet). The
7
+ // undo stack is bounded and popped, so it can't answer "what changed since we
8
+ // started" — this map can. Set once per path, never overwritten, so it always
9
+ // holds the true starting point even after many edits to the same file.
10
+ const origin = new Map();
11
+ // Called BY the write/edit tools, just before they touch the disk. Captures the
12
+ // current on-disk state so it can be restored later. Never throws — recording is
13
+ // best-effort and must not break the write it is shadowing.
14
+ export function recordMutation(absPath) {
15
+ let before = null;
16
+ try {
17
+ if (fs.existsSync(absPath))
18
+ before = fs.readFileSync(absPath, "utf8"); // existing file → keep its content
19
+ }
20
+ catch {
21
+ before = null; // unreadable → treat as "nothing to restore"
22
+ }
23
+ if (!origin.has(absPath))
24
+ origin.set(absPath, before); // first touch this session = the baseline /diff compares against
25
+ stack.push({ path: absPath, before });
26
+ if (stack.length > MAX_UNDO)
27
+ stack.shift(); // bound the history (drop the oldest)
28
+ }
29
+ // How many undos are available — used by the REPL banner / message.
30
+ export function undoDepth() {
31
+ return stack.length;
32
+ }
33
+ // Forget all recorded mutations (e.g. on /clear — a fresh conversation should
34
+ // not be able to undo writes from the previous one, nor report them in /diff).
35
+ export function clearUndo() {
36
+ stack.length = 0;
37
+ origin.clear();
38
+ }
39
+ // Every file whose content differs from its session-origin baseline. Files that
40
+ // were touched but ended up back at their starting content are NOT listed —
41
+ // /diff shows the net effect of the session, not its history.
42
+ export function sessionChanges() {
43
+ const changes = [];
44
+ for (const [p, base] of origin) {
45
+ let current = null;
46
+ try {
47
+ if (fs.existsSync(p))
48
+ current = fs.readFileSync(p, "utf8"); // where the file stands now
49
+ }
50
+ catch {
51
+ current = null;
52
+ }
53
+ if ((base ?? null) === (current ?? null))
54
+ continue; // net-zero change → skip (e.g. edited then undone)
55
+ const status = base === null ? "created" : current === null ? "deleted" : "modified";
56
+ changes.push({ path: p, status, baseline: base ?? "", current: current ?? "" });
57
+ }
58
+ return changes;
59
+ }
60
+ // Undo the most recent recorded write. Returns null if there is nothing to undo.
61
+ // Restores the previous content, or deletes a file that the write had created.
62
+ export function undoLast() {
63
+ const m = stack.pop();
64
+ if (!m)
65
+ return null; // empty history
66
+ let after = null;
67
+ try {
68
+ if (fs.existsSync(m.path))
69
+ after = fs.readFileSync(m.path, "utf8"); // current state, for the diff
70
+ }
71
+ catch {
72
+ after = null;
73
+ }
74
+ const shortPath = path.relative(process.cwd(), m.path) || m.path; // nicer to read than an absolute path
75
+ try {
76
+ if (m.before === null) {
77
+ if (fs.existsSync(m.path))
78
+ fs.rmSync(m.path); // the write created this file → undo removes it
79
+ return { path: m.path, summary: `removed ${shortPath} (it had just been created)`, before: null, after };
80
+ }
81
+ fs.writeFileSync(m.path, m.before, "utf8"); // put the old content back
82
+ return { path: m.path, summary: `restored ${shortPath} to its previous content`, before: m.before, after };
83
+ }
84
+ catch (e) {
85
+ stack.push(m); // restore failed → keep the record so the user can retry
86
+ return { path: m.path, summary: `could not undo ${shortPath}: ${e.message}`, before: m.before, after };
87
+ }
88
+ }
@@ -0,0 +1,60 @@
1
+ // Structured tool-call validation. A model's tool arguments are probabilistic
2
+ // output: it will sometimes drop a required field, send a number as a string,
3
+ // or pass the wrong shape. The harness job is to constrain that output to the
4
+ // declared schema and, on mismatch, hand back a PRECISE repair instruction so
5
+ // the model fixes it on the next turn — instead of the tool crashing or doing
6
+ // the wrong thing silently.
7
+ //
8
+ // This is a deliberately small subset of JSON Schema — exactly what our tool
9
+ // definitions (and MCP servers') actually use: an object with typed properties
10
+ // and a `required` list. Not a general validator; just enough to catch the
11
+ // mistakes models really make, with messages they can act on.
12
+ // JSON Schema "type" → does this runtime value match it? Unknown/absent type
13
+ // matches anything (we don't over-constrain what the schema didn't constrain).
14
+ function typeMatches(value, type) {
15
+ switch (type) {
16
+ case undefined:
17
+ return true; // no declared type → anything goes
18
+ case "string":
19
+ return typeof value === "string";
20
+ case "number":
21
+ case "integer":
22
+ return typeof value === "number" && (type === "number" || Number.isInteger(value));
23
+ case "boolean":
24
+ return typeof value === "boolean";
25
+ case "array":
26
+ return Array.isArray(value);
27
+ case "object":
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ default:
30
+ return true; // a type we don't model — don't reject on it
31
+ }
32
+ }
33
+ // Validate parsed arguments against a schema. Returns a list of human-readable
34
+ // problems; an empty list means the arguments are valid. Pure — no I/O.
35
+ export function validateArgs(schema, args) {
36
+ if (!schema)
37
+ return []; // no schema → nothing to check
38
+ const problems = [];
39
+ // The arguments must be a plain object — every tool takes a named-arg object.
40
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
41
+ return ["arguments must be a JSON object of named parameters"];
42
+ }
43
+ const obj = args;
44
+ // Every required parameter must be present and not null/undefined.
45
+ for (const name of schema.required ?? []) {
46
+ if (obj[name] === undefined || obj[name] === null) {
47
+ problems.push(`missing required parameter "${name}"`);
48
+ }
49
+ }
50
+ // Every PRESENT parameter that has a declared type must match it. (We only
51
+ // type-check what was provided — absence is the `required` check's job.)
52
+ for (const [name, spec] of Object.entries(schema.properties ?? {})) {
53
+ if (obj[name] === undefined || obj[name] === null)
54
+ continue; // not provided — skip (required handled above)
55
+ if (!typeMatches(obj[name], spec.type)) {
56
+ problems.push(`parameter "${name}" should be ${spec.type}, got ${Array.isArray(obj[name]) ? "array" : typeof obj[name]}`);
57
+ }
58
+ }
59
+ return problems;
60
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.1.0",
4
- "description": "A Claude Code-style CLI agent built from scratch with the DeepSeek API — companion code for a 10-day tutorial",
3
+ "version": "0.27.0",
4
+ "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mini-agent": "dist/agent.js"
@@ -15,7 +15,8 @@
15
15
  "typecheck": "tsc --noEmit",
16
16
  "eval": "tsx --env-file=.env eval/run.ts",
17
17
  "build": "tsc -p tsconfig.build.json",
18
- "prepare": "npm run build"
18
+ "prepare": "npm run build",
19
+ "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts"
19
20
  },
20
21
  "engines": {
21
22
  "node": ">=20.6"