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/agent.js CHANGED
@@ -2,32 +2,208 @@
2
2
  import OpenAI from "openai"; // the API client
3
3
  import chalk from "chalk"; // terminal colors
4
4
  import readline from "node:readline/promises"; // promise-based terminal input
5
- import { CONFIG, requireApiKey } from "./config.js"; // provider-agnostic settings (.env loaded there)
6
- import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
5
+ import { createRequire } from "node:module"; // to read package.json for --version
6
+ import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
7
+ import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
7
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
8
- import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
9
- requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
10
- // Build the API client once, for the whole session.
11
- const client = new OpenAI({
12
- baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via MINI_AGENT_BASE_URL
13
- apiKey: CONFIG.apiKey, // from .env or the shell
14
- maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
15
- });
9
+ import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
+ import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
+ import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
12
+ import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
13
+ import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
14
+ import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
15
+ import { Judge } from "./judge.js"; // optional LLM permission classifier
16
+ import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
17
+ import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
18
+ import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
19
+ import path from "node:path"; // shorten paths for the /diff summary
20
+ import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
21
+ import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
22
+ import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
23
+ import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
24
+ import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
25
+ import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
26
+ import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
27
+ // package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
28
+ const pkg = createRequire(import.meta.url)("../package.json");
29
+ // ---- CLI arguments --------------------------------------------------------------
30
+ // Tiny by design: a help text, a version, and a one-shot print mode. Anything
31
+ // fancier belongs in settings files, not flags.
32
+ const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
33
+
34
+ Usage:
35
+ mini-agent interactive session (REPL)
36
+ mini-agent -r | --resume continue the most recent session in this directory
37
+ mini-agent -p "<task>" one-shot: run a single task, print the result, exit
38
+ mini-agent -v | --version print the version
39
+ mini-agent -h | --help this text
40
+
41
+ Configuration (optional):
42
+ ${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
43
+ ${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
44
+ MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override everything.
45
+
46
+ In a session, type /help for the in-session commands.`;
16
47
  // What we tell the user for every way a query can end. No raw stack traces.
17
48
  const EXIT_NOTES = {
18
- [TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
19
49
  [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
20
50
  [TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
21
- [TerminateReason.RateLimitBudgetExhausted]: "DeepSeek keeps rate-limiting us. Wait a minute, then try again.",
51
+ [TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
22
52
  [TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
23
53
  [TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
24
54
  [TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
25
55
  [TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
26
56
  };
57
+ // The in-session command reference, shown by /help.
58
+ const SESSION_HELP = `commands:
59
+ /help this text
60
+ /clear wipe the conversation (and the file read-state) — fresh start
61
+ /compact compact the history into a summary right now (happens automatically near the limit)
62
+ /model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
63
+ /stats event counts for this session (local telemetry — nothing leaves this machine)
64
+ /memory show the durable facts the agent remembers about this project
65
+ /cost tokens, cache hit rate and estimated spend this session (local)
66
+ /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
67
+ /undo revert the most recent file write (write_file / edit_file) this session
68
+ /diff show every file changed this session, as a diff from where it started
69
+ /resume list recent sessions in this project and continue one of them
70
+ /skills list the reusable skills available in this project
71
+ /skill <name> run a skill yourself (works even for user-only skills)
72
+ exit leave (Ctrl+C at the prompt does the same)`;
73
+ // Injected when the user turns plan mode on, so the model knows the rules of the
74
+ // mode it now lives in (the permission gate enforces them regardless).
75
+ const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
27
76
  async function main() {
28
- // The banner: who am I, which model and host am I on, and the three things worth knowing.
29
- console.log(chalk.bold("mini-agent") +
30
- chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | "exit" to quit · "/clear" to reset · Ctrl+C interrupts a running task`));
77
+ // ---- Flag handling, before anything touches the network -----------------------
78
+ const argv = process.argv.slice(2); // everything after "mini-agent"
79
+ if (argv.includes("-v") || argv.includes("--version")) {
80
+ console.log(pkg.version); // just the number — script-friendly
81
+ return;
82
+ }
83
+ if (argv.includes("-h") || argv.includes("--help")) {
84
+ console.log(USAGE); // the full help text
85
+ return;
86
+ }
87
+ // Print mode: -p / --print takes the task from the remaining arguments.
88
+ const pIdx = argv.findIndex((a) => a === "-p" || a === "--print"); // where the flag sits
89
+ const printTask = pIdx >= 0 ? argv.slice(pIdx + 1).join(" ").trim() : null; // everything after it is the task
90
+ if (pIdx >= 0 && !printTask) {
91
+ console.error('Usage: mini-agent -p "<task>"'); // -p without a task is a usage error
92
+ process.exitCode = 2;
93
+ return;
94
+ }
95
+ requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
96
+ // Build the API client once, for the whole session.
97
+ const client = new OpenAI({
98
+ baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via config
99
+ apiKey: CONFIG.apiKey, // from .env or the shell
100
+ maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
101
+ });
102
+ // The system message is built ONCE per session: stable prefix = cache hits
103
+ // on every request. Nothing session-specific may sneak into it later.
104
+ const systemMessage = buildSystemMessage();
105
+ let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
106
+ // ---- Session persistence ---------------------------------------------------------
107
+ // Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
108
+ // -r / --resume picks up the most recent one. The system message is NOT
109
+ // restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
110
+ let sessionId = newSessionId(); // this session's identity (and file name)
111
+ const sessionStartedAt = Date.now(); // for the status line's elapsed clock
112
+ if (argv.includes("-r") || argv.includes("--resume")) {
113
+ const prev = latestSession(); // newest snapshot in this directory, if any
114
+ if (prev) {
115
+ messages.push(...prev.messages); // the old conversation joins the fresh constitution
116
+ sessionId = prev.id; // keep appending to the same session file
117
+ console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
118
+ }
119
+ else {
120
+ console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
121
+ }
122
+ }
123
+ initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
124
+ emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
125
+ // Connect to any configured MCP servers and register their tools. This must
126
+ // finish before the first model call so the tools appear in the manual. A
127
+ // server that fails to start is skipped, never fatal.
128
+ const disconnectMcp = await connectMcpServers();
129
+ process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
130
+ // The optional LLM permission judge, built once if a settings file enabled it.
131
+ const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
132
+ if (judge)
133
+ console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
134
+ // The remember tool: let the model save durable facts to long-term memory.
135
+ // Registered like any tool, so it flows through the same permission gate.
136
+ registerExternalTool(rememberTool);
137
+ const memCount = readMemory().length;
138
+ if (memCount)
139
+ console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
140
+ // Skills: reusable Markdown procedures. The model-invocable ones are exposed
141
+ // through a single `skill` tool whose description lists them (progressive
142
+ // disclosure); user-only skills are reachable via /skill <name>.
143
+ const skills = loadSkills();
144
+ if (skills.some((s) => !s.disableModelInvocation))
145
+ registerExternalTool(buildSkillTool(skills));
146
+ if (skills.length)
147
+ console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
148
+ // Token/cost meter for this session. Prices come from settings, falling back
149
+ // to the defaults; the loop records usage into it from every stream.
150
+ const costMeter = initCostMeter({
151
+ inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
152
+ cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
153
+ outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
154
+ });
155
+ // SessionStart hooks run once, here. Their stdout is injected as context the
156
+ // model sees on its first turn — e.g. inject the current git branch, an
157
+ // on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
158
+ // is no tool to block, only context to add.)
159
+ const sessionStart = await runHooks("SessionStart", {});
160
+ if (sessionStart.stdout)
161
+ messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
162
+ // Per-task interrupt state. `running` decides what Ctrl+C means right now.
163
+ let running = false; // is a task currently executing?
164
+ let interrupted = false; // has the current task been interrupted?
165
+ let controller = new AbortController(); // aborts the in-flight API request
166
+ // ---- One-shot print mode -------------------------------------------------------
167
+ // The same loop, the same permission gate (fail closed without a TTY), no REPL.
168
+ // Designed for scripts: the exit code distinguishes success from every failure.
169
+ if (printTask !== null) {
170
+ process.on("SIGINT", () => {
171
+ if (interrupted)
172
+ process.exit(130); // second press: force quit
173
+ interrupted = true; // first press: wind down
174
+ controller.abort(); // cancel the in-flight request
175
+ });
176
+ messages.push({ role: "user", content: printTask }); // the single task
177
+ const result = await runLoop(messages, {
178
+ client,
179
+ model: CONFIG.model,
180
+ signal: controller.signal,
181
+ isInterrupted: () => interrupted,
182
+ subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
183
+ judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
184
+ // Print mode never prompts: unattended means nobody can say yes.
185
+ // MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
186
+ // were enforced in permissions.ts long before this runs.
187
+ confirm: async (q) => {
188
+ if (process.env.MINI_AGENT_AUTO_APPROVE === "1")
189
+ return true; // explicit bypass
190
+ console.error(chalk.dim(`[print mode] declined (no prompt available): ${q.split("\n")[0]}`)); // visible in stderr, not stdout
191
+ return false; // fail closed
192
+ },
193
+ });
194
+ saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
195
+ emit("agent_session_end"); // close the books
196
+ if (result.reason !== TerminateReason.Done) {
197
+ console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
198
+ if (result.detail)
199
+ console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
200
+ process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
201
+ }
202
+ return; // the answer itself already streamed to stdout
203
+ }
204
+ // ---- Interactive session (REPL) -------------------------------------------------
205
+ // The welcome box: who am I, which model and host am I on, and the basics.
206
+ console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
31
207
  // ONE readline interface for the whole session — the task prompt and the
32
208
  // permission prompts share it. Two interfaces on one stdin fight each other.
33
209
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -61,14 +237,6 @@ async function main() {
61
237
  rl.prompt();
62
238
  return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
63
239
  };
64
- // The system message is built ONCE per session: stable prefix = cache hits
65
- // on every request. Nothing session-specific may sneak into it later.
66
- const systemMessage = buildSystemMessage();
67
- let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
68
- // Per-task interrupt state. `running` decides what Ctrl+C means right now.
69
- let running = false; // is a task currently executing?
70
- let interrupted = false; // has the current task been interrupted?
71
- let controller = new AbortController(); // aborts the in-flight API request
72
240
  // Ctrl+C semantics, by state:
73
241
  // - at the prompt: exit the session (standard REPL behavior)
74
242
  // - while running: first press interrupts the task, second press force-quits
@@ -89,8 +257,8 @@ async function main() {
89
257
  // Fail closed: in a non-interactive session nobody can say yes, so the
90
258
  // answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
91
259
  // denies were already enforced in permissions.ts before we get here.
92
- const confirm = async (question) => {
93
- console.log(chalk.yellow(`\n⚠️ approval needed — ${question}`)); // always show what is being asked
260
+ const confirm = async (question, toolName) => {
261
+ console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
94
262
  if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
95
263
  console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
96
264
  return true; // approve without asking
@@ -99,24 +267,302 @@ async function main() {
99
267
  console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
100
268
  return false; // fail closed
101
269
  }
102
- const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
103
- return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
270
+ // Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
271
+ // option lets the user stop being asked about this tool for the session.
272
+ const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
273
+ const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
274
+ const approved = choice === 0 || choice === 1;
275
+ if (choice === 1 && toolName) {
276
+ CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
277
+ console.log(chalk.dim(` won't ask again for ${toolName} this session`));
278
+ }
279
+ console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
280
+ return approved;
281
+ };
282
+ // The model's handle on the user: present a multi-question form and return the
283
+ // selections. Null in a non-interactive session — the model is told to ask in
284
+ // plain text instead.
285
+ const askUser = async (questions) => {
286
+ if (!process.stdin.isTTY)
287
+ return null;
288
+ return promptForm(rl, questions);
289
+ };
290
+ // Ask the endpoint what models it serves (the OpenAI-compatible /models API).
291
+ // Empty list on any failure — not every endpoint implements it, and a failed
292
+ // listing should never break the command.
293
+ const listModels = async () => {
294
+ try {
295
+ const page = await client.models.list();
296
+ const ids = (page.data ?? []).map((m) => m.id).filter(Boolean);
297
+ return [...new Set(ids)].sort();
298
+ }
299
+ catch {
300
+ return [];
301
+ }
302
+ };
303
+ // /model — show the current model, or switch. `/model <name>` sets it directly;
304
+ // bare `/model` lists the endpoint's models and lets you pick one (Day 29 menu).
305
+ // The switch takes effect next turn: the loop reads CONFIG.model each turn.
306
+ const handleModelCommand = async (arg) => {
307
+ // Switching is SESSION-ONLY by default — a quick "/model <name>" to try a
308
+ // model must never permanently change your default. "/model save <name>"
309
+ // (or save after picking) writes it to the global settings file.
310
+ const save = arg === "save" || arg.startsWith("save ");
311
+ const name = save ? arg.slice(4).trim() : arg;
312
+ const apply = (next) => {
313
+ const prev = CONFIG.model;
314
+ CONFIG.model = next; // any string — the API errors on an unknown one, and the error layer reports it
315
+ if (save) {
316
+ saveGlobalSetting("model", next); // persist as the new default
317
+ console.log(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
318
+ }
319
+ else {
320
+ console.log(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
321
+ }
322
+ };
323
+ if (name) {
324
+ apply(name);
325
+ return;
326
+ }
327
+ // No name: show the current model and a picker.
328
+ console.log(chalk.dim(`model: ${CONFIG.model}\n` +
329
+ (CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
330
+ `endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
331
+ if (!process.stdin.isTTY)
332
+ return; // no picker without a TTY — switch with: /model <name>
333
+ const models = await listModels();
334
+ if (!models.length) {
335
+ console.log(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
336
+ return;
337
+ }
338
+ console.log(chalk.dim("switch model (this session):"));
339
+ const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
340
+ if (choice < 0 || models[choice] === CONFIG.model) {
341
+ console.log(chalk.dim("(model unchanged)"));
342
+ return;
343
+ }
344
+ apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
345
+ };
346
+ // Handle a /slash command. Returns true if the line was a command.
347
+ const handleCommand = async (line) => {
348
+ // /model takes an optional argument (/model <name>), so it can't be a plain
349
+ // switch case — handle it before the exact-match switch.
350
+ if (line === "/model" || line.startsWith("/model ")) {
351
+ await handleModelCommand(line.slice("/model".length).trim());
352
+ return true;
353
+ }
354
+ // /skill <name> runs a skill on the user's behalf — inject its instructions
355
+ // as a turn so the model follows them. Works for user-only skills too.
356
+ if (line === "/skill" || line.startsWith("/skill ")) {
357
+ const name = line.slice("/skill".length).trim();
358
+ if (!name) {
359
+ console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
360
+ return true;
361
+ }
362
+ const s = findSkill(skills, name);
363
+ if (!s) {
364
+ console.log(chalk.yellow(`(no skill named "${name}")`));
365
+ return true;
366
+ }
367
+ messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
368
+ console.log(chalk.dim(`(running skill: ${s.name})`));
369
+ running = true;
370
+ interrupted = false;
371
+ controller = new AbortController();
372
+ const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
373
+ running = false;
374
+ saveSession(sessionId, CONFIG.model, messages);
375
+ if (r.reason !== TerminateReason.Done)
376
+ console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
377
+ return true;
378
+ }
379
+ switch (line) {
380
+ case "/skills": {
381
+ if (!skills.length) {
382
+ console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
383
+ return true;
384
+ }
385
+ console.log(chalk.dim(`skills in this project:`));
386
+ for (const s of skills) {
387
+ const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
388
+ console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
389
+ }
390
+ return true;
391
+ }
392
+ case "/help":
393
+ console.log(chalk.dim(SESSION_HELP)); // the command reference
394
+ return true;
395
+ case "/clear":
396
+ messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
397
+ forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
398
+ clearUndo(); // a fresh conversation should not undo the previous one's writes
399
+ sessionId = newSessionId(); // a fresh conversation is a fresh session file
400
+ console.log(chalk.dim("(history cleared)")); // confirm the reset
401
+ return true;
402
+ case "/stats":
403
+ console.log(chalk.dim(statsReport())); // local counters, busiest first
404
+ return true;
405
+ case "/memory": {
406
+ const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
407
+ console.log(chalk.dim(entries.length
408
+ ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
409
+ : `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`));
410
+ return true;
411
+ }
412
+ case "/cost":
413
+ console.log(chalk.dim(costMeter.report())); // tokens, cache hit rate, estimated spend (local)
414
+ return true;
415
+ case "/undo": {
416
+ // Revert the most recent write. This is a USER action on the filesystem,
417
+ // not a turn for the model — the conversation is untouched. If the model
418
+ // later edits a file it thinks it changed, edit_file's read-before-edit +
419
+ // exact-match guards will catch the staleness and ask it to re-read.
420
+ const undone = undoLast();
421
+ if (!undone) {
422
+ console.log(chalk.dim("(nothing to undo — no file writes recorded this session)"));
423
+ return true;
424
+ }
425
+ console.log(chalk.dim(`↩ ${undone.summary}`));
426
+ // Show the diff of what was put back (from the current state to the
427
+ // restored one), reusing the Day 21 renderer.
428
+ console.log(renderDiff(undone.after ?? "", undone.before ?? ""));
429
+ return true;
430
+ }
431
+ case "/resume": {
432
+ // List recent sessions in this project and switch to the chosen one.
433
+ // Swapping mid-conversation is fine: we rebuild from the constitution +
434
+ // the saved messages, and reset the per-conversation state (read-state,
435
+ // undo history) so the resumed session behaves like a fresh start of it.
436
+ const sessions = listSessions(10);
437
+ if (!sessions.length) {
438
+ console.log(chalk.dim("(no saved sessions in this project yet)"));
439
+ return true;
440
+ }
441
+ console.log(chalk.dim("recent sessions in this project:"));
442
+ sessions.forEach((s, i) => {
443
+ const when = s.savedAt.slice(0, 16).replace("T", " "); // 2026-06-15 10:30
444
+ console.log(chalk.dim(` ${i + 1}. ${when} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`));
445
+ });
446
+ const answer = (await readUserLine(" resume which? [number, or Enter to cancel] ")).trim();
447
+ if (!answer) {
448
+ console.log(chalk.dim("(cancelled)"));
449
+ return true;
450
+ }
451
+ const idx = Number(answer) - 1;
452
+ if (!Number.isInteger(idx) || idx < 0 || idx >= sessions.length) {
453
+ console.log(chalk.dim("(not a valid choice — cancelled)"));
454
+ return true;
455
+ }
456
+ const chosen = loadSession(sessions[idx].id);
457
+ if (!chosen) {
458
+ console.log(chalk.yellow("(could not load that session — it may be corrupt)"));
459
+ return true;
460
+ }
461
+ messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
462
+ sessionId = chosen.id; // keep appending to the resumed session's file
463
+ forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
464
+ clearUndo(); // the previous session's writes are not ours to undo
465
+ console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
466
+ return true;
467
+ }
468
+ case "/diff": {
469
+ // A read-only review of the net effect of this session on the
470
+ // filesystem — every file whose content differs from where it started,
471
+ // shown as a diff from baseline → now. Reuses the Day 21 renderer.
472
+ const changes = sessionChanges();
473
+ if (!changes.length) {
474
+ console.log(chalk.dim("(no file changes this session)"));
475
+ return true;
476
+ }
477
+ const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
478
+ console.log(chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`));
479
+ for (const c of changes) {
480
+ const short = path.relative(process.cwd(), c.path) || c.path;
481
+ console.log(`\n${verb[c.status]} ${short}`);
482
+ console.log(renderDiff(c.baseline, c.current));
483
+ }
484
+ return true;
485
+ }
486
+ case "/plan":
487
+ // A toggle. Turning it on injects the rules of the mode as a user turn so
488
+ // the model adopts them; the gate (permissions.ts) enforces them either way.
489
+ if (isPlanMode()) {
490
+ setPlanMode(false);
491
+ console.log(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
492
+ }
493
+ else {
494
+ setPlanMode(true);
495
+ messages.push({ role: "user", content: PLAN_MODE_NOTICE });
496
+ console.log(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
497
+ }
498
+ return true;
499
+ case "/compact": {
500
+ if (messages.length <= 1) {
501
+ console.log(chalk.dim("(nothing to compact yet)")); // only the system message so far
502
+ return true;
503
+ }
504
+ try {
505
+ await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
506
+ }
507
+ catch (err) {
508
+ console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
509
+ }
510
+ return true;
511
+ }
512
+ default:
513
+ if (line.startsWith("/")) {
514
+ console.log(chalk.dim(`unknown command: ${line} — try /help`)); // typo guard
515
+ return true; // still consumed — don't send typos to the model
516
+ }
517
+ return false; // a normal task line
518
+ }
104
519
  };
105
520
  // The session loop: ask → run → render → ask again. This is what makes it
106
521
  // a conversation instead of a one-shot command.
522
+ // Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
523
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
524
+ process.stdin.on("data", (buf) => {
525
+ if (buf[0] === 0x09) { // Tab
526
+ if (!running)
527
+ toggleLastCollapsed();
528
+ }
529
+ });
530
+ }
107
531
  while (true) {
108
- const line = (await readUserLine(chalk.green("\n> "))).trim(); // next input, or "exit" on EOF
532
+ // Before each prompt, a status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
533
+ // then a separator rule, then the "❯". Only in a TTY; piped input is clean.
534
+ if (process.stdin.isTTY) {
535
+ const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
536
+ console.log("\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt));
537
+ console.log(inputRule());
538
+ }
539
+ const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
109
540
  if (!line)
110
541
  continue; // empty line — just re-prompt
111
542
  if (line === "exit" || line === "quit")
112
543
  break; // explicit goodbye
113
- if (line === "/clear") {
114
- messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
115
- forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
116
- console.log(chalk.dim("(history cleared)")); // confirm the reset
117
- continue;
544
+ if (await handleCommand(line))
545
+ continue; // slash commands never reach the model
546
+ // UserPromptSubmit hook: a chance to validate/inject before the model sees
547
+ // the prompt. exit 2 DROPS the prompt entirely (e.g. block a forbidden ask);
548
+ // stdout becomes extra context for this turn (e.g. inject the current ticket).
549
+ const submit = await runHooks("UserPromptSubmit", { prompt: line });
550
+ if (submit.block) {
551
+ console.log(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${submit.feedback.slice(0, 150)})`));
552
+ continue; // the prompt is erased — never reaches the model
118
553
  }
119
- messages.push({ role: "user", content: line }); // the new turn joins the shared history
554
+ const injected = submit.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${submit.stdout}` : "";
555
+ // @file mentions: if the line references files (@src/loop.ts), attach their
556
+ // content to the message so the model sees them directly. Secret files are
557
+ // refused here, exactly as read_file would refuse them.
558
+ const { augmented, mentions } = expandMentions(line);
559
+ const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
560
+ const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
561
+ if (attached.length)
562
+ console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
563
+ if (refused.length)
564
+ console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
565
+ messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
120
566
  running = true; // Ctrl+C now means "interrupt the task"
121
567
  interrupted = false; // fresh interrupt state for this task
122
568
  controller = new AbortController(); // fresh abort signal for this task
@@ -126,8 +572,25 @@ async function main() {
126
572
  signal: controller.signal, // for aborting requests
127
573
  isInterrupted: () => interrupted, // for stopping between steps
128
574
  confirm, // for permission prompts
575
+ askUser, // the multi-question form the model can pop
576
+ subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
577
+ judge, // optional LLM classifier for the "ask" middle ground
129
578
  });
130
579
  running = false; // back at the prompt — Ctrl+C means "exit" again
580
+ saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
581
+ // Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
582
+ // that saves durable facts — especially user corrections — so memory
583
+ // captures itself instead of relying on the model to call `remember`.
584
+ if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
585
+ try {
586
+ const got = await extractMemories(client, CONFIG.subAgentModel || CONFIG.model, messages);
587
+ if (got.length)
588
+ console.log(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
589
+ }
590
+ catch {
591
+ /* memory extraction must never break the session */
592
+ }
593
+ }
131
594
  // Done already streamed its answer to the screen; everything else gets a
132
595
  // one-line human explanation. The session continues either way — except a
133
596
  // fatal API error, which no amount of chatting will fix.
@@ -139,7 +602,10 @@ async function main() {
139
602
  break; // a bad key won't fix itself mid-session
140
603
  }
141
604
  }
605
+ await runHooks("SessionEnd", {}); // cleanup hooks (tiny 1.5s budget — the user may be leaving via Ctrl+C)
606
+ emit("agent_session_end"); // close the books
142
607
  rl.close(); // release stdin
608
+ tuiCleanup(); // restore terminal
143
609
  console.log(chalk.dim("bye.")); // a clean goodbye
144
610
  }
145
611
  main(); // kick everything off