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.
- package/dist/agent.js +14 -1
- package/dist/context.js +4 -3
- package/dist/ink/app.js +518 -0
- package/dist/ink/chat.js +22 -0
- package/dist/ink/commands.js +103 -0
- package/dist/ink/launch.js +15 -0
- package/dist/ink/main.js +4 -0
- package/dist/ink/setup.js +104 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +142 -81
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +26 -5
- package/package.json +5 -1
|
@@ -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
|
+
}
|
package/dist/ink/main.js
ADDED
|
@@ -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
|
+
}
|
package/dist/ink/sink.js
ADDED
|
@@ -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
|
+
}
|