agent-from-zero 0.31.0 → 0.32.1

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,103 @@
1
+ import chalk from "chalk";
2
+ import path from "node:path";
3
+ import { CONFIG } from "../config.js";
4
+ import { statsReport } from "../telemetry.js";
5
+ import { readMemoryTyped, MEMORY_PATH } from "../memory.js";
6
+ import { getTodos, renderTodos } from "../todos.js";
7
+ import { listBackground } from "../background.js";
8
+ import { listTeam } from "../team.js";
9
+ import { boardSummary } from "../board.js";
10
+ import { undoLast, sessionChanges } from "../undo.js";
11
+ import { renderDiff } from "../diff.js";
12
+ // The non-interactive slash commands: each produces a block of text to commit as
13
+ // a note. Lifted from agent.ts's handleCommand, with console.log → a returned
14
+ // string so the Ink REPL can render them inside its tree. The stateful commands
15
+ // (/clear, /compact, /plan, /model, /resume, /skill) stay in the App — they
16
+ // mutate the conversation or pop an interactive menu.
17
+ // The in-session command reference shown by /help.
18
+ export const SESSION_HELP = `commands:
19
+ /help this text
20
+ /clear wipe the conversation (and the file read-state) — fresh start
21
+ /compact compact the history into a summary right now (happens automatically near the limit)
22
+ /model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
23
+ /stats event counts for this session (local telemetry — nothing leaves this machine)
24
+ /memory show the durable facts the agent remembers about this project
25
+ /cost tokens, cache hit rate and estimated spend this session (local)
26
+ /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
27
+ /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
28
+ /bg list background tasks this session (run_bash_background) and their status
29
+ /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
30
+ /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
31
+ /undo revert the most recent file write (write_file / edit_file) this session
32
+ /diff show every file changed this session, as a diff from where it started
33
+ /resume list recent sessions in this project and continue one of them
34
+ /skills list the reusable skills available in this project
35
+ /skill <name> run a skill yourself (works even for user-only skills)
36
+ exit leave (Ctrl+C does the same)
37
+
38
+ keys (at the prompt):
39
+ Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
40
+ Ctrl+T reveal the folded tool-call trace for the last answer`;
41
+ // Run a non-interactive command. Returns the note text to display, or null if
42
+ // `line` isn't one of these (the App handles the rest).
43
+ export function runInfoCommand(line, ctx) {
44
+ switch (line) {
45
+ case "/help":
46
+ return chalk.dim(SESSION_HELP);
47
+ case "/cost":
48
+ return chalk.dim(ctx.costMeter.report()); // tokens, cache hit rate, estimated spend (local)
49
+ case "/stats":
50
+ return chalk.dim(statsReport()); // local counters, busiest first
51
+ case "/memory": {
52
+ const entries = readMemoryTyped();
53
+ return chalk.dim(entries.length
54
+ ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
55
+ : `(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"})`);
56
+ }
57
+ case "/todos": {
58
+ const todos = getTodos();
59
+ return todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)");
60
+ }
61
+ case "/bg": {
62
+ const bg = listBackground();
63
+ if (!bg.length)
64
+ return chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)");
65
+ const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
66
+ return chalk.dim("background tasks this session:\n") + bg.map((t) => chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`)).join("\n");
67
+ }
68
+ case "/team": {
69
+ const team = listTeam();
70
+ if (!team.length)
71
+ return chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)");
72
+ const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
73
+ return (chalk.dim("agent team this session:\n") +
74
+ team.map((t) => chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${t.pending ? chalk.yellow(` · ${t.pending} unread`) : ""}`)).join("\n"));
75
+ }
76
+ case "/tasks": {
77
+ const summary = boardSummary();
78
+ return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
79
+ }
80
+ case "/skills": {
81
+ if (!ctx.skills.length)
82
+ return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
83
+ return (chalk.dim("skills in this project:\n") +
84
+ ctx.skills.map((s) => chalk.dim(` ${s.name} [${s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user")}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`)).join("\n"));
85
+ }
86
+ case "/undo": {
87
+ const undone = undoLast();
88
+ if (!undone)
89
+ return chalk.dim("(nothing to undo — no file writes recorded this session)");
90
+ return chalk.dim(`↩ ${undone.summary}`) + "\n" + renderDiff(undone.after ?? "", undone.before ?? "");
91
+ }
92
+ case "/diff": {
93
+ const changes = sessionChanges();
94
+ if (!changes.length)
95
+ return chalk.dim("(no file changes this session)");
96
+ const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
97
+ const head = chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`);
98
+ return head + "\n" + changes.map((c) => `\n${verb[c.status]} ${path.relative(process.cwd(), c.path) || c.path}\n${renderDiff(c.baseline, c.current)}`).join("");
99
+ }
100
+ default:
101
+ return null; // not an info command — the App handles it (or it's a normal task line)
102
+ }
103
+ }
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { buildInkSession } from "./setup.js"; // the front-end-agnostic session bootstrap
4
+ import { makeRunTurn } from "./chat.js"; // drives one turn through the real loop
5
+ import { App } from "./app.js"; // the Ink REPL
6
+ // Launch the Ink REPL and resolve when it exits. buildInkSession does the same
7
+ // wiring agent.ts does (MCP, skills, judge, memory, cost, hooks, telemetry,
8
+ // optional resume); App renders it and runs the loop. Exported so BOTH the
9
+ // `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
10
+ // once it hands interactive sessions to Ink) can start it.
11
+ export async function launchInk(opts = {}) {
12
+ const session = await buildInkSession({ resume: opts.resume });
13
+ const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
14
+ await waitUntilExit(); // keep the process alive until the user quits
15
+ }
@@ -0,0 +1,4 @@
1
+ import { launchInk } from "./launch.js"; // the reusable launcher (shared with agent.ts)
2
+ // Direct entry for the Ink REPL: npm run ink. agent.ts launches the same thing
3
+ // for a normal `mini-agent` interactive session.
4
+ await launchInk({ resume: process.argv.includes("-r") || process.argv.includes("--resume") });
@@ -0,0 +1,104 @@
1
+ import OpenAI from "openai";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { CONFIG, requireApiKey } from "../config.js";
5
+ import { buildSystemMessage } from "../prompt.js";
6
+ import { estimateHistoryTokens } from "../context.js";
7
+ import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
8
+ import { gitBranch } from "../tui.js";
9
+ import { newSessionId, latestSession } from "../session.js";
10
+ import { initTelemetry, emit } from "../telemetry.js";
11
+ import { runHooks } from "../hooks.js";
12
+ import { connectMcpServers } from "../mcp.js";
13
+ import { Judge } from "../judge.js";
14
+ import { registerExternalTool } from "../tools.js";
15
+ import { rememberTool, readMemory } from "../memory.js";
16
+ import { loadSkills, buildSkillTool } from "../skills.js";
17
+ import { killAllBackground } from "../background.js";
18
+ import { killAllSubAgents } from "../loop.js";
19
+ import { banner } from "../ui.js";
20
+ // The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
21
+ // does before the prompt loop, but front-end-agnostic: it touches none of
22
+ // readline/editLine, just the shared modules (MCP, skills, judge, memory, cost,
23
+ // hooks, telemetry, session). It returns everything the Ink App needs to run a
24
+ // real session, so main.tsx stays a thin render() call. (agent.ts is left
25
+ // untouched so `npm start` keeps working until 2d flips the default.)
26
+ const pkg = createRequire(import.meta.url)("../../package.json");
27
+ // Build the session. `resume` mirrors `-r/--resume`: fold the most recent
28
+ // conversation in this directory into the fresh constitution.
29
+ export async function buildInkSession(opts = {}) {
30
+ requireApiKey();
31
+ const client = new OpenAI({ baseURL: CONFIG.baseURL, apiKey: CONFIG.apiKey, maxRetries: 0 });
32
+ const systemMessage = buildSystemMessage(); // stable prefix = cache hits every request
33
+ const messages = [{ role: "system", content: systemMessage }];
34
+ const notices = [];
35
+ let sessionId = newSessionId();
36
+ const startedAt = Date.now();
37
+ if (opts.resume) {
38
+ const prev = latestSession();
39
+ if (prev) {
40
+ messages.push(...prev.messages);
41
+ sessionId = prev.id; // keep appending to the same file
42
+ notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
43
+ }
44
+ else {
45
+ notices.push("(no previous session here — starting fresh)");
46
+ }
47
+ }
48
+ initTelemetry(sessionId);
49
+ emit("agent_session_start", { mode: "repl" });
50
+ // MCP servers — must finish before the first model call so their tools appear
51
+ // in the manual. A server that fails to start is skipped, never fatal.
52
+ const disconnectMcp = await connectMcpServers();
53
+ process.on("exit", disconnectMcp);
54
+ process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
55
+ process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
56
+ const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
57
+ if (judge)
58
+ notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
59
+ // The remember tool flows through the same permission gate as any tool.
60
+ registerExternalTool(rememberTool);
61
+ const memCount = readMemory().length;
62
+ if (memCount)
63
+ notices.push(`(long-term memory: ${memCount} facts loaded)`);
64
+ // Skills: model-invocable ones get the single `skill` tool; user-only ones are
65
+ // reachable via /skill <name>.
66
+ const skills = loadSkills();
67
+ if (skills.some((s) => !s.disableModelInvocation))
68
+ registerExternalTool(buildSkillTool(skills));
69
+ if (skills.length)
70
+ notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
71
+ const costMeter = initCostMeter({
72
+ inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
73
+ cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
74
+ outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
75
+ });
76
+ // SessionStart hooks run once; their stdout becomes context on the first turn.
77
+ const sessionStart = await runHooks("SessionStart", {});
78
+ if (sessionStart.stdout)
79
+ messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
80
+ const dir = path.basename(process.cwd());
81
+ const branch = gitBranch();
82
+ const getStatus = () => ({
83
+ ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
84
+ cost: costMeter.cost(),
85
+ elapsedMs: Date.now() - startedAt,
86
+ });
87
+ return {
88
+ client,
89
+ messages,
90
+ systemMessage,
91
+ initialSessionId: sessionId,
92
+ startedAt,
93
+ costMeter,
94
+ skills,
95
+ judge,
96
+ model: CONFIG.model,
97
+ dir,
98
+ branch,
99
+ bannerText: banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host),
100
+ notices,
101
+ getStatus,
102
+ disconnectMcp,
103
+ };
104
+ }
@@ -0,0 +1,50 @@
1
+ // Build a LoopOutput backed by Ink state. Pure glue: every method maps a loop
2
+ // event to a state change.
3
+ export function makeInkSink(api) {
4
+ return {
5
+ // A spinner becomes the single "status" line below the conversation. A fresh
6
+ // handle per model call (the loop makes one per round); `active` mirrors
7
+ // ora's isSpinning so the loop's `spinner?.spinning` checks behave the same.
8
+ spinner(text) {
9
+ api.setStatus(text);
10
+ let active = true;
11
+ return {
12
+ set: (t) => {
13
+ if (active)
14
+ api.setStatus(t);
15
+ },
16
+ stop: () => {
17
+ if (active) {
18
+ active = false;
19
+ api.setStatus(null);
20
+ }
21
+ },
22
+ get spinning() {
23
+ return active;
24
+ },
25
+ };
26
+ },
27
+ // The "💭 thought for Ns" indicator is just a committed line.
28
+ reasoning: (line) => api.pushItem({ kind: "note", text: line }),
29
+ // The streamed answer accumulates in the dynamic region as raw text (live,
30
+ // "it's typing"), then commits as ONE markdown-rendered item when it ends —
31
+ // partial markdown mid-stream renders badly, so we format only once it's whole.
32
+ answer() {
33
+ let buf = "";
34
+ api.setLive("");
35
+ return {
36
+ push: (tok) => {
37
+ buf += tok;
38
+ api.setLive(buf);
39
+ },
40
+ end: () => {
41
+ api.setLive(null); // clear the live preview first, then commit (no flicker/duplicate)
42
+ if (buf.trim())
43
+ api.pushItem({ kind: "answer", text: buf });
44
+ },
45
+ };
46
+ },
47
+ // Generic narration — already chalk-formatted by the loop; just commit it.
48
+ note: (line) => api.pushItem({ kind: "note", text: line }),
49
+ };
50
+ }