agent-from-zero 0.5.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/menu.js ADDED
@@ -0,0 +1,154 @@
1
+ import readline from "node:readline"; // raw-mode keypress events + the interface to pause
2
+ import { renderMenu, MENU_HINT } from "./ui.js"; // the pure drawing + the footer hint
3
+ import { initFormState, reduceForm, renderForm, collectAnswers } from "./form.js"; // the multi-question form
4
+ // An arrow-key selection menu. The whole point: an approval is one keystroke
5
+ // (↑/↓ then Enter), not "type y, then N, then realize you meant the other".
6
+ // Selecting costs the user less than typing.
7
+ //
8
+ // Coordinating with the session's readline (which owns stdin) is the tricky
9
+ // part: we pause it, switch stdin to raw mode, read keypresses ourselves, then
10
+ // hand stdin back exactly as we found it. The `input` parameter is injected so
11
+ // tests can drive it with a fake stream instead of a real terminal.
12
+ //
13
+ // Returns the chosen index, or -1 if cancelled (Esc / Ctrl+C) or not a TTY —
14
+ // callers treat -1 as "no" (fail closed for an approval).
15
+ export function promptSelect(rl, // the session's line reader, paused while we own the keys
16
+ options, // the choices, top to bottom
17
+ input = process.stdin) {
18
+ return new Promise((resolve) => {
19
+ if (!input.isTTY)
20
+ return resolve(-1); // no interactive selection without a terminal — caller falls back
21
+ let selected = 0;
22
+ let done = false;
23
+ const wasRaw = input.isRaw === true; // the session's readline keeps stdin raw — restore THIS, don't force false
24
+ // Redraw the menu in place: move the cursor back up over the previous
25
+ // render (options + the one footer line), clear downward, reprint. The
26
+ // question above the menu stays put.
27
+ const lineCount = options.length + 1; // options, plus the hint footer
28
+ const draw = (first) => {
29
+ if (!first)
30
+ process.stdout.write(`\x1b[${lineCount}A`); // up to the menu's top
31
+ process.stdout.write(`\x1b[J${renderMenu(options, selected)}\n${MENU_HINT}\n`); // clear down, repaint + hint
32
+ };
33
+ const finish = (result) => {
34
+ if (done)
35
+ return;
36
+ done = true;
37
+ input.off("keypress", onKey); // stop listening
38
+ try {
39
+ input.setRawMode(wasRaw); // restore the prior mode — forcing false breaks the live readline → it closes → the REPL exits
40
+ }
41
+ catch {
42
+ /* not all streams support it; ignore */
43
+ }
44
+ rl.resume(); // the line reader takes over again
45
+ resolve(result);
46
+ };
47
+ const onKey = (_str, key) => {
48
+ if (!key || done)
49
+ return;
50
+ switch (key.name) {
51
+ case "up":
52
+ case "k":
53
+ selected = (selected - 1 + options.length) % options.length; // wrap around the top
54
+ draw(false);
55
+ break;
56
+ case "down":
57
+ case "j":
58
+ selected = (selected + 1) % options.length; // wrap around the bottom
59
+ draw(false);
60
+ break;
61
+ case "return":
62
+ case "enter":
63
+ finish(selected); // commit the choice
64
+ break;
65
+ case "escape":
66
+ finish(-1); // bail
67
+ break;
68
+ default:
69
+ if (key.ctrl && key.name === "c")
70
+ finish(-1); // Ctrl+C in raw mode arrives as a keypress, not a signal
71
+ }
72
+ };
73
+ try {
74
+ rl.pause(); // stop the line reader from turning our keystrokes into lines
75
+ if (input === process.stdin)
76
+ readline.emitKeypressEvents(input); // turn raw bytes into named keys (real stdin only)
77
+ input.setRawMode(true); // keys arrive immediately, no Enter needed
78
+ input.resume(); // CRUCIAL: rl.pause() paused the stream — resume it or no keypresses reach us (menu hangs)
79
+ input.on("keypress", onKey);
80
+ draw(true); // first paint
81
+ }
82
+ catch {
83
+ finish(-1); // anything unexpected in setup → cancel, caller falls back to typing
84
+ }
85
+ });
86
+ }
87
+ // The multi-question form (Day 30): the agent asks several questions at once,
88
+ // the user answers each and submits, and we hand back organized answers. Same
89
+ // raw-mode borrow/return dance as promptSelect; the state machine is in form.ts.
90
+ // Returns the answers, or null if cancelled / not a TTY.
91
+ export function promptForm(rl, questions, input = process.stdin) {
92
+ return new Promise((resolve) => {
93
+ if (!input.isTTY || !questions.length)
94
+ return resolve(null); // no form without a terminal or questions
95
+ let state = initFormState(questions);
96
+ let done = false;
97
+ const wasRaw = input.isRaw === true; // restore this on exit, not false (see promptSelect)
98
+ const lineCount = renderForm(questions, state).split("\n").length; // constant for these questions
99
+ const draw = (first) => {
100
+ if (!first)
101
+ process.stdout.write(`\x1b[${lineCount}A`); // up over the whole form
102
+ process.stdout.write(`\x1b[J${renderForm(questions, state)}\n`); // clear down, repaint
103
+ };
104
+ const finish = (result) => {
105
+ if (done)
106
+ return;
107
+ done = true;
108
+ input.off("keypress", onKey);
109
+ try {
110
+ input.setRawMode(wasRaw); // restore prior mode; forcing false breaks the live readline → REPL exits
111
+ }
112
+ catch {
113
+ /* ignore */
114
+ }
115
+ rl.resume();
116
+ resolve(result);
117
+ };
118
+ const onKey = (_str, key) => {
119
+ if (!key || done)
120
+ return;
121
+ if (key.name === "up" || key.name === "k") {
122
+ state = reduceForm(questions, state, "up").state;
123
+ draw(false);
124
+ }
125
+ else if (key.name === "down" || key.name === "j" || key.name === "tab") {
126
+ state = reduceForm(questions, state, "down").state;
127
+ draw(false);
128
+ }
129
+ else if (key.name === "return" || key.name === "enter" || key.name === "space") {
130
+ const next = reduceForm(questions, state, "select");
131
+ state = next.state;
132
+ if (next.done)
133
+ finish(collectAnswers(questions, state)); // submitted with all answered
134
+ else
135
+ draw(false);
136
+ }
137
+ else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
138
+ finish(null); // cancel
139
+ }
140
+ };
141
+ try {
142
+ rl.pause();
143
+ if (input === process.stdin)
144
+ readline.emitKeypressEvents(input);
145
+ input.setRawMode(true);
146
+ input.resume(); // resume the stream rl.pause() paused, or no keypresses reach the form
147
+ input.on("keypress", onKey);
148
+ draw(true);
149
+ }
150
+ catch {
151
+ finish(null);
152
+ }
153
+ });
154
+ }
@@ -80,9 +80,64 @@ function checkBash(command) {
80
80
  // Fail closed: a command we don't recognize is a question for the human.
81
81
  return { decision: "ask", reason: "unrecognized command", summary };
82
82
  }
83
+ // ---- Plan mode (Day 20) ------------------------------------------------------
84
+ // A research-only mode. While it is on, the agent may observe — read files,
85
+ // search, run safe read-only shell — but every tool that mutates the world is
86
+ // blocked, until it presents a plan and the user approves it. The state lives
87
+ // here because the gate is what enforces it; the REPL toggles it with /plan, and
88
+ // the exit_plan_mode tool turns it off once the user approves a plan.
89
+ let planMode = false; // off by default — most sessions never touch it
90
+ export const isPlanMode = () => planMode; // the REPL reads this to mark its prompt
91
+ export const setPlanMode = (on) => { planMode = on; }; // /plan and exit_plan_mode flip it
92
+ // In plan mode, is this call safe — i.e. does it only observe, never mutate?
93
+ // run_bash is decided by its own classifier: a command the gate already rated
94
+ // "allow" (ls, cat, git status) observes; "ask"/"deny" ones (rm, sudo) mutate.
95
+ function planSafe(toolName, verdict) {
96
+ switch (toolName) {
97
+ case "read_file":
98
+ case "search":
99
+ case "task": // the sub-agent's own calls hit this same gate, still in plan mode
100
+ case "spawn_teammate": // a teammate's own calls hit this same gate (plan mode still blocks their writes)
101
+ case "send_message": // coordination only — changes nothing on disk
102
+ case "request_shutdown": // team protocol (Day 39): coordination, no side effects
103
+ case "request_plan":
104
+ case "review_plan":
105
+ case "submit_plan":
106
+ case "create_task": // task board (Day 40): touches the internal board, not the user's project
107
+ case "list_tasks":
108
+ case "claim_task":
109
+ case "complete_task":
110
+ case "ask_user": // asking the user a question is safe in plan mode — it doesn't change anything
111
+ case "skill": // loading a skill's instructions is read-only; what it does is gated per-call
112
+ case "todo_write": // planning is exactly what plan mode is FOR — never block it
113
+ case "bash_output": // reading a background task's output observes, never mutates
114
+ case "exit_plan_mode": // the way OUT of plan mode must never be blocked by plan mode
115
+ return true;
116
+ case "run_bash":
117
+ case "run_bash_background":
118
+ return verdict.decision === "allow"; // only the read-only commands the classifier cleared
119
+ default:
120
+ return false; // write_file, edit_file, MCP and unknown tools: all mutate-or-unknown
121
+ }
122
+ }
83
123
  // ---- The single entry point ---------------------------------------------------
84
- // Called by the loop for EVERY tool call, before anything executes.
124
+ // Called by the loop for EVERY tool call, before anything executes. Plan mode is
125
+ // applied as an outer filter: it can only TIGHTEN the base decision (downgrade a
126
+ // mutating allow/ask to deny), never loosen one — a base "deny" keeps its more
127
+ // specific reason, because deny always wins.
85
128
  export function checkPermission(toolName, argsJson) {
129
+ const base = basePermission(toolName, argsJson);
130
+ if (planMode && base.decision !== "deny" && !planSafe(toolName, base)) {
131
+ return {
132
+ decision: "deny",
133
+ reason: "plan mode is on — investigate with read-only tools, then call exit_plan_mode to present a plan for the user to approve before you change anything",
134
+ summary: base.summary,
135
+ };
136
+ }
137
+ return base;
138
+ }
139
+ // The underlying rules, plan-mode-agnostic. Wrapped by checkPermission above.
140
+ function basePermission(toolName, argsJson) {
86
141
  // A user-configured tool block beats everything, including built-in allows.
87
142
  if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
88
143
  return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
@@ -102,6 +157,40 @@ export function checkPermission(toolName, argsJson) {
102
157
  // Spawning a sub-agent is orchestration, not action: every tool the
103
158
  // sub-agent uses goes through this same gate individually.
104
159
  return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
160
+ case "spawn_teammate":
161
+ // Same as task: spawning is orchestration. Each teammate's own tool calls
162
+ // pass through this gate; a teammate's non-interactive policy auto-proceeds
163
+ // on writes but a hard DENY here still stands (deny always wins).
164
+ return { decision: "allow", reason: "teammate tools are gated individually", summary: "spawn_teammate" };
165
+ case "send_message":
166
+ // Dropping a message in another agent's mailbox has no filesystem effect on
167
+ // the user's project — it is pure team coordination.
168
+ return { decision: "allow", reason: "team coordination, no side effects", summary: "send_message" };
169
+ case "request_shutdown":
170
+ case "request_plan":
171
+ case "review_plan":
172
+ case "submit_plan":
173
+ // Team protocol messages (Day 39) — request/response coordination over the
174
+ // mailbox. No filesystem effect; the work they gate still passes the gate.
175
+ return { decision: "allow", reason: "team protocol, no side effects", summary: toolName };
176
+ case "create_task":
177
+ case "list_tasks":
178
+ case "claim_task":
179
+ case "complete_task":
180
+ // Task board (Day 40) — bookkeeping on the internal board, not the user's
181
+ // project. The actual work a claimed task drives still passes this gate.
182
+ return { decision: "allow", reason: "task board, no project side effects", summary: toolName };
183
+ case "ask_user":
184
+ // Asking the user a question has no side effects — it's the safest thing
185
+ // the model can do. Never gate it behind an approval prompt.
186
+ return { decision: "allow", reason: "asks the user, no side effects", summary: "ask_user" };
187
+ case "skill":
188
+ // Loading a skill just returns instructions; whatever the skill then DOES
189
+ // goes through this same gate, call by call. Loading itself is free.
190
+ return { decision: "allow", reason: "loads instructions, no side effects", summary: "skill" };
191
+ case "todo_write":
192
+ // Writing the plan touches nothing but in-memory state and the screen.
193
+ return { decision: "allow", reason: "updates the plan, no side effects", summary: "todo_write" };
105
194
  case "read_file": {
106
195
  const p = args.path ?? ""; // the file the model wants to read
107
196
  if (SECRET_FILE_RE.test(path.resolve(p))) {
@@ -124,9 +213,40 @@ export function checkPermission(toolName, argsJson) {
124
213
  return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
125
214
  }
126
215
  case "run_bash":
127
- return checkBash(args.command ?? ""); // bash gets input-aware analysis
216
+ case "run_bash_background": {
217
+ // Backgrounding changes WHEN output comes back, never WHAT runs — so a
218
+ // background command gets the exact same input-aware analysis as a
219
+ // foreground one. The danger is in the command, not the blocking.
220
+ const verdict = checkBash(args.command ?? ""); // bash gets input-aware analysis
221
+ // "Don't ask again for run_bash this session" (chosen in the approval menu)
222
+ // upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
223
+ // stands. Convenience never overrides the no-fly rules. A grant for either
224
+ // shell tool covers the other: the command is what was vouched for.
225
+ if (verdict.decision === "ask" && (CONFIG.permissions.allow.includes(`tool:${toolName}`) || CONFIG.permissions.allow.includes("tool:run_bash"))) {
226
+ return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
227
+ }
228
+ return verdict;
229
+ }
230
+ case "bash_output":
231
+ // Polling a background task only reads its captured output and status —
232
+ // it touches nothing on the user's filesystem. Always safe.
233
+ return { decision: "allow", reason: "reads background task output, no side effects", summary: args.task_id ?? "bash_output" };
234
+ case "exit_plan_mode": {
235
+ // Outside plan mode this tool does nothing — let it through silently. In
236
+ // plan mode it is the approval gate: ask the human, showing the plan, and
237
+ // only on yes does the tool run and flip plan mode off.
238
+ if (!planMode)
239
+ return { decision: "allow", reason: "not in plan mode (no-op)", summary: "exit_plan_mode" };
240
+ return { decision: "ask", reason: "leave plan mode and start implementing", summary: args.plan ?? "(no plan provided)" };
241
+ }
128
242
  default:
129
- // A tool we don't know gets the most suspicious treatment, not the least.
130
- return { decision: "ask", reason: "unknown tool", summary: toolName };
243
+ // Everything else — unknown tools and MCP tools (mcp__server__tool) —
244
+ // gets the most suspicious treatment, not the least: ask. The user can
245
+ // pre-approve a trusted MCP tool with "tool:mcp__server__tool" in
246
+ // settings, or block one with "tool:..." in deny (checked at the top).
247
+ if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
248
+ return { decision: "allow", reason: "pre-approved by your settings", summary: toolName };
249
+ }
250
+ return { decision: "ask", reason: toolName.startsWith("mcp__") ? "external MCP tool" : "unknown tool", summary: toolName };
131
251
  }
132
252
  }
package/dist/prompt.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs"; // to load the optional AGENT.md project memory
2
2
  import path from "node:path"; // to resolve it from the current directory
3
+ import { memoryContext } from "./memory.js"; // long-term cross-session memory block
3
4
  // The static constitution: identical for every session, every user, every day.
4
5
  // NOTHING session-specific belongs here — DeepSeek caches by prefix, and a
5
6
  // single changed byte (a date, a username, a cwd) would break the cache for
@@ -33,21 +34,41 @@ Rules:
33
34
  - Use your tools to do the work. Verify what you claim — do not guess.
34
35
  - Your FINAL message is returned to the parent verbatim. Make it a compact, factual report: findings, exact paths, numbers, code snippets. No greetings, no questions back.
35
36
  - You cannot talk to the user. If the task cannot be completed, report precisely what is missing.`;
37
+ // The teammate constitution (Day 38, extended Day 39). Unlike a sub-agent, a
38
+ // teammate is persistent and CONNECTED: it works across many rounds, talks to
39
+ // the team through mailboxes, and coordinates through explicit PROTOCOLS rather
40
+ // than guessing. Load-bearing rules: keep the lead informed; propose-before-act
41
+ // on risky work (submit_plan); and don't exit on your own — report, then idle
42
+ // until the lead shuts you down. {name}/{role} are filled in at spawn time.
43
+ export const TEAMMATE_PROMPT = `You are {name}, a teammate on an agent team. Your role: {role}.
44
+
45
+ How a team works:
46
+ - You work alongside other teammates, coordinated by the "lead". You all run at the same time.
47
+ - There is a shared TASK BOARD. When you're idle you're automatically offered the next ready task ("You claimed task ..."). Just do it, then call complete_task — you'll be offered the next one. You can also list_tasks to see the board, claim_task a specific one, or create_task to add work you discover.
48
+ - Use send_message to coordinate: report progress or findings to "lead" as you go, or ask a specific teammate something. Messages you receive appear as "[message from X]" — read them and act.
49
+ - Before any RISKY or far-reaching change (refactoring auth, migrating a schema, deleting/rewriting many files), call submit_plan FIRST and wait for the lead's approval. Only start once it's approved; if rejected, revise and submit again.
50
+ - You CANNOT talk to the human and you CANNOT spawn teammates. Decide within your role; if you're blocked, message the lead and say precisely what you need.
51
+ - Use your tools (read_file, edit_file, write_file, search, run_bash) to do real work. Verify what you claim — do not guess.
52
+ - When your current task is done, complete_task it and report to the lead, then STOP. You'll idle and be offered more board work, or follow-up messages. Do NOT decide on your own that the whole job is over — the lead ends your run by requesting shutdown, and you'll exit cleanly then.`;
36
53
  // Project memory: an optional AGENT.md in the current directory — the
37
54
  // equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
38
55
  // within a session (cache-friendly), different across projects (by design).
39
56
  const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes attention
40
- // Build the complete system message for this session.
57
+ // Build the complete system message for this session: the constitution, then
58
+ // optional AGENT.md instructions, then optional long-term memory. All three are
59
+ // resolved once per session for a stable, cacheable prefix.
41
60
  export function buildSystemMessage() {
61
+ let msg = SYSTEM_PROMPT;
42
62
  const p = path.join(process.cwd(), "AGENT.md"); // only the current directory — V1 keeps lookup simple
43
- if (!fs.existsSync(p))
44
- return SYSTEM_PROMPT; // no project memory — constitution only
45
- const memory = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
46
- // The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
47
- // compliance; imperative wrappers measurably do better.
48
- return `${SYSTEM_PROMPT}
49
-
50
- The user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:
51
-
52
- ${memory}`;
63
+ if (fs.existsSync(p)) {
64
+ const agentMd = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
65
+ // The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
66
+ // compliance; imperative wrappers measurably do better.
67
+ msg += `\n\nThe user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:\n\n${agentMd}`;
68
+ }
69
+ // Long-term memory is BACKGROUND, not an override: facts you established
70
+ // earlier, to be verified against the code — not hard rules. The different
71
+ // framing matters (see memory.ts).
72
+ msg += memoryContext();
73
+ return msg;
53
74
  }
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
- const SESSIONS_DIR = path.resolve(".mini-agent", "sessions");
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
- fs.mkdirSync(SESSIONS_DIR, { recursive: true }); // first save creates the directory
18
- const file = path.join(SESSIONS_DIR, `${id}.json`); // the real destination
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
- if (!fs.existsSync(SESSIONS_DIR))
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(SESSIONS_DIR, files[0].f), "utf8")); // the newest snapshot
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 files = fs
55
- .readdirSync(SESSIONS_DIR)
56
- .filter((f) => f.endsWith(".json"))
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
+ }