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/memory.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs"; // memory lives in a file on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// Long-term memory: facts the agent should carry ACROSS sessions, not just
|
|
4
|
+
// within one conversation. Session persistence (Day 12) restores "what we were
|
|
5
|
+
// talking about"; this restores "what I should always know about this project".
|
|
6
|
+
//
|
|
7
|
+
// One Markdown file, project-local, human-readable and human-editable. Not a
|
|
8
|
+
// vector database, not embeddings: a file you can open, read, and fix.
|
|
9
|
+
//
|
|
10
|
+
// Two ways facts get in: the model calls `remember` explicitly, OR — when
|
|
11
|
+
// auto-extract is on — after each turn a cheap model call reads the conversation
|
|
12
|
+
// and saves what's worth keeping (especially turns where the USER corrected the
|
|
13
|
+
// assistant: 90% the model gets right isn't worth saving, the 10% it got wrong
|
|
14
|
+
// and was corrected on is gold). Every fact is TYPED so the model can tell "this
|
|
15
|
+
// is who the user is" from "this is a correction" from "this is a project fact".
|
|
16
|
+
export const MEMORY_PATH = path.resolve(".mini-agent", "MEMORY.md");
|
|
17
|
+
// The four memory types (handbook §17). feedback is the most valuable.
|
|
18
|
+
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
|
|
19
|
+
// Bounds, because unbounded memory dilutes the model's attention (Ch6: too many
|
|
20
|
+
// rules → worse compliance) and grows the cached prefix forever. On overflow we
|
|
21
|
+
// drop the OLDEST — recent facts are likelier still true.
|
|
22
|
+
const MAX_ENTRIES = 40; // at most this many remembered facts
|
|
23
|
+
const MAX_BYTES = 12_000; // ...and at most this many bytes total
|
|
24
|
+
const HEADER = "# mini-agent long-term memory\n# One fact per line: - [type] fact. Injected at session start. Edit freely.\n";
|
|
25
|
+
// Coerce any string to a valid type, defaulting to "project".
|
|
26
|
+
function asType(t) {
|
|
27
|
+
return MEMORY_TYPES.includes(t ?? "") ? t : "project";
|
|
28
|
+
}
|
|
29
|
+
// Parse one bullet's text into a typed entry. "[feedback] foo" → feedback; a
|
|
30
|
+
// bare "foo" (legacy / hand-written) → project.
|
|
31
|
+
function parseEntry(text) {
|
|
32
|
+
const m = text.match(/^\[(\w+)\]\s*(.*)$/);
|
|
33
|
+
if (m && MEMORY_TYPES.includes(m[1]))
|
|
34
|
+
return { type: m[1], fact: m[2].trim() };
|
|
35
|
+
return { type: "project", fact: text.trim() };
|
|
36
|
+
}
|
|
37
|
+
// Read facts WITH their types. The fiddly parsing lives here.
|
|
38
|
+
export function readMemoryTyped() {
|
|
39
|
+
try {
|
|
40
|
+
return fs
|
|
41
|
+
.readFileSync(MEMORY_PATH, "utf8")
|
|
42
|
+
.split("\n")
|
|
43
|
+
.filter((l) => l.startsWith("- "))
|
|
44
|
+
.map((l) => parseEntry(l.slice(2).trim()))
|
|
45
|
+
.filter((e) => e.fact);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return []; // no memory file yet — fine
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Read just the fact text (no type). Kept for callers that only want the facts.
|
|
52
|
+
export function readMemory() {
|
|
53
|
+
return readMemoryTyped().map((e) => e.fact);
|
|
54
|
+
}
|
|
55
|
+
// The block injected into the system message, grouped by type so the model can
|
|
56
|
+
// weigh "a correction" differently from "a project fact". Empty when there's
|
|
57
|
+
// nothing, so the cached prefix stays clean for projects that never use it.
|
|
58
|
+
export function memoryContext() {
|
|
59
|
+
const entries = readMemoryTyped();
|
|
60
|
+
if (!entries.length)
|
|
61
|
+
return "";
|
|
62
|
+
const labels = {
|
|
63
|
+
user: "About the user",
|
|
64
|
+
feedback: "Guidance & corrections the user gave you (follow these)",
|
|
65
|
+
project: "Project facts & decisions",
|
|
66
|
+
reference: "References",
|
|
67
|
+
};
|
|
68
|
+
let out = "\n\nLong-term project memory (background from earlier sessions; verify against the code before relying on a specific claim):";
|
|
69
|
+
for (const type of MEMORY_TYPES) {
|
|
70
|
+
const group = entries.filter((e) => e.type === type);
|
|
71
|
+
if (group.length)
|
|
72
|
+
out += `\n[${labels[type]}]\n${group.map((e) => `- ${e.fact}`).join("\n")}`;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
// Append a typed fact, then enforce the caps. Self-healing: every write re-applies
|
|
77
|
+
// the bounds, so the file can never grow past them even if hand-edited huge.
|
|
78
|
+
export function addFact(fact, type = "project") {
|
|
79
|
+
const clean = fact.replace(/\s+/g, " ").trim(); // one line, normalized whitespace
|
|
80
|
+
if (!clean)
|
|
81
|
+
return "[error] nothing to remember (empty fact).";
|
|
82
|
+
const t = asType(type); // coerce a bad type to "project" so the on-disk format stays parseable
|
|
83
|
+
let entries = readMemoryTyped();
|
|
84
|
+
if (entries.some((e) => e.fact === clean))
|
|
85
|
+
return "Already remembered — no change."; // idempotent
|
|
86
|
+
entries.push({ type: t, fact: clean });
|
|
87
|
+
if (entries.length > MAX_ENTRIES)
|
|
88
|
+
entries = entries.slice(entries.length - MAX_ENTRIES); // drop oldest
|
|
89
|
+
let body = entries.map((e) => `- [${e.type}] ${e.fact}`).join("\n");
|
|
90
|
+
while (Buffer.byteLength(HEADER + body, "utf8") > MAX_BYTES && entries.length > 1) {
|
|
91
|
+
entries.shift(); // still too big — drop the oldest until it fits
|
|
92
|
+
body = entries.map((e) => `- [${e.type}] ${e.fact}`).join("\n");
|
|
93
|
+
}
|
|
94
|
+
fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
|
|
95
|
+
fs.writeFileSync(MEMORY_PATH, HEADER + body + "\n", "utf8");
|
|
96
|
+
return `Remembered (${t}). (${entries.length} fact${entries.length === 1 ? "" : "s"} stored.)`;
|
|
97
|
+
}
|
|
98
|
+
// The tool the model calls to save something worth keeping.
|
|
99
|
+
export const rememberTool = {
|
|
100
|
+
definition: {
|
|
101
|
+
type: "function",
|
|
102
|
+
function: {
|
|
103
|
+
name: "remember",
|
|
104
|
+
description: `Save a durable fact to long-term memory (persists across sessions, shown to you at the start of every future session).
|
|
105
|
+
Use it for things still true next time: the build/test command, a gotcha, a convention, a decision and its reason, a user preference, or a correction the user gave you.
|
|
106
|
+
Do NOT use it for transient state (current task, today's bug). Keep each fact to one sentence. Pick the right "type".`,
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
fact: { type: "string", description: "One durable fact, as a single sentence" },
|
|
111
|
+
type: { type: "string", enum: [...MEMORY_TYPES], description: "user (who they are/prefs) · feedback (a correction/guidance) · project (a decision/convention/gotcha) · reference (a URL/doc)" },
|
|
112
|
+
},
|
|
113
|
+
required: ["fact"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
run: (args) => addFact(args.fact ?? "", asType(args.type)),
|
|
118
|
+
};
|
|
119
|
+
// ---- auto-extract -----------------------------------------------------------
|
|
120
|
+
// After a turn, read the conversation and save what's worth keeping. The high-
|
|
121
|
+
// frequency write path from §17 — so memory captures itself instead of relying
|
|
122
|
+
// on the model to remember to call `remember`.
|
|
123
|
+
const EXTRACT_SYSTEM = `You extract DURABLE memories from a slice of a coding-assistant conversation — facts worth carrying into FUTURE sessions.
|
|
124
|
+
Output ONLY a JSON array. Each item: {"type": "...", "fact": "one sentence"}. No prose, no markdown fences.
|
|
125
|
+
"type" is one of:
|
|
126
|
+
- "user": who the user is — role, preferences, how they like to work.
|
|
127
|
+
- "feedback": guidance or a CORRECTION the user gave the assistant. These are the MOST valuable — prioritize any turn where the user corrected or redirected the assistant.
|
|
128
|
+
- "project": a goal, decision, convention, or non-obvious gotcha about THIS project.
|
|
129
|
+
- "reference": a pointer to an external resource (a URL, a doc, a ticket).
|
|
130
|
+
Do NOT save: transient task state, the bug currently being fixed, or anything obvious from reading the code. Keep each fact to one sentence.
|
|
131
|
+
If nothing is worth saving, output [].`;
|
|
132
|
+
// Parse the model's extraction output into typed entries. Lenient: finds the
|
|
133
|
+
// JSON array even if wrapped in prose/fences, drops malformed items. Pure.
|
|
134
|
+
export function parseExtractedMemories(text) {
|
|
135
|
+
const m = text.match(/\[[\s\S]*\]/); // the first […] — tolerate surrounding chatter
|
|
136
|
+
if (!m)
|
|
137
|
+
return [];
|
|
138
|
+
let arr;
|
|
139
|
+
try {
|
|
140
|
+
arr = JSON.parse(m[0]);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(arr))
|
|
146
|
+
return [];
|
|
147
|
+
return arr
|
|
148
|
+
.filter((x) => !!x && typeof x === "object")
|
|
149
|
+
.filter((x) => typeof x.fact === "string" && x.fact.trim())
|
|
150
|
+
.map((x) => ({ type: asType(x.type), fact: String(x.fact).replace(/\s+/g, " ").trim() }));
|
|
151
|
+
}
|
|
152
|
+
// Build the transcript handed to the extractor: the last few user/assistant
|
|
153
|
+
// turns (string content only), each capped, so the extraction stays cheap.
|
|
154
|
+
function buildTranscript(messages) {
|
|
155
|
+
const turns = messages
|
|
156
|
+
.filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim())
|
|
157
|
+
.slice(-12) // recent context is what matters
|
|
158
|
+
.map((m) => `${m.role}: ${String(m.content).split("\n\n[Referenced files")[0].slice(0, 600)}`);
|
|
159
|
+
return turns.join("\n\n");
|
|
160
|
+
}
|
|
161
|
+
// Run one extraction pass: a single (cheap) model call, parse, save. Returns the
|
|
162
|
+
// facts it saved. Never throws — a failed extraction must not break the session.
|
|
163
|
+
export async function extractMemories(client, model, messages) {
|
|
164
|
+
const transcript = buildTranscript(messages);
|
|
165
|
+
if (!transcript.trim())
|
|
166
|
+
return [];
|
|
167
|
+
let text = "";
|
|
168
|
+
try {
|
|
169
|
+
const res = await client.chat.completions.create({
|
|
170
|
+
model,
|
|
171
|
+
messages: [
|
|
172
|
+
{ role: "system", content: EXTRACT_SYSTEM },
|
|
173
|
+
{ role: "user", content: transcript },
|
|
174
|
+
],
|
|
175
|
+
stream: false,
|
|
176
|
+
});
|
|
177
|
+
text = res.choices?.[0]?.message?.content ?? "";
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return []; // network/model error — skip silently, try again next turn
|
|
181
|
+
}
|
|
182
|
+
const extracted = parseExtractedMemories(text);
|
|
183
|
+
for (const e of extracted)
|
|
184
|
+
addFact(e.fact, e.type); // addFact dedups internally
|
|
185
|
+
return extracted;
|
|
186
|
+
}
|
package/dist/mentions.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs"; // read referenced files
|
|
2
|
+
import path from "node:path"; // resolve mention paths
|
|
3
|
+
import { checkPermission } from "./permissions.js"; // reuse the read_file gate — secret files must never leak in
|
|
4
|
+
// @file mentions: let the user pull a file into the conversation just by naming
|
|
5
|
+
// it — "explain @src/loop.ts" attaches the file's content so the model sees it
|
|
6
|
+
// without a separate read_file round-trip. The point is convenience, but the
|
|
7
|
+
// SAME rule still holds: a secret file (.env, *.pem) must never enter the
|
|
8
|
+
// context. So every mention is routed through the read_file permission gate
|
|
9
|
+
// (Day 4) — if it would deny a read, the mention is refused, not read.
|
|
10
|
+
// @ must start the token (line start or after whitespace) so emails (a@b) and
|
|
11
|
+
// npm scopes inside words don't trigger. The path runs to the next space; we
|
|
12
|
+
// trim trailing sentence punctuation so "see @cart.js." doesn't include the dot.
|
|
13
|
+
const MENTION_RE = /(?:^|\s)@(\S+)/g;
|
|
14
|
+
const MAX_MENTION_BYTES = 100_000; // a referenced file bigger than this is truncated
|
|
15
|
+
// Pull the raw @tokens out of a line (trailing , ; : ! ? ) stripped).
|
|
16
|
+
export function findMentions(line) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const m of line.matchAll(MENTION_RE)) {
|
|
19
|
+
const raw = m[1].replace(/[.,;:!?)\]]+$/, ""); // drop trailing punctuation, keep inner dots (cart.js)
|
|
20
|
+
if (raw)
|
|
21
|
+
out.push(raw);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
// Resolve one mention to a file we may attach. Never throws.
|
|
26
|
+
function resolveMention(raw) {
|
|
27
|
+
const abs = path.resolve(raw);
|
|
28
|
+
// The gate decides if a read is allowed; a secret file comes back "deny".
|
|
29
|
+
if (checkPermission("read_file", JSON.stringify({ path: raw })).decision === "deny") {
|
|
30
|
+
return { raw, path: abs, status: "denied" };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(abs);
|
|
34
|
+
if (stat.isDirectory())
|
|
35
|
+
return { raw, path: abs, status: "dir" };
|
|
36
|
+
let content = fs.readFileSync(abs, "utf8");
|
|
37
|
+
if (content.length > MAX_MENTION_BYTES)
|
|
38
|
+
content = content.slice(0, MAX_MENTION_BYTES) + `\n... (truncated, file is ${content.length} chars)`;
|
|
39
|
+
return { raw, path: abs, status: "ok", content };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { raw, path: abs, status: "missing" }; // not a file — probably not a reference at all
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Expand a user line: find @mentions, attach the readable ones, and refuse the
|
|
46
|
+
// secret ones out loud. Returns the augmented message (file blocks appended) and
|
|
47
|
+
// the mention list (for the REPL to summarize). A line with no resolvable
|
|
48
|
+
// mention is returned unchanged — @tokens that don't name a real file (an email,
|
|
49
|
+
// a stray @) are ignored silently so we don't nag on every "@" in prose.
|
|
50
|
+
export function expandMentions(line) {
|
|
51
|
+
const mentions = findMentions(line).map(resolveMention);
|
|
52
|
+
// Only attach files we actually read, and announce refusals (security-relevant).
|
|
53
|
+
// "missing"/"dir" are dropped silently — likely not file references.
|
|
54
|
+
const attach = mentions.filter((m) => m.status === "ok" || m.status === "denied");
|
|
55
|
+
if (!attach.length)
|
|
56
|
+
return { augmented: line, mentions };
|
|
57
|
+
const blocks = attach.map((m) => m.status === "ok"
|
|
58
|
+
? `--- ${m.raw} ---\n${m.content}`
|
|
59
|
+
: `--- ${m.raw} (refused: secret file, not included) ---`);
|
|
60
|
+
const augmented = `${line}\n\n[Referenced files — the user attached these with @mentions]\n${blocks.join("\n\n")}`;
|
|
61
|
+
return { augmented, mentions };
|
|
62
|
+
}
|
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,51 @@ 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 "ask_user": // asking the user a question is safe in plan mode — it doesn't change anything
|
|
101
|
+
case "skill": // loading a skill's instructions is read-only; what it does is gated per-call
|
|
102
|
+
case "exit_plan_mode": // the way OUT of plan mode must never be blocked by plan mode
|
|
103
|
+
return true;
|
|
104
|
+
case "run_bash":
|
|
105
|
+
return verdict.decision === "allow"; // only the read-only commands the classifier cleared
|
|
106
|
+
default:
|
|
107
|
+
return false; // write_file, edit_file, MCP and unknown tools: all mutate-or-unknown
|
|
108
|
+
}
|
|
109
|
+
}
|
|
83
110
|
// ---- The single entry point ---------------------------------------------------
|
|
84
|
-
// Called by the loop for EVERY tool call, before anything executes.
|
|
111
|
+
// Called by the loop for EVERY tool call, before anything executes. Plan mode is
|
|
112
|
+
// applied as an outer filter: it can only TIGHTEN the base decision (downgrade a
|
|
113
|
+
// mutating allow/ask to deny), never loosen one — a base "deny" keeps its more
|
|
114
|
+
// specific reason, because deny always wins.
|
|
85
115
|
export function checkPermission(toolName, argsJson) {
|
|
116
|
+
const base = basePermission(toolName, argsJson);
|
|
117
|
+
if (planMode && base.decision !== "deny" && !planSafe(toolName, base)) {
|
|
118
|
+
return {
|
|
119
|
+
decision: "deny",
|
|
120
|
+
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",
|
|
121
|
+
summary: base.summary,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return base;
|
|
125
|
+
}
|
|
126
|
+
// The underlying rules, plan-mode-agnostic. Wrapped by checkPermission above.
|
|
127
|
+
function basePermission(toolName, argsJson) {
|
|
86
128
|
// A user-configured tool block beats everything, including built-in allows.
|
|
87
129
|
if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
|
|
88
130
|
return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
|
|
@@ -102,6 +144,14 @@ export function checkPermission(toolName, argsJson) {
|
|
|
102
144
|
// Spawning a sub-agent is orchestration, not action: every tool the
|
|
103
145
|
// sub-agent uses goes through this same gate individually.
|
|
104
146
|
return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
|
|
147
|
+
case "ask_user":
|
|
148
|
+
// Asking the user a question has no side effects — it's the safest thing
|
|
149
|
+
// the model can do. Never gate it behind an approval prompt.
|
|
150
|
+
return { decision: "allow", reason: "asks the user, no side effects", summary: "ask_user" };
|
|
151
|
+
case "skill":
|
|
152
|
+
// Loading a skill just returns instructions; whatever the skill then DOES
|
|
153
|
+
// goes through this same gate, call by call. Loading itself is free.
|
|
154
|
+
return { decision: "allow", reason: "loads instructions, no side effects", summary: "skill" };
|
|
105
155
|
case "read_file": {
|
|
106
156
|
const p = args.path ?? ""; // the file the model wants to read
|
|
107
157
|
if (SECRET_FILE_RE.test(path.resolve(p))) {
|
|
@@ -123,10 +173,32 @@ export function checkPermission(toolName, argsJson) {
|
|
|
123
173
|
}
|
|
124
174
|
return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
|
|
125
175
|
}
|
|
126
|
-
case "run_bash":
|
|
127
|
-
|
|
176
|
+
case "run_bash": {
|
|
177
|
+
const verdict = checkBash(args.command ?? ""); // bash gets input-aware analysis
|
|
178
|
+
// "Don't ask again for run_bash this session" (chosen in the approval menu)
|
|
179
|
+
// upgrades an ASK to ALLOW — but a hard DENY (rm -rf /, .git, .env…) always
|
|
180
|
+
// stands. Convenience never overrides the no-fly rules.
|
|
181
|
+
if (verdict.decision === "ask" && CONFIG.permissions.allow.includes("tool:run_bash")) {
|
|
182
|
+
return { decision: "allow", reason: "bash pre-approved for this session", summary: verdict.summary };
|
|
183
|
+
}
|
|
184
|
+
return verdict;
|
|
185
|
+
}
|
|
186
|
+
case "exit_plan_mode": {
|
|
187
|
+
// Outside plan mode this tool does nothing — let it through silently. In
|
|
188
|
+
// plan mode it is the approval gate: ask the human, showing the plan, and
|
|
189
|
+
// only on yes does the tool run and flip plan mode off.
|
|
190
|
+
if (!planMode)
|
|
191
|
+
return { decision: "allow", reason: "not in plan mode (no-op)", summary: "exit_plan_mode" };
|
|
192
|
+
return { decision: "ask", reason: "leave plan mode and start implementing", summary: args.plan ?? "(no plan provided)" };
|
|
193
|
+
}
|
|
128
194
|
default:
|
|
129
|
-
//
|
|
130
|
-
|
|
195
|
+
// Everything else — unknown tools and MCP tools (mcp__server__tool) —
|
|
196
|
+
// gets the most suspicious treatment, not the least: ask. The user can
|
|
197
|
+
// pre-approve a trusted MCP tool with "tool:mcp__server__tool" in
|
|
198
|
+
// settings, or block one with "tool:..." in deny (checked at the top).
|
|
199
|
+
if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
|
|
200
|
+
return { decision: "allow", reason: "pre-approved by your settings", summary: toolName };
|
|
201
|
+
}
|
|
202
|
+
return { decision: "ask", reason: toolName.startsWith("mcp__") ? "external MCP tool" : "unknown tool", summary: toolName };
|
|
131
203
|
}
|
|
132
204
|
}
|
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
|
|
@@ -37,17 +38,21 @@ Rules:
|
|
|
37
38
|
// equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
|
|
38
39
|
// within a session (cache-friendly), different across projects (by design).
|
|
39
40
|
const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes attention
|
|
40
|
-
// Build the complete system message for this session
|
|
41
|
+
// Build the complete system message for this session: the constitution, then
|
|
42
|
+
// optional AGENT.md instructions, then optional long-term memory. All three are
|
|
43
|
+
// resolved once per session for a stable, cacheable prefix.
|
|
41
44
|
export function buildSystemMessage() {
|
|
45
|
+
let msg = SYSTEM_PROMPT;
|
|
42
46
|
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
|
-
|
|
47
|
+
if (fs.existsSync(p)) {
|
|
48
|
+
const agentMd = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
|
|
49
|
+
// The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
|
|
50
|
+
// compliance; imperative wrappers measurably do better.
|
|
51
|
+
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}`;
|
|
52
|
+
}
|
|
53
|
+
// Long-term memory is BACKGROUND, not an override: facts you established
|
|
54
|
+
// earlier, to be verified against the code — not hard rules. The different
|
|
55
|
+
// framing matters (see memory.ts).
|
|
56
|
+
msg += memoryContext();
|
|
57
|
+
return msg;
|
|
53
58
|
}
|