agent-from-zero 0.5.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/README.md +23 -2
- package/dist/agent.js +334 -18
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +167 -19
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +77 -5
- package/dist/prompt.js +16 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/tools.js +47 -4
- package/dist/tui.js +67 -0
- package/dist/ui.js +134 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +2 -2
package/dist/session.js
CHANGED
|
@@ -3,7 +3,9 @@ import path from "node:path"; // path joining
|
|
|
3
3
|
// Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
|
|
4
4
|
// Project-local (not global) because a conversation is about a codebase —
|
|
5
5
|
// resuming a refactor of project A from inside project B makes no sense.
|
|
6
|
-
|
|
6
|
+
// Resolved lazily (a function, not a const) so it always reflects the current
|
|
7
|
+
// working directory — and so tests can chdir into a scratch dir.
|
|
8
|
+
const sessionsDir = () => path.resolve(".mini-agent", "sessions");
|
|
7
9
|
const KEEP_SESSIONS = 20; // how many session files survive pruning
|
|
8
10
|
// A fresh, filename-safe session id derived from the wall clock.
|
|
9
11
|
export function newSessionId() {
|
|
@@ -14,8 +16,9 @@ export function newSessionId() {
|
|
|
14
16
|
// only representation that is always correct. Crash mid-write leaves the
|
|
15
17
|
// previous snapshot intact.
|
|
16
18
|
export function saveSession(id, model, messages) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const dir = sessionsDir();
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true }); // first save creates the directory
|
|
21
|
+
const file = path.join(dir, `${id}.json`); // the real destination
|
|
19
22
|
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
|
|
20
23
|
const data = {
|
|
21
24
|
id,
|
|
@@ -29,33 +32,72 @@ export function saveSession(id, model, messages) {
|
|
|
29
32
|
fs.renameSync(tmp, file); // atomic swap
|
|
30
33
|
prune(); // keep the directory bounded
|
|
31
34
|
}
|
|
35
|
+
// Session files on disk, newest (most recently saved) first. Shared by every
|
|
36
|
+
// reader below. Skips tmp files and strangers.
|
|
37
|
+
function sessionFilesNewestFirst() {
|
|
38
|
+
const dir = sessionsDir();
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return []; // nothing ever saved here
|
|
41
|
+
return fs
|
|
42
|
+
.readdirSync(dir)
|
|
43
|
+
.filter((f) => f.endsWith(".json")) // ignore tmp files and strangers
|
|
44
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs })) // newest by modification time
|
|
45
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
46
|
+
}
|
|
32
47
|
// Load the most recent session, or null if there is none (or it is unreadable —
|
|
33
48
|
// a corrupt session file should not kill the CLI, just start fresh).
|
|
34
49
|
export function latestSession() {
|
|
35
|
-
|
|
36
|
-
return null; // nothing ever saved here
|
|
37
|
-
const files = fs
|
|
38
|
-
.readdirSync(SESSIONS_DIR)
|
|
39
|
-
.filter((f) => f.endsWith(".json")) // ignore tmp files and strangers
|
|
40
|
-
.map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs })) // newest by modification time
|
|
41
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
50
|
+
const files = sessionFilesNewestFirst();
|
|
42
51
|
if (!files.length)
|
|
43
52
|
return null; // empty directory
|
|
44
53
|
try {
|
|
45
|
-
return JSON.parse(fs.readFileSync(path.join(
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir(), files[0].f), "utf8")); // the newest snapshot
|
|
46
55
|
}
|
|
47
56
|
catch {
|
|
48
57
|
return null; // unreadable — treat as absent, never crash on resume
|
|
49
58
|
}
|
|
50
59
|
}
|
|
60
|
+
// Load one session by id, or null if missing/corrupt.
|
|
61
|
+
export function loadSession(id) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir(), `${id}.json`), "utf8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A one-line, human-readable title for a session: the first thing the user
|
|
70
|
+
// actually typed, with any @file attachment block stripped off and the text
|
|
71
|
+
// flattened to a single trimmed line. This is what makes a session list
|
|
72
|
+
// pickable instead of a wall of timestamps.
|
|
73
|
+
export function sessionTitle(messages) {
|
|
74
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
75
|
+
let text = typeof firstUser?.content === "string" ? firstUser.content : "";
|
|
76
|
+
text = text.split("\n\n[Referenced files")[0]; // drop the @file attachment the REPL appended (Day 26)
|
|
77
|
+
const line = text.replace(/\s+/g, " ").trim(); // flatten to one line
|
|
78
|
+
return line || "(no prompt)";
|
|
79
|
+
}
|
|
80
|
+
// Recent sessions, newest first, summarized for a picker. Unreadable files are
|
|
81
|
+
// skipped, never fatal.
|
|
82
|
+
export function listSessions(limit = 10) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const { f } of sessionFilesNewestFirst()) {
|
|
85
|
+
if (out.length >= limit)
|
|
86
|
+
break;
|
|
87
|
+
try {
|
|
88
|
+
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir(), f), "utf8"));
|
|
89
|
+
out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: sessionTitle(s.messages) });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* skip a corrupt file */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
51
97
|
// Delete everything beyond the newest KEEP_SESSIONS files. A CLI that quietly
|
|
52
98
|
// accumulates unbounded state in every project directory is not industrial.
|
|
53
99
|
function prune() {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
.map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs }))
|
|
58
|
-
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
59
|
-
for (const { f } of files.slice(KEEP_SESSIONS))
|
|
60
|
-
fs.rmSync(path.join(SESSIONS_DIR, f)); // drop the tail
|
|
100
|
+
const dir = sessionsDir();
|
|
101
|
+
for (const { f } of sessionFilesNewestFirst().slice(KEEP_SESSIONS))
|
|
102
|
+
fs.rmSync(path.join(dir, f)); // drop the tail beyond KEEP_SESSIONS
|
|
61
103
|
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs"; // discover and read SKILL.md files
|
|
2
|
+
import os from "node:os"; // the global skills dir
|
|
3
|
+
import path from "node:path"; // path resolution
|
|
4
|
+
// Project skills win over global ones with the same name.
|
|
5
|
+
function skillDirs() {
|
|
6
|
+
return [path.resolve(".mini-agent", "skills"), path.join(os.homedir(), ".config", "mini-agent", "skills")];
|
|
7
|
+
}
|
|
8
|
+
// Parse one SKILL.md into a Skill. Pure — no filesystem — so the (fiddly)
|
|
9
|
+
// frontmatter parsing is unit-testable. A tiny YAML subset: `key: value` lines.
|
|
10
|
+
export function parseSkill(raw, fallbackName, filePath = "") {
|
|
11
|
+
const fm = {};
|
|
12
|
+
let body = raw.trim();
|
|
13
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); // frontmatter between --- fences
|
|
14
|
+
if (m) {
|
|
15
|
+
body = m[2].trim();
|
|
16
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
17
|
+
const i = line.indexOf(":");
|
|
18
|
+
if (i < 0)
|
|
19
|
+
continue;
|
|
20
|
+
const k = line.slice(0, i).trim();
|
|
21
|
+
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, ""); // strip surrounding quotes
|
|
22
|
+
if (k)
|
|
23
|
+
fm[k] = v;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
name: fm.name || fallbackName,
|
|
28
|
+
description: fm.description || "",
|
|
29
|
+
whenToUse: fm.when_to_use || fm.whenToUse || fm.description || "",
|
|
30
|
+
allowedTools: (fm["allowed-tools"] || fm.allowedTools || "")
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
disableModelInvocation: /^true$/i.test(fm.disableModelInvocation || fm["disable-model-invocation"] || ""),
|
|
35
|
+
body,
|
|
36
|
+
path: filePath,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Discover skills under the skill dirs (project first, then global). A bad file
|
|
40
|
+
// is skipped, never fatal. `dirs` is injectable for tests.
|
|
41
|
+
export function loadSkills(dirs = skillDirs()) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
let entries = [];
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdirSync(dir);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
continue; // dir doesn't exist — fine
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const file = path.join(dir, entry, "SKILL.md");
|
|
54
|
+
try {
|
|
55
|
+
const skill = parseSkill(fs.readFileSync(file, "utf8"), entry, file);
|
|
56
|
+
if (seen.has(skill.name))
|
|
57
|
+
continue; // first wins → project overrides global
|
|
58
|
+
seen.add(skill.name);
|
|
59
|
+
out.push(skill);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* not a skill folder — skip */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
export function findSkill(skills, name) {
|
|
69
|
+
const n = name.trim().toLowerCase();
|
|
70
|
+
return skills.find((s) => s.name.toLowerCase() === n);
|
|
71
|
+
}
|
|
72
|
+
const MAX_DESC = 250; // cap each skill's listing line (handbook MAX_LISTING_DESC_CHARS)
|
|
73
|
+
const LIST_BUDGET = 8000; // cap the whole listing (handbook DEFAULT_CHAR_BUDGET)
|
|
74
|
+
// The one-line-per-skill listing the model sees (name + when-to-use), budget
|
|
75
|
+
// capped. This is the "progressive disclosure" surface: enough to decide whether
|
|
76
|
+
// to invoke, not the full body.
|
|
77
|
+
export function skillListing(skills) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
let used = 0;
|
|
80
|
+
for (const s of skills) {
|
|
81
|
+
const desc = (s.whenToUse || s.description).replace(/\s+/g, " ").slice(0, MAX_DESC);
|
|
82
|
+
const line = `- ${s.name}: ${desc}`;
|
|
83
|
+
if (used + line.length > LIST_BUDGET)
|
|
84
|
+
break; // out of budget — stop listing
|
|
85
|
+
used += line.length;
|
|
86
|
+
lines.push(line);
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
// What gets handed back when a skill is invoked: the body, plus a reminder of
|
|
91
|
+
// its allowed tools (least-privilege is advisory here — the permission gate
|
|
92
|
+
// still governs every actual call).
|
|
93
|
+
export function skillInstructions(s) {
|
|
94
|
+
const tools = s.allowedTools.length ? `\n\n[This skill should use only these tools: ${s.allowedTools.join(", ")}. Other actions still go through the normal permission gate.]` : "";
|
|
95
|
+
return `# Skill: ${s.name}\n\nFollow these steps:\n\n${s.body}${tools}`;
|
|
96
|
+
}
|
|
97
|
+
// Build the `skill` tool from the model-invocable skills. The tool's description
|
|
98
|
+
// IS the listing, so the model learns what skills exist and when to use them;
|
|
99
|
+
// calling it with a name returns that skill's full instructions.
|
|
100
|
+
export function buildSkillTool(skills) {
|
|
101
|
+
const invokable = skills.filter((s) => !s.disableModelInvocation);
|
|
102
|
+
const listing = skillListing(invokable) || "(none)";
|
|
103
|
+
return {
|
|
104
|
+
definition: {
|
|
105
|
+
type: "function",
|
|
106
|
+
function: {
|
|
107
|
+
name: "skill",
|
|
108
|
+
description: `Load and follow a reusable skill — a saved, named procedure. Call this when the user's request matches one of the skills below; it returns step-by-step instructions you then carry out yourself.
|
|
109
|
+
Available skills:
|
|
110
|
+
${listing}
|
|
111
|
+
Pass the exact skill name. If nothing matches, do not call this — just answer normally.`,
|
|
112
|
+
parameters: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: { name: { type: "string", description: "The exact skill name to load" } },
|
|
115
|
+
required: ["name"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
run: (args) => {
|
|
120
|
+
const s = findSkill(skills, args.name ?? "");
|
|
121
|
+
if (!s)
|
|
122
|
+
return `[error] No skill named "${args.name}". Available: ${invokable.map((x) => x.name).join(", ") || "none"}.`;
|
|
123
|
+
if (s.disableModelInvocation)
|
|
124
|
+
return `[error] Skill "${s.name}" can only be invoked by the user, not by you.`;
|
|
125
|
+
return skillInstructions(s);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -2,6 +2,9 @@ import fs from "node:fs"; // filesystem reads and writes
|
|
|
2
2
|
import os from "node:os"; // temp dir for spilled tool output
|
|
3
3
|
import path from "node:path"; // path resolution and joining
|
|
4
4
|
import { spawn } from "node:child_process"; // async process execution (does NOT block the event loop)
|
|
5
|
+
import { validateArgs } from "./validate.js"; // schema validation for tool arguments
|
|
6
|
+
import { setPlanMode } from "./permissions.js"; // plan mode (Day 20): exit_plan_mode flips it off on approval
|
|
7
|
+
import { recordMutation } from "./undo.js"; // capture the before-state so /undo (Day 22) can restore it
|
|
5
8
|
// ============ Session state ============
|
|
6
9
|
// The foundation of "read before edit": which files has this session actually read?
|
|
7
10
|
// Files that were never read must not be edited — otherwise the model edits from
|
|
@@ -67,6 +70,7 @@ On error: follow the error message — read the file first, then retry.`, {
|
|
|
67
70
|
return fail(`${p} already exists but you have not read it. Use read_file to check its current content before overwriting.`); // no blind overwrites
|
|
68
71
|
}
|
|
69
72
|
fs.mkdirSync(path.dirname(p), { recursive: true }); // create parent directories as needed
|
|
73
|
+
recordMutation(p); // snapshot the previous state (or "didn't exist") for /undo, before we overwrite
|
|
70
74
|
fs.writeFileSync(p, args.content, "utf8"); // write the new content
|
|
71
75
|
readFiles.set(p, ++touchCounter); // you wrote it, so you know its content — counts as read
|
|
72
76
|
return `Wrote ${p} (${args.content.length} chars)`; // confirm with the size as a sanity check
|
|
@@ -102,6 +106,7 @@ ALWAYS use this tool to edit files. NEVER use sed/awk or shell redirection via r
|
|
|
102
106
|
}
|
|
103
107
|
// Note: content.replace(old, newStr) would expand $& $' etc. in newStr as special
|
|
104
108
|
// replacement patterns — pass a function so the replacement is taken literally.
|
|
109
|
+
recordMutation(p); // snapshot the pre-edit content for /undo, before we change it
|
|
105
110
|
fs.writeFileSync(p, content.replace(args.old_string, () => args.new_string), "utf8"); // apply the single replacement
|
|
106
111
|
return `Edited ${p}: 1 replacement made.`; // confirm exactly what happened
|
|
107
112
|
},
|
|
@@ -230,20 +235,50 @@ On error: failures return the error output — analyze it and try a different ap
|
|
|
230
235
|
});
|
|
231
236
|
}),
|
|
232
237
|
};
|
|
238
|
+
// ============ exit_plan_mode ============
|
|
239
|
+
// Plan mode (Day 20) makes the agent research-only: the permission gate blocks
|
|
240
|
+
// every mutating tool until the model presents a plan the user approves. The
|
|
241
|
+
// model calls this when its plan is ready; the gate turns it into an "ask", so
|
|
242
|
+
// the user sees the plan first. run() executes ONLY after the user says yes —
|
|
243
|
+
// so reaching this code IS the approval, and the one thing it does is leave plan
|
|
244
|
+
// mode. Outside plan mode the gate makes it a harmless no-op allow.
|
|
245
|
+
const exitPlanMode = {
|
|
246
|
+
definition: def("exit_plan_mode", `Leave plan mode and start implementing. Call this ONLY when you are in plan mode and have finished investigating with read-only tools.
|
|
247
|
+
Pass your plan as markdown in "plan"; the user is shown it and asked to approve. Do NOT call it to ask a question, and not before you have a concrete, ordered plan.
|
|
248
|
+
On approval, plan mode turns off and writing/executing tools become available. If declined, you stay in plan mode — refine the plan and try again.`, { plan: { type: "string", description: "The implementation plan, as concise ordered markdown steps" } }, ["plan"]),
|
|
249
|
+
run: () => {
|
|
250
|
+
setPlanMode(false); // run() only happens after the user approved — leave plan mode so implementation can begin
|
|
251
|
+
return "Plan approved by the user. Plan mode is OFF — implement the plan now; writing and executing tools are available again.";
|
|
252
|
+
},
|
|
253
|
+
};
|
|
233
254
|
// ============ Registry & dispatch ============
|
|
234
|
-
//
|
|
255
|
+
// The built-in tools. External tools (MCP servers, Day 15) are registered into
|
|
256
|
+
// the same record at startup, so they flow through the same dispatch and the
|
|
257
|
+
// same permission gate — there is exactly one execution path, no matter where a
|
|
258
|
+
// tool came from.
|
|
235
259
|
export const tools = {
|
|
236
260
|
read_file: readFile,
|
|
237
261
|
write_file: writeFile,
|
|
238
262
|
edit_file: editFile,
|
|
239
263
|
search,
|
|
240
264
|
run_bash: runBash,
|
|
265
|
+
exit_plan_mode: exitPlanMode,
|
|
241
266
|
};
|
|
242
|
-
//
|
|
243
|
-
|
|
267
|
+
// Register an external tool (e.g. one discovered from an MCP server). Kept in
|
|
268
|
+
// the same `tools` record so dispatch, the permission gate, and the tool-list
|
|
269
|
+
// sent to the model all treat it identically to a built-in.
|
|
270
|
+
export function registerExternalTool(tool) {
|
|
271
|
+
tools[tool.definition.function.name] = tool;
|
|
272
|
+
}
|
|
273
|
+
// The manuals sent to the model. A function (not a const) since Day 15: MCP
|
|
274
|
+
// tools register after module load, so the list must be computed when asked.
|
|
275
|
+
export function toolDefinitions() {
|
|
276
|
+
return Object.values(tools).map((t) => t.definition);
|
|
277
|
+
}
|
|
244
278
|
// Read-only tools never touch the filesystem in a conflicting way, so the loop
|
|
245
279
|
// is free to run a batch of them concurrently. Anything that writes or executes
|
|
246
|
-
//
|
|
280
|
+
// (including any external/MCP tool, which we cannot reason about) runs alone, in
|
|
281
|
+
// order. (See loop.ts for the batching.)
|
|
247
282
|
const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
|
|
248
283
|
export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
|
|
249
284
|
// Single entry point: every failure becomes text fed back to the model.
|
|
@@ -261,6 +296,14 @@ export async function dispatch(name, argsJson, signal) {
|
|
|
261
296
|
catch {
|
|
262
297
|
return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
|
|
263
298
|
}
|
|
299
|
+
// Validate against the tool's declared schema BEFORE running it. The model's
|
|
300
|
+
// arguments are probabilistic — a missing required field or a wrong type gets
|
|
301
|
+
// a precise repair message here instead of a cryptic crash inside the tool
|
|
302
|
+
// (or, for MCP tools, a wasted round-trip to the server).
|
|
303
|
+
const problems = validateArgs(tool.definition.function.parameters, args);
|
|
304
|
+
if (problems.length) {
|
|
305
|
+
return fail(`Invalid arguments for ${name}: ${problems.join("; ")}. Fix and call again.`);
|
|
306
|
+
}
|
|
264
307
|
// Each tool gets a result cap sized to its job. run_bash already manages its
|
|
265
308
|
// own output (preview + spill-to-disk via spillIfHuge); its cap must leave
|
|
266
309
|
// room for the spill note, or dispatch would chop off the very pointer the
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Terminal UI helpers that need state or the filesystem: the git branch for the
|
|
2
|
+
// status line, and collapsible tool output. The pure formatting (status line,
|
|
3
|
+
// spinner) lives in ui.ts; this is the side-effecting half.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
// ---- git branch for the status line -----------------------------------------
|
|
8
|
+
// Walk up from the cwd to the nearest .git and read HEAD. A branch name, a short
|
|
9
|
+
// detached SHA, or null if this isn't a repo. Never throws.
|
|
10
|
+
export function gitBranch() {
|
|
11
|
+
let dir = process.cwd();
|
|
12
|
+
for (let i = 0; i < 40; i++) {
|
|
13
|
+
try {
|
|
14
|
+
const gitPath = path.join(dir, ".git");
|
|
15
|
+
const stat = fs.statSync(gitPath);
|
|
16
|
+
let headFile = path.join(gitPath, "HEAD");
|
|
17
|
+
if (stat.isFile()) {
|
|
18
|
+
// A worktree: .git is a file "gitdir: <path>" pointing elsewhere.
|
|
19
|
+
const gitdir = fs.readFileSync(gitPath, "utf8").replace(/^gitdir:\s*/, "").trim();
|
|
20
|
+
headFile = path.join(path.resolve(dir, gitdir), "HEAD");
|
|
21
|
+
}
|
|
22
|
+
const head = fs.readFileSync(headFile, "utf8").trim();
|
|
23
|
+
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
24
|
+
return m ? m[1] : head.slice(0, 7); // branch name, or a short detached SHA
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* not a repo here — keep walking up */
|
|
28
|
+
}
|
|
29
|
+
const parent = path.dirname(dir);
|
|
30
|
+
if (parent === dir)
|
|
31
|
+
break; // reached the filesystem root
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const collapsed = [];
|
|
37
|
+
const MAX_COLLAPSED = 50; // bound the memory — old blocks fall off
|
|
38
|
+
// Print the one-line summary now; stash the full text for Tab to reveal later.
|
|
39
|
+
export function printToolSummary(summary, full) {
|
|
40
|
+
process.stdout.write(summary + "\n");
|
|
41
|
+
collapsed.push({ full, expanded: false });
|
|
42
|
+
if (collapsed.length > MAX_COLLAPSED)
|
|
43
|
+
collapsed.shift();
|
|
44
|
+
}
|
|
45
|
+
// Tab handler: reveal (or re-hide) the full text of the most recent tool block.
|
|
46
|
+
// Returns false if there's nothing to toggle.
|
|
47
|
+
export function toggleLastCollapsed() {
|
|
48
|
+
const block = collapsed[collapsed.length - 1];
|
|
49
|
+
if (!block)
|
|
50
|
+
return false;
|
|
51
|
+
block.expanded = !block.expanded;
|
|
52
|
+
if (block.expanded) {
|
|
53
|
+
process.stdout.write(chalk.dim(block.full.replace(/^/gm, " ⎿ ")) + "\n");
|
|
54
|
+
process.stdout.write(chalk.dim(" (Tab to collapse)\n"));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
process.stdout.write(chalk.dim(" (collapsed)\n"));
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// ---- cleanup -----------------------------------------------------------------
|
|
62
|
+
// Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
|
|
63
|
+
// of the screen), but keep the hook so callers don't have to change if that
|
|
64
|
+
// changes again.
|
|
65
|
+
export function cleanup() {
|
|
66
|
+
process.stdout.write("\n");
|
|
67
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import chalk from "chalk"; // the one place color lives
|
|
2
|
+
// The app's visual theme, in one place. Before this, emoji and colors were
|
|
3
|
+
// sprinkled across agent.ts and loop.ts — no coherent look, and impossible to
|
|
4
|
+
// restyle without hunting. This module owns "what the tool looks like": the
|
|
5
|
+
// welcome box, the prompt, the activity markers, the spinner. Still no TUI
|
|
6
|
+
// framework — we print line by line — just with one consistent vocabulary.
|
|
7
|
+
//
|
|
8
|
+
// Glyphs over emoji: ⏺ (an action or answer), ⎿ (a nested detail), ❯ (the
|
|
9
|
+
// prompt). They're width-1 in terminals (emoji like 🔧 are width-2 and break
|
|
10
|
+
// alignment), and they read as "a real tool" rather than "a toy".
|
|
11
|
+
// Draw a rounded box around plain-text lines. Width is measured on the PLAIN
|
|
12
|
+
// text, so callers pass plain strings; an optional per-line style wraps the
|
|
13
|
+
// already-padded cell (ANSI sits outside the padding, so alignment survives).
|
|
14
|
+
// Keep box content ASCII-ish — String.length miscounts CJK/emoji width.
|
|
15
|
+
function box(lines) {
|
|
16
|
+
const width = Math.max(...lines.map((l) => l.text.length));
|
|
17
|
+
const top = chalk.dim(`╭${"─".repeat(width + 2)}╮`);
|
|
18
|
+
const bottom = chalk.dim(`╰${"─".repeat(width + 2)}╯`);
|
|
19
|
+
const body = lines.map((l) => {
|
|
20
|
+
const cell = l.text.padEnd(width); // pad the plain text to a uniform width
|
|
21
|
+
return chalk.dim("│ ") + (l.style ? l.style(cell) : cell) + chalk.dim(" │"); // style wraps the padded cell
|
|
22
|
+
});
|
|
23
|
+
return [top, ...body, bottom].join("\n");
|
|
24
|
+
}
|
|
25
|
+
// The welcome banner, shown once at startup.
|
|
26
|
+
export function banner(version, model, host) {
|
|
27
|
+
return box([
|
|
28
|
+
{ text: `mini-agent v${version}`, style: chalk.bold.cyan },
|
|
29
|
+
{ text: `${model} · ${host}`, style: chalk.dim },
|
|
30
|
+
{ text: "" },
|
|
31
|
+
{ text: "/help commands @file attach a file Ctrl+C interrupt", style: chalk.dim },
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
// The input prompt. A styled chevron, not a bare ">". Plan mode (Day 20) gets a
|
|
35
|
+
// distinct marker so you always know writes are blocked.
|
|
36
|
+
export function promptString(planMode) {
|
|
37
|
+
return planMode ? chalk.yellow.bold("\n⏸ plan ❯ ") : chalk.cyan.bold("\n❯ ");
|
|
38
|
+
}
|
|
39
|
+
// The activity log vocabulary. ⏺ marks a thing happening, ⎿ marks a detail
|
|
40
|
+
// hanging off it. Indentation/nesting is the caller's job (it knows sub-agent
|
|
41
|
+
// depth), so these are just the styled pieces.
|
|
42
|
+
export const mark = {
|
|
43
|
+
tool: (name, args) => chalk.cyan("⏺ ") + chalk.bold(name) + (args ? chalk.dim(` ${args}`) : ""),
|
|
44
|
+
answer: chalk.green("⏺ "), // prefix for the model's streamed answer
|
|
45
|
+
denied: (reason) => chalk.red(" ⎿ ") + chalk.red(`denied — ${reason}`),
|
|
46
|
+
declined: chalk.yellow(" ⎿ declined"),
|
|
47
|
+
judge: chalk.dim(" ⎿ judge: clearly safe, auto-allowed"),
|
|
48
|
+
hookBlock: (event) => chalk.red(` ⎿ blocked by ${event} hook`),
|
|
49
|
+
subAgentStart: (desc, tier) => chalk.blue(" ⎿ sub-agent") + (tier ? chalk.dim(` [${tier}]`) : "") + chalk.blue(`: ${desc}`),
|
|
50
|
+
subAgentDone: chalk.blue(" ⎿ sub-agent done"),
|
|
51
|
+
note: (s) => chalk.dim(s), // dim asides (resumed, attached, cleared…)
|
|
52
|
+
warn: (s) => chalk.yellow(s),
|
|
53
|
+
};
|
|
54
|
+
// Playful "thinking" words, cycled while waiting for the first token — the same
|
|
55
|
+
// touch Claude Code uses so a wait feels alive, not frozen.
|
|
56
|
+
const WORDS = ["Thinking", "Pondering", "Cogitating", "Reasoning", "Working", "Scheming", "Brewing", "Crunching", "Mulling", "Noodling"];
|
|
57
|
+
// Pick a word by a seed (a call counter) rather than Math.random — deterministic,
|
|
58
|
+
// testable, and still varies turn to turn.
|
|
59
|
+
export function thinkingWord(seed) {
|
|
60
|
+
return WORDS[((seed % WORDS.length) + WORDS.length) % WORDS.length];
|
|
61
|
+
}
|
|
62
|
+
// Compact a token count: 1234 → "1.2k", 980 → "980".
|
|
63
|
+
export function formatTokens(n) {
|
|
64
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
65
|
+
}
|
|
66
|
+
// Human elapsed time: 7s / 1m32s / 1h05m.
|
|
67
|
+
export function formatElapsed(ms) {
|
|
68
|
+
const s = Math.floor(ms / 1000);
|
|
69
|
+
if (s < 60)
|
|
70
|
+
return `${s}s`;
|
|
71
|
+
const m = Math.floor(s / 60);
|
|
72
|
+
if (m < 60)
|
|
73
|
+
return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
|
74
|
+
const h = Math.floor(m / 60);
|
|
75
|
+
return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
|
76
|
+
}
|
|
77
|
+
// The live spinner text: the model (so you can SEE which one is answering — proof
|
|
78
|
+
// a /model switch took effect) + a thinking word + elapsed + tokens streamed so
|
|
79
|
+
// far + how to bail. Mirrors Claude Code's "Mulling… (1m32s · ↓ 4.0k tokens)".
|
|
80
|
+
export function spinnerText(word, elapsedSec, subAgent, model, tokens) {
|
|
81
|
+
const head = subAgent ? "sub-agent" : word;
|
|
82
|
+
const tag = model && !subAgent ? chalk.dim(`${model} · `) : ""; // show the model on top-level calls
|
|
83
|
+
const bits = [`${elapsedSec}s`];
|
|
84
|
+
if (tokens && tokens > 0)
|
|
85
|
+
bits.push(`↓ ${formatTokens(tokens)} tokens`);
|
|
86
|
+
bits.push("Ctrl+C to interrupt");
|
|
87
|
+
return `${tag}${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
|
|
88
|
+
}
|
|
89
|
+
// The persistent status line — model, project, branch, context use, spend, time.
|
|
90
|
+
// Pure (dir + branch passed in) so it's testable without a filesystem or git.
|
|
91
|
+
export function statusLine(model, dir, branch, ctxPct, costUsd, elapsedMs) {
|
|
92
|
+
const parts = [
|
|
93
|
+
chalk.cyan(`[${model}]`),
|
|
94
|
+
chalk.dim(`📁 ${dir}`),
|
|
95
|
+
branch ? chalk.green(`🌿 ${branch}`) : "",
|
|
96
|
+
chalk.dim(`ctx ${ctxPct}%`),
|
|
97
|
+
chalk.yellow(`$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`),
|
|
98
|
+
chalk.dim(`⏱ ${formatElapsed(elapsedMs)}`),
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
return parts.join(chalk.dim(" · "));
|
|
101
|
+
}
|
|
102
|
+
// ---- the input area ---------------------------------------------------------
|
|
103
|
+
// Like Claude Code: two horizontal rules (top + bottom) with the user's input
|
|
104
|
+
// between them. Long input is truncated to keep the box compact.
|
|
105
|
+
function frameWidth() {
|
|
106
|
+
return Math.max(8, Math.min(process.stdout.columns || 80, 100) - 1); // fit the terminal, but cap the rule
|
|
107
|
+
}
|
|
108
|
+
export function inputRule() {
|
|
109
|
+
return chalk.dim("─".repeat(frameWidth())); // a plain separator line
|
|
110
|
+
}
|
|
111
|
+
// The prompt under the rule. Plan mode (Day 20) is marked so you know writes are blocked.
|
|
112
|
+
export function framedPrompt(planMode) {
|
|
113
|
+
return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
|
|
114
|
+
}
|
|
115
|
+
// ---- the selection menu -----------------------------------------------------
|
|
116
|
+
// Render a numbered list of options with one highlighted. The interactive part
|
|
117
|
+
// (raw-mode arrow keys) lives in menu.ts; this is just the pure drawing, so it's
|
|
118
|
+
// testable. Selecting beats typing: an approval is one keystroke, not "type y/N".
|
|
119
|
+
// Numbered so the user can also see "option 2" at a glance, like Claude Code.
|
|
120
|
+
export function renderMenu(options, selected) {
|
|
121
|
+
return options
|
|
122
|
+
.map((o, i) => {
|
|
123
|
+
const label = `${i + 1}. ${o}`;
|
|
124
|
+
return i === selected ? chalk.cyan.bold(`❯ ${label}`) : chalk.dim(` ${label}`);
|
|
125
|
+
})
|
|
126
|
+
.join("\n");
|
|
127
|
+
}
|
|
128
|
+
// The hint line under a menu — how to drive it.
|
|
129
|
+
export const MENU_HINT = chalk.dim("↑↓ to move · Enter to select · Esc to cancel");
|
|
130
|
+
// Label a model list for the picker, marking the one in use. Pure, so the "which
|
|
131
|
+
// is current" logic is testable without hitting the network for the list.
|
|
132
|
+
export function formatModelChoices(models, current) {
|
|
133
|
+
return models.map((m) => (m === current ? `${m} (current)` : m));
|
|
134
|
+
}
|
package/dist/undo.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs"; // read the "before" state and restore it
|
|
2
|
+
import path from "node:path"; // only for display shortening
|
|
3
|
+
const MAX_UNDO = 50; // remember at most this many writes — older ones drop off
|
|
4
|
+
const stack = []; // most recent change on top
|
|
5
|
+
// The session-origin baseline: for each file touched this session, what it
|
|
6
|
+
// looked like the FIRST time we changed it (null = it didn't exist yet). The
|
|
7
|
+
// undo stack is bounded and popped, so it can't answer "what changed since we
|
|
8
|
+
// started" — this map can. Set once per path, never overwritten, so it always
|
|
9
|
+
// holds the true starting point even after many edits to the same file.
|
|
10
|
+
const origin = new Map();
|
|
11
|
+
// Called BY the write/edit tools, just before they touch the disk. Captures the
|
|
12
|
+
// current on-disk state so it can be restored later. Never throws — recording is
|
|
13
|
+
// best-effort and must not break the write it is shadowing.
|
|
14
|
+
export function recordMutation(absPath) {
|
|
15
|
+
let before = null;
|
|
16
|
+
try {
|
|
17
|
+
if (fs.existsSync(absPath))
|
|
18
|
+
before = fs.readFileSync(absPath, "utf8"); // existing file → keep its content
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
before = null; // unreadable → treat as "nothing to restore"
|
|
22
|
+
}
|
|
23
|
+
if (!origin.has(absPath))
|
|
24
|
+
origin.set(absPath, before); // first touch this session = the baseline /diff compares against
|
|
25
|
+
stack.push({ path: absPath, before });
|
|
26
|
+
if (stack.length > MAX_UNDO)
|
|
27
|
+
stack.shift(); // bound the history (drop the oldest)
|
|
28
|
+
}
|
|
29
|
+
// How many undos are available — used by the REPL banner / message.
|
|
30
|
+
export function undoDepth() {
|
|
31
|
+
return stack.length;
|
|
32
|
+
}
|
|
33
|
+
// Forget all recorded mutations (e.g. on /clear — a fresh conversation should
|
|
34
|
+
// not be able to undo writes from the previous one, nor report them in /diff).
|
|
35
|
+
export function clearUndo() {
|
|
36
|
+
stack.length = 0;
|
|
37
|
+
origin.clear();
|
|
38
|
+
}
|
|
39
|
+
// Every file whose content differs from its session-origin baseline. Files that
|
|
40
|
+
// were touched but ended up back at their starting content are NOT listed —
|
|
41
|
+
// /diff shows the net effect of the session, not its history.
|
|
42
|
+
export function sessionChanges() {
|
|
43
|
+
const changes = [];
|
|
44
|
+
for (const [p, base] of origin) {
|
|
45
|
+
let current = null;
|
|
46
|
+
try {
|
|
47
|
+
if (fs.existsSync(p))
|
|
48
|
+
current = fs.readFileSync(p, "utf8"); // where the file stands now
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
current = null;
|
|
52
|
+
}
|
|
53
|
+
if ((base ?? null) === (current ?? null))
|
|
54
|
+
continue; // net-zero change → skip (e.g. edited then undone)
|
|
55
|
+
const status = base === null ? "created" : current === null ? "deleted" : "modified";
|
|
56
|
+
changes.push({ path: p, status, baseline: base ?? "", current: current ?? "" });
|
|
57
|
+
}
|
|
58
|
+
return changes;
|
|
59
|
+
}
|
|
60
|
+
// Undo the most recent recorded write. Returns null if there is nothing to undo.
|
|
61
|
+
// Restores the previous content, or deletes a file that the write had created.
|
|
62
|
+
export function undoLast() {
|
|
63
|
+
const m = stack.pop();
|
|
64
|
+
if (!m)
|
|
65
|
+
return null; // empty history
|
|
66
|
+
let after = null;
|
|
67
|
+
try {
|
|
68
|
+
if (fs.existsSync(m.path))
|
|
69
|
+
after = fs.readFileSync(m.path, "utf8"); // current state, for the diff
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
after = null;
|
|
73
|
+
}
|
|
74
|
+
const shortPath = path.relative(process.cwd(), m.path) || m.path; // nicer to read than an absolute path
|
|
75
|
+
try {
|
|
76
|
+
if (m.before === null) {
|
|
77
|
+
if (fs.existsSync(m.path))
|
|
78
|
+
fs.rmSync(m.path); // the write created this file → undo removes it
|
|
79
|
+
return { path: m.path, summary: `removed ${shortPath} (it had just been created)`, before: null, after };
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(m.path, m.before, "utf8"); // put the old content back
|
|
82
|
+
return { path: m.path, summary: `restored ${shortPath} to its previous content`, before: m.before, after };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
stack.push(m); // restore failed → keep the record so the user can retry
|
|
86
|
+
return { path: m.path, summary: `could not undo ${shortPath}: ${e.message}`, before: m.before, after };
|
|
87
|
+
}
|
|
88
|
+
}
|