agent-from-zero 0.5.0 → 0.28.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/ui.js ADDED
@@ -0,0 +1,138 @@
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 Esc interrupt Ctrl+C quit", 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
+ bgNote: (n) => chalk.magenta(" ⎿ ") + chalk.magenta(`${n} background task${n === 1 ? "" : "s"} finished — notified the agent`), // Day 37
52
+ teammateStart: (name, role) => chalk.magenta(" ⎿ teammate ") + chalk.bold(name) + chalk.magenta(` spawned`) + chalk.dim(` — ${role}`), // Day 38
53
+ teammateDone: (name, ok) => (ok ? chalk.magenta(" ⎿ teammate ") + chalk.bold(name) + chalk.magenta(" finished") : chalk.yellow(" ⎿ teammate ") + chalk.bold(name) + chalk.yellow(" ended (not done)")),
54
+ inbox: (n) => chalk.magenta(" ⎿ ") + chalk.magenta(`${n} team message${n === 1 ? "" : "s"} received`),
55
+ note: (s) => chalk.dim(s), // dim asides (resumed, attached, cleared…)
56
+ warn: (s) => chalk.yellow(s),
57
+ };
58
+ // Playful "thinking" words, cycled while waiting for the first token — the same
59
+ // touch Claude Code uses so a wait feels alive, not frozen.
60
+ const WORDS = ["Thinking", "Pondering", "Cogitating", "Reasoning", "Working", "Scheming", "Brewing", "Crunching", "Mulling", "Noodling"];
61
+ // Pick a word by a seed (a call counter) rather than Math.random — deterministic,
62
+ // testable, and still varies turn to turn.
63
+ export function thinkingWord(seed) {
64
+ return WORDS[((seed % WORDS.length) + WORDS.length) % WORDS.length];
65
+ }
66
+ // Compact a token count: 1234 → "1.2k", 980 → "980".
67
+ export function formatTokens(n) {
68
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
69
+ }
70
+ // Human elapsed time: 7s / 1m32s / 1h05m.
71
+ export function formatElapsed(ms) {
72
+ const s = Math.floor(ms / 1000);
73
+ if (s < 60)
74
+ return `${s}s`;
75
+ const m = Math.floor(s / 60);
76
+ if (m < 60)
77
+ return `${m}m${String(s % 60).padStart(2, "0")}s`;
78
+ const h = Math.floor(m / 60);
79
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
80
+ }
81
+ // The live spinner text: the model (so you can SEE which one is answering — proof
82
+ // a /model switch took effect) + a thinking word + elapsed + tokens streamed so
83
+ // far + how to bail. Mirrors Claude Code's "Mulling… (1m32s · ↓ 4.0k tokens)".
84
+ export function spinnerText(word, elapsedSec, subAgent, model, tokens) {
85
+ const head = subAgent ? "sub-agent" : word;
86
+ const tag = model && !subAgent ? chalk.dim(`${model} · `) : ""; // show the model on top-level calls
87
+ const bits = [`${elapsedSec}s`];
88
+ if (tokens && tokens > 0)
89
+ bits.push(`↓ ${formatTokens(tokens)} tokens`);
90
+ bits.push("Ctrl+C to interrupt");
91
+ return `${tag}${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
92
+ }
93
+ // The persistent status line — model, project, branch, context use, spend, time.
94
+ // Pure (dir + branch passed in) so it's testable without a filesystem or git.
95
+ export function statusLine(model, dir, branch, ctxPct, costUsd, elapsedMs) {
96
+ const parts = [
97
+ chalk.cyan(`[${model}]`),
98
+ chalk.dim(`📁 ${dir}`),
99
+ branch ? chalk.green(`🌿 ${branch}`) : "",
100
+ chalk.dim(`ctx ${ctxPct}%`),
101
+ chalk.yellow(`$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`),
102
+ chalk.dim(`⏱ ${formatElapsed(elapsedMs)}`),
103
+ ].filter(Boolean);
104
+ return parts.join(chalk.dim(" · "));
105
+ }
106
+ // ---- the input area ---------------------------------------------------------
107
+ // Like Claude Code: two horizontal rules (top + bottom) with the user's input
108
+ // between them. Long input is truncated to keep the box compact.
109
+ function frameWidth() {
110
+ return Math.max(8, Math.min(process.stdout.columns || 80, 100) - 1); // fit the terminal, but cap the rule
111
+ }
112
+ export function inputRule() {
113
+ return chalk.dim("─".repeat(frameWidth())); // a plain separator line
114
+ }
115
+ // The prompt under the rule. Plan mode (Day 20) is marked so you know writes are blocked.
116
+ export function framedPrompt(planMode) {
117
+ return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
118
+ }
119
+ // ---- the selection menu -----------------------------------------------------
120
+ // Render a numbered list of options with one highlighted. The interactive part
121
+ // (raw-mode arrow keys) lives in menu.ts; this is just the pure drawing, so it's
122
+ // testable. Selecting beats typing: an approval is one keystroke, not "type y/N".
123
+ // Numbered so the user can also see "option 2" at a glance, like Claude Code.
124
+ export function renderMenu(options, selected) {
125
+ return options
126
+ .map((o, i) => {
127
+ const label = `${i + 1}. ${o}`;
128
+ return i === selected ? chalk.cyan.bold(`❯ ${label}`) : chalk.dim(` ${label}`);
129
+ })
130
+ .join("\n");
131
+ }
132
+ // The hint line under a menu — how to drive it.
133
+ export const MENU_HINT = chalk.dim("↑↓ to move · Enter to select · Esc to cancel");
134
+ // Label a model list for the picker, marking the one in use. Pure, so the "which
135
+ // is current" logic is testable without hitting the network for the list.
136
+ export function formatModelChoices(models, current) {
137
+ return models.map((m) => (m === current ? `${m} (current)` : m));
138
+ }
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,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.5.0",
3
+ "version": "0.28.0",
4
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": {
@@ -16,7 +16,7 @@
16
16
  "eval": "tsx --env-file=.env eval/run.ts",
17
17
  "build": "tsc -p tsconfig.build.json",
18
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"
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 && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20.6"
@@ -24,8 +24,12 @@
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
26
  "chalk": "^5.6.2",
27
+ "cli-table3": "^0.6.5",
28
+ "marked": "^15.0.12",
29
+ "marked-terminal": "^7.3.0",
27
30
  "openai": "^6.42.0",
28
- "ora": "^9.4.0"
31
+ "ora": "^9.4.0",
32
+ "wrap-ansi": "^10.0.0"
29
33
  },
30
34
  "devDependencies": {
31
35
  "@types/node": "^25.9.2",