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.
@@ -0,0 +1,160 @@
1
+ // ============ Background tasks (Day 37) ============
2
+ // The problem: run_bash blocks. A command that takes longer than its 30s
3
+ // watchdog (npm install, a full test run, a build) is SIGKILLed before it
4
+ // finishes, and even a 25s one freezes the agent — we sit idle, paying for
5
+ // tokens we aren't using, while a child process churns. The model cannot think
6
+ // and wait at the same time.
7
+ //
8
+ // The fix, mirroring Claude Code: a command can be sent to the BACKGROUND. We
9
+ // spawn it, hand the model a task id immediately, and let the loop keep going.
10
+ // When the command finishes, the loop injects a <task_notification> as the next
11
+ // user turn — so the result rejoins the conversation without ever having blocked
12
+ // it. Long-running servers (npm run dev) that never "finish" are supported too:
13
+ // they stay `running` forever and the model polls them with bash_output.
14
+ //
15
+ // Why no extra thread / lock (unlike the Python reference): Node is single-
16
+ // threaded with an event loop. `spawn` is already async — stdout/stderr arrive
17
+ // as events while the main loop runs. There is no shared-memory race to guard,
18
+ // so the whole "daemon thread + mutex" dance collapses into plain callbacks.
19
+ import { spawn } from "node:child_process";
20
+ const tasks = new Map(); // id → task, for the whole session
21
+ let seq = 0; // increments per task — seeds the id
22
+ const OUTPUT_CAP = 80_000; // chars of output retained per task (keep the tail)
23
+ const SUMMARY_TAIL = 1_500; // chars of the tail shown inline in a notification
24
+ // Append output to a task, keeping only the most recent OUTPUT_CAP chars. We
25
+ // keep the TAIL, not the head: an exit error or a "server ready on :3000" line
26
+ // lands at the end, and that is what the model needs to react to. `produced`
27
+ // counts everything ever written so the poll cursor stays correct across trims.
28
+ function append(task, chunk) {
29
+ task.produced += chunk.length;
30
+ task.output += chunk;
31
+ if (task.output.length > OUTPUT_CAP)
32
+ task.output = task.output.slice(-OUTPUT_CAP);
33
+ }
34
+ // Start a command in the background. Returns its task id immediately — the
35
+ // command keeps running after this returns. Note the deliberate omissions vs
36
+ // run_bash: NO 30s timeout (the whole point is to outlive it) and NO turn
37
+ // AbortSignal (Ctrl+C interrupts the current turn, but a background job is meant
38
+ // to survive into later turns; only killAll, on session exit, stops them).
39
+ export function startBackground(command) {
40
+ const id = `bg_${++seq}`;
41
+ const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics, same as run_bash
42
+ const task = {
43
+ id,
44
+ command,
45
+ child,
46
+ status: "running",
47
+ exitCode: null,
48
+ output: "",
49
+ produced: 0,
50
+ readAt: 0,
51
+ notified: false,
52
+ startedAt: Date.now(),
53
+ endedAt: null,
54
+ };
55
+ tasks.set(id, task);
56
+ child.stdout?.on("data", (d) => append(task, d.toString())); // stream output into the buffer as it arrives
57
+ child.stderr?.on("data", (d) => append(task, d.toString()));
58
+ child.on("error", (err) => {
59
+ // The process could not even start (e.g. command not found). Record it as a
60
+ // failure with the reason, so the notification explains what happened.
61
+ append(task, `\n[could not start: ${err.message}]`);
62
+ if (task.status === "running") {
63
+ task.status = "failed";
64
+ task.exitCode = null;
65
+ task.endedAt = Date.now();
66
+ }
67
+ });
68
+ child.on("close", (code, signal) => {
69
+ if (task.status !== "running")
70
+ return; // already terminal (killAll set `killed`, or an error fired) — don't overwrite
71
+ task.exitCode = code;
72
+ task.status = code === 0 ? "completed" : "failed";
73
+ if (signal)
74
+ task.status = "killed"; // killed by a signal, not its own exit
75
+ task.endedAt = Date.now();
76
+ });
77
+ return id;
78
+ }
79
+ // Collect notifications for every task that has FINISHED since the last call.
80
+ // Each task is reported exactly once (the `notified` flag), so calling this
81
+ // every round is safe: a finished task surfaces once, then never again. Returns
82
+ // "" when there is nothing to report. The <task_notification> shape is taken
83
+ // straight from the tutorial reference so the format is consistent across the
84
+ // course.
85
+ export function pendingNotifications() {
86
+ const done = [];
87
+ for (const task of tasks.values()) {
88
+ if (task.status === "running" || task.notified)
89
+ continue; // still going, or already reported
90
+ task.notified = true; // report it once
91
+ const secs = task.endedAt ? Math.round((task.endedAt - task.startedAt) / 1000) : 0;
92
+ const tail = task.output.slice(-SUMMARY_TAIL).trim() || "(no output)";
93
+ const more = task.output.length > SUMMARY_TAIL ? " (truncated — use bash_output for the full log)" : "";
94
+ done.push(`<task_notification>\n` +
95
+ ` <task_id>${task.id}</task_id>\n` +
96
+ ` <status>${task.status}</status>\n` +
97
+ ` <command>${task.command}</command>\n` +
98
+ ` <exit_code>${task.exitCode ?? "n/a"}</exit_code>\n` +
99
+ ` <elapsed>${secs}s</elapsed>\n` +
100
+ ` <summary>${tail}${more}</summary>\n` +
101
+ `</task_notification>`);
102
+ }
103
+ if (!done.length)
104
+ return "";
105
+ // The framing line matters: without it the model can mistake the XML for
106
+ // something it must echo. Tell it plainly that a job it started has finished.
107
+ return `A background task you started has finished. Read the notification, then continue.\n\n${done.join("\n\n")}`;
108
+ }
109
+ // Read a background task's current state for the bash_output tool. Returns only
110
+ // the output PRODUCED SINCE THE LAST POLL (like Claude Code's BashOutput), so
111
+ // the model can watch a long job without re-reading the whole log each time.
112
+ // `full: true` returns the entire retained buffer instead.
113
+ export function readBackground(id, full = false) {
114
+ const task = tasks.get(id);
115
+ if (!task) {
116
+ const known = [...tasks.keys()];
117
+ return `[error] No background task "${id}". ${known.length ? `Known tasks: ${known.join(", ")}.` : "No background tasks have been started this session."}`;
118
+ }
119
+ let chunk;
120
+ if (full) {
121
+ chunk = task.output; // the entire retained buffer
122
+ }
123
+ else {
124
+ const fresh = task.produced - task.readAt; // chars produced since last poll
125
+ // Trimming may have dropped older chars; never read past what we still hold.
126
+ chunk = task.output.slice(Math.max(0, task.output.length - fresh));
127
+ }
128
+ task.readAt = task.produced; // either way, the model has now seen up to here — next poll shows only newer output
129
+ const head = `[${task.id}] ${task.status}${task.exitCode !== null ? ` (exit ${task.exitCode})` : ""} · ${task.command}`;
130
+ // Be precise about an empty body: nothing ever vs. nothing NEW since the last
131
+ // poll (the model already read it) vs. nothing new yet on a still-running job.
132
+ const body = chunk.trim() || (task.produced === 0 ? "(no output)" : task.status === "running" ? "(no new output yet)" : "(no new output)");
133
+ return `${head}\n${body}`;
134
+ }
135
+ // A one-line-per-task snapshot for the /bg command. Newest first.
136
+ export function listBackground() {
137
+ return [...tasks.values()]
138
+ .sort((a, b) => b.startedAt - a.startedAt)
139
+ .map((t) => ({
140
+ id: t.id,
141
+ status: t.status,
142
+ command: t.command,
143
+ elapsed: Math.round(((t.endedAt ?? Date.now()) - t.startedAt) / 1000),
144
+ }));
145
+ }
146
+ // True if any task is still running — the REPL uses this to warn before exit.
147
+ export function hasRunningBackground() {
148
+ return [...tasks.values()].some((t) => t.status === "running");
149
+ }
150
+ // Kill every still-running task. Called on session exit so a forgotten dev
151
+ // server or a slow install does not outlive the agent as an orphan.
152
+ export function killAllBackground() {
153
+ for (const task of tasks.values()) {
154
+ if (task.status !== "running")
155
+ continue;
156
+ task.child.kill("SIGKILL");
157
+ task.status = "killed";
158
+ task.endedAt = Date.now();
159
+ }
160
+ }
package/dist/board.js ADDED
@@ -0,0 +1,179 @@
1
+ // ============ The shared task board (Day 40) ============
2
+ // Day 38/39 gave us a team, but the LEAD still had to hand every teammate its
3
+ // work by message. That doesn't scale: with twenty tasks the lead becomes a
4
+ // dispatcher, and a teammate that finishes early sits idle while the lead is
5
+ // busy. s17's fix: a shared TASK BOARD that agents pull from THEMSELVES. The
6
+ // lead (or a teammate) drops tasks on the board; any idle teammate scans it,
7
+ // CLAIMS a ready one, does it, marks it complete, and scans again — autonomous
8
+ // work distribution with no dispatcher in the middle.
9
+ //
10
+ // Like the mailboxes, the board is ON DISK (落盘) for observability: each task is
11
+ // a .mini-agent/tasks/<id>.json file you can read while the team runs. And like
12
+ // the mailboxes, it needs NO lock despite "concurrent" claimers: claimTask is a
13
+ // synchronous read-check-write, and on one event loop a synchronous function
14
+ // runs to completion without yielding — so two teammates can never both win the
15
+ // same task. (The Python reference uses proper-lockfile because its teammates are
16
+ // OS threads; production CC adds fs.watch + atomic claim-with-busy-check.)
17
+ //
18
+ // Not to be confused with todo_write (Day 36): that is one agent's PRIVATE
19
+ // checklist; this is the team's SHARED, claimable work queue.
20
+ import fs from "node:fs"; // tasks are real files
21
+ import path from "node:path"; // path joining
22
+ let seq = 0; // increments per task — seeds the id
23
+ let wiped = false; // wipe stale board files once per process run (like team.ts)
24
+ // Resolved lazily so it tracks the cwd, exactly like the session + team dirs.
25
+ function boardRoot() {
26
+ const root = path.resolve(".mini-agent", "tasks");
27
+ fs.mkdirSync(root, { recursive: true });
28
+ if (!wiped) {
29
+ for (const f of fs.readdirSync(root))
30
+ if (f.endsWith(".json"))
31
+ fs.rmSync(path.join(root, f)); // fresh board for this run
32
+ wiped = true;
33
+ }
34
+ return root;
35
+ }
36
+ const taskPath = (id) => path.join(boardRoot(), `${id}.json`);
37
+ function saveTask(task) {
38
+ fs.writeFileSync(taskPath(task.id), JSON.stringify(task, null, 2)); // pretty, so `cat` is readable
39
+ }
40
+ export function getTask(id) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(taskPath(id), "utf8"));
43
+ }
44
+ catch {
45
+ return undefined; // missing or corrupt
46
+ }
47
+ }
48
+ // Every task on the board, oldest first (creation order = the natural reading
49
+ // order for a dependency graph).
50
+ export function listTasks() {
51
+ const root = boardRoot();
52
+ return fs
53
+ .readdirSync(root)
54
+ .filter((f) => f.endsWith(".json"))
55
+ .map((f) => {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(path.join(root, f), "utf8"));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ })
63
+ .filter((t) => t !== null)
64
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
65
+ }
66
+ // Create a task. `blockedBy` may reference ids that don't exist yet — canStart
67
+ // treats an unknown dependency as "not satisfied", so order of creation is free.
68
+ export function createTask(subject, description = "", blockedBy = []) {
69
+ const task = { id: `task_${++seq}`, subject, description, status: "pending", owner: null, blockedBy, createdAt: Date.now() };
70
+ saveTask(task);
71
+ return task;
72
+ }
73
+ // Resolve a blockedBy entry to a task. Models naturally reference a dependency
74
+ // by its SUBJECT ("alpha") rather than its id ("task_1"), so we accept either —
75
+ // id first, then a unique subject match. Returns undefined if it matches nothing
76
+ // (or an ambiguous subject), which canStart then treats as still-blocking.
77
+ export function resolveDep(dep) {
78
+ const byId = getTask(dep);
79
+ if (byId)
80
+ return byId;
81
+ const bySubject = listTasks().filter((t) => t.subject === dep);
82
+ return bySubject.length === 1 ? bySubject[0] : undefined; // ignore an ambiguous subject
83
+ }
84
+ // Can this task be started? Only if every blockedBy dependency resolves to a
85
+ // COMPLETED task. A missing/ambiguous dependency counts as blocking (fail closed).
86
+ export function canStart(id) {
87
+ const task = getTask(id);
88
+ if (!task)
89
+ return false;
90
+ return task.blockedBy.every((dep) => resolveDep(dep)?.status === "completed");
91
+ }
92
+ // Claim a task for `owner`. The critical section: read, check it's still
93
+ // pending+unowned+ready, then write owner+in_progress — all synchronous, so it
94
+ // is atomic against any other agent on this event loop. Returns ok=false (with a
95
+ // reason) if someone else got there first or a dependency isn't done.
96
+ export function claimTask(id, owner) {
97
+ const task = getTask(id);
98
+ if (!task)
99
+ return { ok: false, error: `No task "${id}".` };
100
+ if (task.status !== "pending")
101
+ return { ok: false, error: `Task ${id} is ${task.status}${task.owner ? ` (owner ${task.owner})` : ""}, not claimable.` };
102
+ if (task.owner)
103
+ return { ok: false, error: `Task ${id} is already owned by ${task.owner}.` };
104
+ if (!canStart(id))
105
+ return { ok: false, error: `Task ${id} is blocked by unfinished tasks: ${task.blockedBy.filter((d) => resolveDep(d)?.status !== "completed").join(", ")}.` };
106
+ task.owner = owner;
107
+ task.status = "in_progress";
108
+ saveTask(task);
109
+ return { ok: true, task };
110
+ }
111
+ // Complete a task and report which downstream tasks it just unblocked (so the
112
+ // claimer can mention them, and the board moves forward).
113
+ export function completeTask(id, by) {
114
+ const task = getTask(id);
115
+ if (!task)
116
+ return { ok: false, error: `No task "${id}".` };
117
+ if (task.status !== "in_progress")
118
+ return { ok: false, error: `Task ${id} is ${task.status}, not in progress.` };
119
+ if (by && task.owner && task.owner !== by)
120
+ return { ok: false, error: `Task ${id} is owned by ${task.owner}, not ${by}.` };
121
+ task.status = "completed";
122
+ saveTask(task);
123
+ // Tasks that listed this one as a dependency (by id or subject) and are now
124
+ // ready just opened up.
125
+ const unblocked = listTasks()
126
+ .filter((t) => t.status === "pending" && !t.owner && t.blockedBy.some((d) => resolveDep(d)?.id === id) && canStart(t.id))
127
+ .map((t) => t.id);
128
+ return { ok: true, unblocked };
129
+ }
130
+ // Scan for the next task this agent can take and CLAIM it, atomically. Returns
131
+ // the claimed task, or null if nothing is available right now. This is the heart
132
+ // of autonomy: an idle teammate calls this and either gets work or keeps waiting.
133
+ // Trying each candidate (rather than just the first) closes the race where two
134
+ // agents scanned the same "available" task — the loser simply tries the next.
135
+ export function claimNextAvailable(owner) {
136
+ for (const t of listTasks()) {
137
+ if (t.status !== "pending" || t.owner || !canStart(t.id))
138
+ continue;
139
+ const res = claimTask(t.id, owner);
140
+ if (res.ok)
141
+ return res.task;
142
+ }
143
+ return null;
144
+ }
145
+ // Is there pending-and-ready work nobody has claimed? Used to tell "the board is
146
+ // genuinely drained" from "a teammate is briefly between tasks".
147
+ export function hasClaimableWork() {
148
+ return listTasks().some((t) => t.status === "pending" && !t.owner && canStart(t.id));
149
+ }
150
+ // Any task not yet completed — the board still has open work (claimed or not).
151
+ export function hasOpenTasks() {
152
+ return listTasks().some((t) => t.status !== "completed");
153
+ }
154
+ // A compact, human-readable board snapshot — for the list_tasks tool result and
155
+ // the /tasks command. One line per task: status, id, owner, deps.
156
+ export function boardSummary() {
157
+ const tasks = listTasks();
158
+ if (!tasks.length)
159
+ return "(the task board is empty)";
160
+ const icon = { pending: "○", in_progress: "◐", completed: "✓" };
161
+ return tasks
162
+ .map((t) => {
163
+ const owner = t.owner ? ` @${t.owner}` : "";
164
+ const deps = t.blockedBy.length ? ` (blockedBy: ${t.blockedBy.join(", ")})` : "";
165
+ return `${icon[t.status]} ${t.id} [${t.status}${owner}] ${t.subject}${deps}`;
166
+ })
167
+ .join("\n");
168
+ }
169
+ // Wipe the board: delete every task file and reset the counter. Called on
170
+ // /clear, /resume, and when the lead disbands the team — each task starts fresh.
171
+ export function resetBoard() {
172
+ const root = path.resolve(".mini-agent", "tasks");
173
+ if (fs.existsSync(root))
174
+ for (const f of fs.readdirSync(root))
175
+ if (f.endsWith(".json"))
176
+ fs.rmSync(path.join(root, f));
177
+ seq = 0;
178
+ wiped = true; // we just cleared it; don't re-wipe on the next touch this run
179
+ }
package/dist/config.js CHANGED
@@ -52,6 +52,10 @@ export const CONFIG = {
52
52
  process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
53
53
  "", // empty = missing; requireApiKey() turns that into a helpful error
54
54
  model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
55
+ // Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
56
+ // a cheap/fast one for grunt work (reading many files, broad search), or a
57
+ // strong one as an "advisor" to double-check. undefined → use the main model.
58
+ subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
55
59
  contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
56
60
  projectSettings.contextWindow || // then files
57
61
  globalSettings.contextWindow ||
@@ -62,14 +66,44 @@ export const CONFIG = {
62
66
  allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
63
67
  deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
64
68
  },
65
- hooks: {
66
- // Hook lists also concatenate: global hooks run first, then project hooks.
67
- PreToolUse: [...(globalSettings.hooks?.PreToolUse ?? []), ...(projectSettings.hooks?.PreToolUse ?? [])],
68
- PostToolUse: [...(globalSettings.hooks?.PostToolUse ?? []), ...(projectSettings.hooks?.PostToolUse ?? [])],
69
- SessionStart: [...(globalSettings.hooks?.SessionStart ?? []), ...(projectSettings.hooks?.SessionStart ?? [])],
70
- Stop: [...(globalSettings.hooks?.Stop ?? []), ...(projectSettings.hooks?.Stop ?? [])],
69
+ // Hooks for every event, merged across layers (global first, then project) by
70
+ // a generic union so adding a new event needs no edit here.
71
+ hooks: (() => {
72
+ const merged = {};
73
+ for (const src of [globalSettings.hooks, projectSettings.hooks]) {
74
+ for (const [event, defs] of Object.entries(src ?? {}))
75
+ merged[event] = [...(merged[event] ?? []), ...(defs ?? [])];
76
+ }
77
+ return merged;
78
+ })(),
79
+ // MCP servers: project entries override global ones with the same name.
80
+ mcpServers: { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) },
81
+ // LLM permission judge: off unless a settings file turns it on. Project wins.
82
+ judge: {
83
+ enabled: projectSettings.judge?.enabled ?? globalSettings.judge?.enabled ?? false,
84
+ model: projectSettings.judge?.model || globalSettings.judge?.model || undefined, // undefined → use the main model
85
+ },
86
+ // Token prices for the /cost estimate (defaults applied in cost.ts). Project
87
+ // overrides global; either may set just the fields it cares about.
88
+ pricing: { ...(globalSettings.pricing ?? {}), ...(projectSettings.pricing ?? {}) },
89
+ // Long-term memory: auto-extract durable facts after each turn. Off by default
90
+ // — it costs an extra (cheap) model call per turn. Project overrides global.
91
+ memory: {
92
+ autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
71
93
  },
72
94
  };
95
+ // Persist a single key to the global settings file, preserving all other keys.
96
+ // Used by /model to remember the user's last choice across sessions.
97
+ export function saveGlobalSetting(key, value) {
98
+ const dir = path.dirname(GLOBAL_SETTINGS_PATH);
99
+ if (!fs.existsSync(dir))
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const current = fs.existsSync(GLOBAL_SETTINGS_PATH)
102
+ ? JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf8"))
103
+ : {};
104
+ current[key] = value;
105
+ fs.writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(current, null, 2) + "\n");
106
+ }
73
107
  // No key, no point: fail with instructions instead of a stack trace later.
74
108
  export function requireApiKey() {
75
109
  if (CONFIG.apiKey)
package/dist/cost.js ADDED
@@ -0,0 +1,93 @@
1
+ // Token & cost accounting. The most-emphasized lesson of the Harness book is
2
+ // token economics: "cache breaks are silent", "90% of the bill is re-sending
3
+ // the same prompt". You cannot manage what you cannot see — so every model
4
+ // call's usage is metered, and /cost shows tokens, the cache hit rate, and an
5
+ // estimated spend. Nothing here leaves the machine; it is just arithmetic over
6
+ // the `usage` the API already returns.
7
+ export const DEFAULT_PRICING = {
8
+ inputPerM: 0.27, // $ / 1M — check your provider; override in settings.pricing
9
+ cachedInputPerM: 0.07, // cache hits are ~4x cheaper — this is why hit rate matters
10
+ outputPerM: 1.1, // output is the pricey one — don't let the model ramble
11
+ };
12
+ // Normalize a provider `usage` object into our four fields. Handles DeepSeek
13
+ // (prompt_cache_hit_tokens / prompt_cache_miss_tokens) and OpenAI
14
+ // (prompt_tokens_details.cached_tokens) shapes; falls back gracefully.
15
+ export function normalizeUsage(usage) {
16
+ if (!usage)
17
+ return { inputUncached: 0, inputCached: 0, output: 0 };
18
+ const prompt = Number(usage.prompt_tokens ?? 0); // total input tokens
19
+ const output = Number(usage.completion_tokens ?? 0); // total output tokens
20
+ // DeepSeek reports the split directly:
21
+ const dsHit = Number(usage.prompt_cache_hit_tokens ?? NaN);
22
+ const dsMiss = Number(usage.prompt_cache_miss_tokens ?? NaN);
23
+ if (!Number.isNaN(dsHit) && !Number.isNaN(dsMiss)) {
24
+ return { inputUncached: dsMiss, inputCached: dsHit, output };
25
+ }
26
+ // OpenAI reports only the cached count, nested:
27
+ const details = usage.prompt_tokens_details;
28
+ const cached = Number(details?.cached_tokens ?? 0);
29
+ return { inputUncached: Math.max(0, prompt - cached), inputCached: cached, output };
30
+ }
31
+ // Estimated dollar cost of some usage under some pricing. Pure — easy to test.
32
+ export function costOf(u, p) {
33
+ return (u.inputUncached * p.inputPerM + u.inputCached * p.cachedInputPerM + u.output * p.outputPerM) / 1_000_000;
34
+ }
35
+ // Cache hit rate over input tokens, 0..1. The single most useful number for
36
+ // spotting a silent cache break: if it drops, you are paying full price for a
37
+ // prefix that should have been free.
38
+ export function cacheHitRate(u) {
39
+ const input = u.inputUncached + u.inputCached;
40
+ return input === 0 ? 0 : u.inputCached / input;
41
+ }
42
+ // The process-wide meter. The loop records into it from every stream; the CLI
43
+ // builds it once (with the configured prices) and /cost reads it. A module
44
+ // singleton because metering is ambient — threading it through every call would
45
+ // add noise for a cross-cutting concern. Tests construct their own CostMeter.
46
+ let sessionMeter = null;
47
+ export function initCostMeter(pricing) {
48
+ sessionMeter = new CostMeter(pricing);
49
+ return sessionMeter;
50
+ }
51
+ export function recordUsage(usage) {
52
+ sessionMeter?.record(usage); // no-op until the CLI initializes it (tests stay silent)
53
+ }
54
+ // Accumulates usage across a session and formats /cost.
55
+ export class CostMeter {
56
+ pricing;
57
+ total = { inputUncached: 0, inputCached: 0, output: 0 };
58
+ calls = 0; // how many model calls were metered
59
+ constructor(pricing) {
60
+ this.pricing = pricing;
61
+ }
62
+ // Fold one response's usage into the running total.
63
+ record(usage) {
64
+ const u = normalizeUsage(usage);
65
+ if (u.inputUncached + u.inputCached + u.output === 0)
66
+ return; // a chunk with no usage — ignore
67
+ this.total.inputUncached += u.inputUncached;
68
+ this.total.inputCached += u.inputCached;
69
+ this.total.output += u.output;
70
+ this.calls++;
71
+ }
72
+ // Numbers for the status line: estimated spend so far and total tokens.
73
+ cost() {
74
+ return costOf(this.total, this.pricing);
75
+ }
76
+ inputTokens() {
77
+ return this.total.inputUncached + this.total.inputCached;
78
+ }
79
+ outputTokens() {
80
+ return this.total.output;
81
+ }
82
+ // The /cost report. Estimated, local-only.
83
+ report() {
84
+ const t = this.total;
85
+ const input = t.inputUncached + t.inputCached;
86
+ return [
87
+ `tokens this session (${this.calls} model calls):`,
88
+ ` input: ${input} (${t.inputCached} cached, ${(cacheHitRate(t) * 100).toFixed(0)}% hit rate)`,
89
+ ` output: ${t.output}`,
90
+ ` estimated cost: $${costOf(t, this.pricing).toFixed(4)} (rough — prices set in settings.pricing)`,
91
+ ].join("\n");
92
+ }
93
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs"; // read the target file to diff against
2
+ import path from "node:path"; // resolve the file path
3
+ import chalk from "chalk"; // color the +/- lines (auto-disabled when output isn't a TTY)
4
+ // Longest-common-subsequence over lines → an ordered list of ops. Classic DP:
5
+ // lcs[i][j] = length of the LCS of a[i:] and b[j:]. Walking it forwards turns the
6
+ // table into the smallest set of removals + additions that turns a into b.
7
+ export function diffLines(oldText, newText) {
8
+ const a = oldText.length ? oldText.split("\n") : []; // empty string = no lines (not [""])
9
+ const b = newText.length ? newText.split("\n") : [];
10
+ const n = a.length;
11
+ const m = b.length;
12
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); // (n+1)×(m+1), last row/col are zeros
13
+ for (let i = n - 1; i >= 0; i--) {
14
+ for (let j = m - 1; j >= 0; j--) {
15
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); // match extends the subsequence; else take the better side
16
+ }
17
+ }
18
+ const ops = [];
19
+ let i = 0;
20
+ let j = 0;
21
+ while (i < n && j < m) {
22
+ if (a[i] === b[j])
23
+ ops.push({ kind: " ", line: a[i++] }), j++; // common line — keep it as context
24
+ else if (lcs[i + 1][j] >= lcs[i][j + 1])
25
+ ops.push({ kind: "-", line: a[i++] }); // dropping a[i] keeps more of the LCS → it was removed
26
+ else
27
+ ops.push({ kind: "+", line: b[j++] }); // otherwise b[j] is an addition
28
+ }
29
+ while (i < n)
30
+ ops.push({ kind: "-", line: a[i++] }); // leftover old lines were all removed
31
+ while (j < m)
32
+ ops.push({ kind: "+", line: b[j++] }); // leftover new lines were all added
33
+ return ops;
34
+ }
35
+ const CONTEXT = 3; // unchanged lines to keep around each change, for readability
36
+ const MAX_LINES = 60; // hard cap on rendered lines — a huge diff must not flood the prompt
37
+ // Paint one line by its kind. chalk no-ops when colors are off, so the plain
38
+ // text is always present (and assertable in tests).
39
+ function paint(kind, text) {
40
+ if (kind === "+")
41
+ return chalk.green(text);
42
+ if (kind === "-")
43
+ return chalk.red(text);
44
+ return chalk.dim(text);
45
+ }
46
+ // Render ops as a compact unified diff: a "+A -B" summary header, the changed
47
+ // lines with a few lines of context, and long runs of untouched lines collapsed
48
+ // to a "⋮ (N unchanged lines)" marker so the reader sees the change, not the file.
49
+ export function renderDiff(oldText, newText, color = true) {
50
+ const ops = diffLines(oldText, newText);
51
+ const added = ops.filter((o) => o.kind === "+").length;
52
+ const removed = ops.filter((o) => o.kind === "-").length;
53
+ // Keep any unchanged line within CONTEXT of a change; drop the rest.
54
+ const keep = new Array(ops.length).fill(false);
55
+ ops.forEach((op, idx) => {
56
+ if (op.kind === " ")
57
+ return;
58
+ for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(ops.length - 1, idx + CONTEXT); k++)
59
+ keep[k] = true;
60
+ });
61
+ const out = [];
62
+ let i = 0;
63
+ while (i < ops.length) {
64
+ if (keep[i]) {
65
+ out.push(color ? paint(ops[i].kind, `${ops[i].kind} ${ops[i].line}`) : `${ops[i].kind} ${ops[i].line}`);
66
+ i++;
67
+ }
68
+ else {
69
+ let skipped = 0;
70
+ while (i < ops.length && !keep[i])
71
+ skipped++, i++; // collapse the whole untouched run at once
72
+ const note = ` ⋮ (${skipped} unchanged line${skipped === 1 ? "" : "s"})`;
73
+ out.push(color ? chalk.dim(note) : note);
74
+ }
75
+ }
76
+ const body = out.length > MAX_LINES ? [...out.slice(0, MAX_LINES), ` … (${out.length - MAX_LINES} more diff lines)`] : out;
77
+ const header = ` +${added} -${removed}`;
78
+ return [color ? chalk.dim(header) : header, ...body].join("\n");
79
+ }
80
+ const MAX_PREVIEW_BYTES = 200_000; // don't read+diff an enormous file just to preview it
81
+ // Read a file for diffing, or null if it shouldn't be diffed (missing, too big,
82
+ // or binary). A preview must never break a tool call, so every failure is null.
83
+ function safeRead(p) {
84
+ try {
85
+ if (fs.statSync(p).size > MAX_PREVIEW_BYTES)
86
+ return null; // too big to preview cheaply
87
+ const text = fs.readFileSync(p, "utf8");
88
+ return text.includes("\0") ? null : text; // a NUL byte means binary — don't diff it
89
+ }
90
+ catch {
91
+ return null; // unreadable / missing
92
+ }
93
+ }
94
+ // Build the diff preview shown before a write/edit runs. Returns null when there
95
+ // is nothing useful to show (not a write tool, unparseable args, binary file).
96
+ // NEVER throws.
97
+ export function previewChange(toolName, argsJson) {
98
+ let args;
99
+ try {
100
+ args = JSON.parse(argsJson);
101
+ }
102
+ catch {
103
+ return null; // dispatch will report the JSON error; nothing to preview
104
+ }
105
+ try {
106
+ if (toolName === "write_file") {
107
+ const p = path.resolve(args.path ?? "");
108
+ const exists = fs.existsSync(p);
109
+ const oldText = exists ? safeRead(p) : ""; // new file → empty "before"
110
+ if (oldText === null)
111
+ return null; // existing but binary/oversized
112
+ return ` ${exists ? "overwrite" : "create"} ${args.path}\n${renderDiff(oldText, String(args.content ?? ""))}`;
113
+ }
114
+ if (toolName === "edit_file") {
115
+ const p = path.resolve(args.path ?? "");
116
+ if (!fs.existsSync(p))
117
+ return null; // edit of a missing file → the tool will error
118
+ const oldText = safeRead(p);
119
+ if (oldText === null)
120
+ return null;
121
+ const occurrences = args.old_string ? oldText.split(args.old_string).length - 1 : 0;
122
+ // The tool only applies a unique match. If it's unique, diff the real
123
+ // before/after; otherwise just show the intended swap (the tool will
124
+ // reject a 0/ambiguous match anyway, but the human still sees the intent).
125
+ const newText = occurrences === 1 ? oldText.split(args.old_string).join(args.new_string ?? "") : null;
126
+ return newText !== null
127
+ ? ` edit ${args.path}\n${renderDiff(oldText, newText)}`
128
+ : ` edit ${args.path}\n${renderDiff(String(args.old_string ?? ""), String(args.new_string ?? ""))}`;
129
+ }
130
+ }
131
+ catch {
132
+ return null; // anything unexpected → no preview, never a crash
133
+ }
134
+ return null; // not a file-changing tool
135
+ }