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/README.md +44 -18
- package/dist/agent.js +446 -23
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/editor.js +307 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +862 -22
- package/dist/markdown.js +152 -0
- 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 +124 -4
- package/dist/prompt.js +32 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +133 -5
- package/dist/tui.js +67 -0
- package/dist/ui.js +138 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +7 -3
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
|
+
}
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
130
|
-
|
|
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 (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
+
}
|