agent-from-zero 0.1.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 +37 -3
- package/dist/agent.js +500 -34
- package/dist/config.js +88 -12
- package/dist/context.js +4 -3
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +92 -0
- package/dist/judge.js +77 -0
- package/dist/loop.js +267 -39
- 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 +104 -6
- package/dist/prompt.js +16 -11
- package/dist/session.js +103 -0
- package/dist/skills.js +128 -0
- package/dist/telemetry.js +43 -0
- package/dist/tools.js +120 -18
- 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 +4 -3
package/dist/agent.js
CHANGED
|
@@ -2,32 +2,208 @@
|
|
|
2
2
|
import OpenAI from "openai"; // the API client
|
|
3
3
|
import chalk from "chalk"; // terminal colors
|
|
4
4
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
|
+
import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
7
|
+
import { runLoop, TerminateReason, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
7
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
8
|
-
import { forgetFilesExcept } from "./tools.js"; //
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
9
|
+
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
10
|
+
import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
+
import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
12
|
+
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
13
|
+
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
14
|
+
import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
|
|
15
|
+
import { Judge } from "./judge.js"; // optional LLM permission classifier
|
|
16
|
+
import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
|
|
17
|
+
import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
|
|
18
|
+
import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
|
|
19
|
+
import path from "node:path"; // shorten paths for the /diff summary
|
|
20
|
+
import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
|
|
21
|
+
import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
|
|
22
|
+
import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
|
|
23
|
+
import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
|
|
24
|
+
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
25
|
+
import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
|
|
26
|
+
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
27
|
+
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
28
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
29
|
+
// ---- CLI arguments --------------------------------------------------------------
|
|
30
|
+
// Tiny by design: a help text, a version, and a one-shot print mode. Anything
|
|
31
|
+
// fancier belongs in settings files, not flags.
|
|
32
|
+
const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
mini-agent interactive session (REPL)
|
|
36
|
+
mini-agent -r | --resume continue the most recent session in this directory
|
|
37
|
+
mini-agent -p "<task>" one-shot: run a single task, print the result, exit
|
|
38
|
+
mini-agent -v | --version print the version
|
|
39
|
+
mini-agent -h | --help this text
|
|
40
|
+
|
|
41
|
+
Configuration (optional):
|
|
42
|
+
${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
|
|
43
|
+
${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
|
|
44
|
+
MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override everything.
|
|
45
|
+
|
|
46
|
+
In a session, type /help for the in-session commands.`;
|
|
16
47
|
// What we tell the user for every way a query can end. No raw stack traces.
|
|
17
48
|
const EXIT_NOTES = {
|
|
18
|
-
[TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
|
|
19
49
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
20
50
|
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
21
|
-
[TerminateReason.RateLimitBudgetExhausted]: "
|
|
51
|
+
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
|
|
22
52
|
[TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
|
|
23
53
|
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
|
|
24
54
|
[TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
|
|
25
55
|
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
26
56
|
};
|
|
57
|
+
// The in-session command reference, shown by /help.
|
|
58
|
+
const SESSION_HELP = `commands:
|
|
59
|
+
/help this text
|
|
60
|
+
/clear wipe the conversation (and the file read-state) — fresh start
|
|
61
|
+
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
62
|
+
/model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
|
|
63
|
+
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
64
|
+
/memory show the durable facts the agent remembers about this project
|
|
65
|
+
/cost tokens, cache hit rate and estimated spend this session (local)
|
|
66
|
+
/plan toggle plan mode — research-only; the agent presents a plan you approve before any change
|
|
67
|
+
/undo revert the most recent file write (write_file / edit_file) this session
|
|
68
|
+
/diff show every file changed this session, as a diff from where it started
|
|
69
|
+
/resume list recent sessions in this project and continue one of them
|
|
70
|
+
/skills list the reusable skills available in this project
|
|
71
|
+
/skill <name> run a skill yourself (works even for user-only skills)
|
|
72
|
+
exit leave (Ctrl+C at the prompt does the same)`;
|
|
73
|
+
// Injected when the user turns plan mode on, so the model knows the rules of the
|
|
74
|
+
// mode it now lives in (the permission gate enforces them regardless).
|
|
75
|
+
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
27
76
|
async function main() {
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
// ---- Flag handling, before anything touches the network -----------------------
|
|
78
|
+
const argv = process.argv.slice(2); // everything after "mini-agent"
|
|
79
|
+
if (argv.includes("-v") || argv.includes("--version")) {
|
|
80
|
+
console.log(pkg.version); // just the number — script-friendly
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
84
|
+
console.log(USAGE); // the full help text
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Print mode: -p / --print takes the task from the remaining arguments.
|
|
88
|
+
const pIdx = argv.findIndex((a) => a === "-p" || a === "--print"); // where the flag sits
|
|
89
|
+
const printTask = pIdx >= 0 ? argv.slice(pIdx + 1).join(" ").trim() : null; // everything after it is the task
|
|
90
|
+
if (pIdx >= 0 && !printTask) {
|
|
91
|
+
console.error('Usage: mini-agent -p "<task>"'); // -p without a task is a usage error
|
|
92
|
+
process.exitCode = 2;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
|
|
96
|
+
// Build the API client once, for the whole session.
|
|
97
|
+
const client = new OpenAI({
|
|
98
|
+
baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via config
|
|
99
|
+
apiKey: CONFIG.apiKey, // from .env or the shell
|
|
100
|
+
maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
|
|
101
|
+
});
|
|
102
|
+
// The system message is built ONCE per session: stable prefix = cache hits
|
|
103
|
+
// on every request. Nothing session-specific may sneak into it later.
|
|
104
|
+
const systemMessage = buildSystemMessage();
|
|
105
|
+
let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
|
|
106
|
+
// ---- Session persistence ---------------------------------------------------------
|
|
107
|
+
// Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
|
|
108
|
+
// -r / --resume picks up the most recent one. The system message is NOT
|
|
109
|
+
// restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
|
|
110
|
+
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
111
|
+
const sessionStartedAt = Date.now(); // for the status line's elapsed clock
|
|
112
|
+
if (argv.includes("-r") || argv.includes("--resume")) {
|
|
113
|
+
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
114
|
+
if (prev) {
|
|
115
|
+
messages.push(...prev.messages); // the old conversation joins the fresh constitution
|
|
116
|
+
sessionId = prev.id; // keep appending to the same session file
|
|
117
|
+
console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
|
|
124
|
+
emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
|
|
125
|
+
// Connect to any configured MCP servers and register their tools. This must
|
|
126
|
+
// finish before the first model call so the tools appear in the manual. A
|
|
127
|
+
// server that fails to start is skipped, never fatal.
|
|
128
|
+
const disconnectMcp = await connectMcpServers();
|
|
129
|
+
process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
|
|
130
|
+
// The optional LLM permission judge, built once if a settings file enabled it.
|
|
131
|
+
const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
|
|
132
|
+
if (judge)
|
|
133
|
+
console.log(chalk.dim(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`));
|
|
134
|
+
// The remember tool: let the model save durable facts to long-term memory.
|
|
135
|
+
// Registered like any tool, so it flows through the same permission gate.
|
|
136
|
+
registerExternalTool(rememberTool);
|
|
137
|
+
const memCount = readMemory().length;
|
|
138
|
+
if (memCount)
|
|
139
|
+
console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
|
|
140
|
+
// Skills: reusable Markdown procedures. The model-invocable ones are exposed
|
|
141
|
+
// through a single `skill` tool whose description lists them (progressive
|
|
142
|
+
// disclosure); user-only skills are reachable via /skill <name>.
|
|
143
|
+
const skills = loadSkills();
|
|
144
|
+
if (skills.some((s) => !s.disableModelInvocation))
|
|
145
|
+
registerExternalTool(buildSkillTool(skills));
|
|
146
|
+
if (skills.length)
|
|
147
|
+
console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
|
|
148
|
+
// Token/cost meter for this session. Prices come from settings, falling back
|
|
149
|
+
// to the defaults; the loop records usage into it from every stream.
|
|
150
|
+
const costMeter = initCostMeter({
|
|
151
|
+
inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
|
|
152
|
+
cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
|
|
153
|
+
outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
|
|
154
|
+
});
|
|
155
|
+
// SessionStart hooks run once, here. Their stdout is injected as context the
|
|
156
|
+
// model sees on its first turn — e.g. inject the current git branch, an
|
|
157
|
+
// on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
|
|
158
|
+
// is no tool to block, only context to add.)
|
|
159
|
+
const sessionStart = await runHooks("SessionStart", {});
|
|
160
|
+
if (sessionStart.stdout)
|
|
161
|
+
messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
|
|
162
|
+
// Per-task interrupt state. `running` decides what Ctrl+C means right now.
|
|
163
|
+
let running = false; // is a task currently executing?
|
|
164
|
+
let interrupted = false; // has the current task been interrupted?
|
|
165
|
+
let controller = new AbortController(); // aborts the in-flight API request
|
|
166
|
+
// ---- One-shot print mode -------------------------------------------------------
|
|
167
|
+
// The same loop, the same permission gate (fail closed without a TTY), no REPL.
|
|
168
|
+
// Designed for scripts: the exit code distinguishes success from every failure.
|
|
169
|
+
if (printTask !== null) {
|
|
170
|
+
process.on("SIGINT", () => {
|
|
171
|
+
if (interrupted)
|
|
172
|
+
process.exit(130); // second press: force quit
|
|
173
|
+
interrupted = true; // first press: wind down
|
|
174
|
+
controller.abort(); // cancel the in-flight request
|
|
175
|
+
});
|
|
176
|
+
messages.push({ role: "user", content: printTask }); // the single task
|
|
177
|
+
const result = await runLoop(messages, {
|
|
178
|
+
client,
|
|
179
|
+
model: CONFIG.model,
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
isInterrupted: () => interrupted,
|
|
182
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
183
|
+
judge, // auto-allow clearly-safe commands even unattended (safer than blanket AUTO_APPROVE)
|
|
184
|
+
// Print mode never prompts: unattended means nobody can say yes.
|
|
185
|
+
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
186
|
+
// were enforced in permissions.ts long before this runs.
|
|
187
|
+
confirm: async (q) => {
|
|
188
|
+
if (process.env.MINI_AGENT_AUTO_APPROVE === "1")
|
|
189
|
+
return true; // explicit bypass
|
|
190
|
+
console.error(chalk.dim(`[print mode] declined (no prompt available): ${q.split("\n")[0]}`)); // visible in stderr, not stdout
|
|
191
|
+
return false; // fail closed
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
|
|
195
|
+
emit("agent_session_end"); // close the books
|
|
196
|
+
if (result.reason !== TerminateReason.Done) {
|
|
197
|
+
console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
|
|
198
|
+
if (result.detail)
|
|
199
|
+
console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
|
|
200
|
+
process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
|
|
201
|
+
}
|
|
202
|
+
return; // the answer itself already streamed to stdout
|
|
203
|
+
}
|
|
204
|
+
// ---- Interactive session (REPL) -------------------------------------------------
|
|
205
|
+
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
206
|
+
console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
|
|
31
207
|
// ONE readline interface for the whole session — the task prompt and the
|
|
32
208
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
33
209
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -61,14 +237,6 @@ async function main() {
|
|
|
61
237
|
rl.prompt();
|
|
62
238
|
return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
|
|
63
239
|
};
|
|
64
|
-
// The system message is built ONCE per session: stable prefix = cache hits
|
|
65
|
-
// on every request. Nothing session-specific may sneak into it later.
|
|
66
|
-
const systemMessage = buildSystemMessage();
|
|
67
|
-
let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
|
|
68
|
-
// Per-task interrupt state. `running` decides what Ctrl+C means right now.
|
|
69
|
-
let running = false; // is a task currently executing?
|
|
70
|
-
let interrupted = false; // has the current task been interrupted?
|
|
71
|
-
let controller = new AbortController(); // aborts the in-flight API request
|
|
72
240
|
// Ctrl+C semantics, by state:
|
|
73
241
|
// - at the prompt: exit the session (standard REPL behavior)
|
|
74
242
|
// - while running: first press interrupts the task, second press force-quits
|
|
@@ -89,8 +257,8 @@ async function main() {
|
|
|
89
257
|
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
90
258
|
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
91
259
|
// denies were already enforced in permissions.ts before we get here.
|
|
92
|
-
const confirm = async (question) => {
|
|
93
|
-
console.log(chalk.yellow(`\n
|
|
260
|
+
const confirm = async (question, toolName) => {
|
|
261
|
+
console.log(chalk.yellow(`\n⚠ approval needed — ${question}`)); // always show what is being asked
|
|
94
262
|
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
95
263
|
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
96
264
|
return true; // approve without asking
|
|
@@ -99,24 +267,302 @@ async function main() {
|
|
|
99
267
|
console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
|
|
100
268
|
return false; // fail closed
|
|
101
269
|
}
|
|
102
|
-
|
|
103
|
-
|
|
270
|
+
// Selecting beats typing: ↑/↓ + Enter instead of "type y/N". The middle
|
|
271
|
+
// option lets the user stop being asked about this tool for the session.
|
|
272
|
+
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
273
|
+
const choice = await promptSelect(rl, ["Yes", allowLabel, "No — let me tell the agent what to do instead"]);
|
|
274
|
+
const approved = choice === 0 || choice === 1;
|
|
275
|
+
if (choice === 1 && toolName) {
|
|
276
|
+
CONFIG.permissions.allow.push(`tool:${toolName}`); // remember the grant for the rest of the session
|
|
277
|
+
console.log(chalk.dim(` won't ask again for ${toolName} this session`));
|
|
278
|
+
}
|
|
279
|
+
console.log(approved ? chalk.green(" ✓ approved") : chalk.yellow(" ✗ declined"));
|
|
280
|
+
return approved;
|
|
281
|
+
};
|
|
282
|
+
// The model's handle on the user: present a multi-question form and return the
|
|
283
|
+
// selections. Null in a non-interactive session — the model is told to ask in
|
|
284
|
+
// plain text instead.
|
|
285
|
+
const askUser = async (questions) => {
|
|
286
|
+
if (!process.stdin.isTTY)
|
|
287
|
+
return null;
|
|
288
|
+
return promptForm(rl, questions);
|
|
289
|
+
};
|
|
290
|
+
// Ask the endpoint what models it serves (the OpenAI-compatible /models API).
|
|
291
|
+
// Empty list on any failure — not every endpoint implements it, and a failed
|
|
292
|
+
// listing should never break the command.
|
|
293
|
+
const listModels = async () => {
|
|
294
|
+
try {
|
|
295
|
+
const page = await client.models.list();
|
|
296
|
+
const ids = (page.data ?? []).map((m) => m.id).filter(Boolean);
|
|
297
|
+
return [...new Set(ids)].sort();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
// /model — show the current model, or switch. `/model <name>` sets it directly;
|
|
304
|
+
// bare `/model` lists the endpoint's models and lets you pick one (Day 29 menu).
|
|
305
|
+
// The switch takes effect next turn: the loop reads CONFIG.model each turn.
|
|
306
|
+
const handleModelCommand = async (arg) => {
|
|
307
|
+
// Switching is SESSION-ONLY by default — a quick "/model <name>" to try a
|
|
308
|
+
// model must never permanently change your default. "/model save <name>"
|
|
309
|
+
// (or save after picking) writes it to the global settings file.
|
|
310
|
+
const save = arg === "save" || arg.startsWith("save ");
|
|
311
|
+
const name = save ? arg.slice(4).trim() : arg;
|
|
312
|
+
const apply = (next) => {
|
|
313
|
+
const prev = CONFIG.model;
|
|
314
|
+
CONFIG.model = next; // any string — the API errors on an unknown one, and the error layer reports it
|
|
315
|
+
if (save) {
|
|
316
|
+
saveGlobalSetting("model", next); // persist as the new default
|
|
317
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
console.log(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
if (name) {
|
|
324
|
+
apply(name);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// No name: show the current model and a picker.
|
|
328
|
+
console.log(chalk.dim(`model: ${CONFIG.model}\n` +
|
|
329
|
+
(CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
|
|
330
|
+
`endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
|
|
331
|
+
if (!process.stdin.isTTY)
|
|
332
|
+
return; // no picker without a TTY — switch with: /model <name>
|
|
333
|
+
const models = await listModels();
|
|
334
|
+
if (!models.length) {
|
|
335
|
+
console.log(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
console.log(chalk.dim("switch model (this session):"));
|
|
339
|
+
const choice = await promptSelect(rl, formatModelChoices(models, CONFIG.model));
|
|
340
|
+
if (choice < 0 || models[choice] === CONFIG.model) {
|
|
341
|
+
console.log(chalk.dim("(model unchanged)"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
|
|
345
|
+
};
|
|
346
|
+
// Handle a /slash command. Returns true if the line was a command.
|
|
347
|
+
const handleCommand = async (line) => {
|
|
348
|
+
// /model takes an optional argument (/model <name>), so it can't be a plain
|
|
349
|
+
// switch case — handle it before the exact-match switch.
|
|
350
|
+
if (line === "/model" || line.startsWith("/model ")) {
|
|
351
|
+
await handleModelCommand(line.slice("/model".length).trim());
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
// /skill <name> runs a skill on the user's behalf — inject its instructions
|
|
355
|
+
// as a turn so the model follows them. Works for user-only skills too.
|
|
356
|
+
if (line === "/skill" || line.startsWith("/skill ")) {
|
|
357
|
+
const name = line.slice("/skill".length).trim();
|
|
358
|
+
if (!name) {
|
|
359
|
+
console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
const s = findSkill(skills, name);
|
|
363
|
+
if (!s) {
|
|
364
|
+
console.log(chalk.yellow(`(no skill named "${name}")`));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
|
|
368
|
+
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
369
|
+
running = true;
|
|
370
|
+
interrupted = false;
|
|
371
|
+
controller = new AbortController();
|
|
372
|
+
const r = await runLoop(messages, { client, model: CONFIG.model, signal: controller.signal, isInterrupted: () => interrupted, confirm, askUser, subAgentModel: CONFIG.subAgentModel, judge });
|
|
373
|
+
running = false;
|
|
374
|
+
saveSession(sessionId, CONFIG.model, messages);
|
|
375
|
+
if (r.reason !== TerminateReason.Done)
|
|
376
|
+
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
switch (line) {
|
|
380
|
+
case "/skills": {
|
|
381
|
+
if (!skills.length) {
|
|
382
|
+
console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
console.log(chalk.dim(`skills in this project:`));
|
|
386
|
+
for (const s of skills) {
|
|
387
|
+
const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
|
|
388
|
+
console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
case "/help":
|
|
393
|
+
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
394
|
+
return true;
|
|
395
|
+
case "/clear":
|
|
396
|
+
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
397
|
+
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
398
|
+
clearUndo(); // a fresh conversation should not undo the previous one's writes
|
|
399
|
+
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
400
|
+
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
401
|
+
return true;
|
|
402
|
+
case "/stats":
|
|
403
|
+
console.log(chalk.dim(statsReport())); // local counters, busiest first
|
|
404
|
+
return true;
|
|
405
|
+
case "/memory": {
|
|
406
|
+
const entries = readMemoryTyped(); // the durable, typed facts carried across sessions
|
|
407
|
+
console.log(chalk.dim(entries.length
|
|
408
|
+
? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
|
|
409
|
+
: `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`));
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
case "/cost":
|
|
413
|
+
console.log(chalk.dim(costMeter.report())); // tokens, cache hit rate, estimated spend (local)
|
|
414
|
+
return true;
|
|
415
|
+
case "/undo": {
|
|
416
|
+
// Revert the most recent write. This is a USER action on the filesystem,
|
|
417
|
+
// not a turn for the model — the conversation is untouched. If the model
|
|
418
|
+
// later edits a file it thinks it changed, edit_file's read-before-edit +
|
|
419
|
+
// exact-match guards will catch the staleness and ask it to re-read.
|
|
420
|
+
const undone = undoLast();
|
|
421
|
+
if (!undone) {
|
|
422
|
+
console.log(chalk.dim("(nothing to undo — no file writes recorded this session)"));
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
console.log(chalk.dim(`↩ ${undone.summary}`));
|
|
426
|
+
// Show the diff of what was put back (from the current state to the
|
|
427
|
+
// restored one), reusing the Day 21 renderer.
|
|
428
|
+
console.log(renderDiff(undone.after ?? "", undone.before ?? ""));
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
case "/resume": {
|
|
432
|
+
// List recent sessions in this project and switch to the chosen one.
|
|
433
|
+
// Swapping mid-conversation is fine: we rebuild from the constitution +
|
|
434
|
+
// the saved messages, and reset the per-conversation state (read-state,
|
|
435
|
+
// undo history) so the resumed session behaves like a fresh start of it.
|
|
436
|
+
const sessions = listSessions(10);
|
|
437
|
+
if (!sessions.length) {
|
|
438
|
+
console.log(chalk.dim("(no saved sessions in this project yet)"));
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
console.log(chalk.dim("recent sessions in this project:"));
|
|
442
|
+
sessions.forEach((s, i) => {
|
|
443
|
+
const when = s.savedAt.slice(0, 16).replace("T", " "); // 2026-06-15 10:30
|
|
444
|
+
console.log(chalk.dim(` ${i + 1}. ${when} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`));
|
|
445
|
+
});
|
|
446
|
+
const answer = (await readUserLine(" resume which? [number, or Enter to cancel] ")).trim();
|
|
447
|
+
if (!answer) {
|
|
448
|
+
console.log(chalk.dim("(cancelled)"));
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
const idx = Number(answer) - 1;
|
|
452
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= sessions.length) {
|
|
453
|
+
console.log(chalk.dim("(not a valid choice — cancelled)"));
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
const chosen = loadSession(sessions[idx].id);
|
|
457
|
+
if (!chosen) {
|
|
458
|
+
console.log(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
|
|
462
|
+
sessionId = chosen.id; // keep appending to the resumed session's file
|
|
463
|
+
forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
|
|
464
|
+
clearUndo(); // the previous session's writes are not ours to undo
|
|
465
|
+
console.log(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
case "/diff": {
|
|
469
|
+
// A read-only review of the net effect of this session on the
|
|
470
|
+
// filesystem — every file whose content differs from where it started,
|
|
471
|
+
// shown as a diff from baseline → now. Reuses the Day 21 renderer.
|
|
472
|
+
const changes = sessionChanges();
|
|
473
|
+
if (!changes.length) {
|
|
474
|
+
console.log(chalk.dim("(no file changes this session)"));
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
|
|
478
|
+
console.log(chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`));
|
|
479
|
+
for (const c of changes) {
|
|
480
|
+
const short = path.relative(process.cwd(), c.path) || c.path;
|
|
481
|
+
console.log(`\n${verb[c.status]} ${short}`);
|
|
482
|
+
console.log(renderDiff(c.baseline, c.current));
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
case "/plan":
|
|
487
|
+
// A toggle. Turning it on injects the rules of the mode as a user turn so
|
|
488
|
+
// the model adopts them; the gate (permissions.ts) enforces them either way.
|
|
489
|
+
if (isPlanMode()) {
|
|
490
|
+
setPlanMode(false);
|
|
491
|
+
console.log(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
setPlanMode(true);
|
|
495
|
+
messages.push({ role: "user", content: PLAN_MODE_NOTICE });
|
|
496
|
+
console.log(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
case "/compact": {
|
|
500
|
+
if (messages.length <= 1) {
|
|
501
|
+
console.log(chalk.dim("(nothing to compact yet)")); // only the system message so far
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
default:
|
|
513
|
+
if (line.startsWith("/")) {
|
|
514
|
+
console.log(chalk.dim(`unknown command: ${line} — try /help`)); // typo guard
|
|
515
|
+
return true; // still consumed — don't send typos to the model
|
|
516
|
+
}
|
|
517
|
+
return false; // a normal task line
|
|
518
|
+
}
|
|
104
519
|
};
|
|
105
520
|
// The session loop: ask → run → render → ask again. This is what makes it
|
|
106
521
|
// a conversation instead of a one-shot command.
|
|
522
|
+
// Listen for Tab key on raw stdin — toggles expand/collapse of hidden tool output.
|
|
523
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
524
|
+
process.stdin.on("data", (buf) => {
|
|
525
|
+
if (buf[0] === 0x09) { // Tab
|
|
526
|
+
if (!running)
|
|
527
|
+
toggleLastCollapsed();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
107
531
|
while (true) {
|
|
108
|
-
|
|
532
|
+
// Before each prompt, a status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
|
|
533
|
+
// then a separator rule, then the "❯". Only in a TTY; piped input is clean.
|
|
534
|
+
if (process.stdin.isTTY) {
|
|
535
|
+
const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
|
|
536
|
+
console.log("\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt));
|
|
537
|
+
console.log(inputRule());
|
|
538
|
+
}
|
|
539
|
+
const line = (await readUserLine(framedPrompt(isPlanMode()))).trim(); // next input, or "exit" on EOF
|
|
109
540
|
if (!line)
|
|
110
541
|
continue; // empty line — just re-prompt
|
|
111
542
|
if (line === "exit" || line === "quit")
|
|
112
543
|
break; // explicit goodbye
|
|
113
|
-
if (line
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
544
|
+
if (await handleCommand(line))
|
|
545
|
+
continue; // slash commands never reach the model
|
|
546
|
+
// UserPromptSubmit hook: a chance to validate/inject before the model sees
|
|
547
|
+
// the prompt. exit 2 DROPS the prompt entirely (e.g. block a forbidden ask);
|
|
548
|
+
// stdout becomes extra context for this turn (e.g. inject the current ticket).
|
|
549
|
+
const submit = await runHooks("UserPromptSubmit", { prompt: line });
|
|
550
|
+
if (submit.block) {
|
|
551
|
+
console.log(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${submit.feedback.slice(0, 150)})`));
|
|
552
|
+
continue; // the prompt is erased — never reaches the model
|
|
118
553
|
}
|
|
119
|
-
|
|
554
|
+
const injected = submit.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${submit.stdout}` : "";
|
|
555
|
+
// @file mentions: if the line references files (@src/loop.ts), attach their
|
|
556
|
+
// content to the message so the model sees them directly. Secret files are
|
|
557
|
+
// refused here, exactly as read_file would refuse them.
|
|
558
|
+
const { augmented, mentions } = expandMentions(line);
|
|
559
|
+
const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
|
|
560
|
+
const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
|
|
561
|
+
if (attached.length)
|
|
562
|
+
console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
|
|
563
|
+
if (refused.length)
|
|
564
|
+
console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
|
|
565
|
+
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
120
566
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
121
567
|
interrupted = false; // fresh interrupt state for this task
|
|
122
568
|
controller = new AbortController(); // fresh abort signal for this task
|
|
@@ -126,8 +572,25 @@ async function main() {
|
|
|
126
572
|
signal: controller.signal, // for aborting requests
|
|
127
573
|
isInterrupted: () => interrupted, // for stopping between steps
|
|
128
574
|
confirm, // for permission prompts
|
|
575
|
+
askUser, // the multi-question form the model can pop
|
|
576
|
+
subAgentModel: CONFIG.subAgentModel, // delegated work may run on a different tier
|
|
577
|
+
judge, // optional LLM classifier for the "ask" middle ground
|
|
129
578
|
});
|
|
130
579
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
580
|
+
saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
|
|
581
|
+
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
582
|
+
// that saves durable facts — especially user corrections — so memory
|
|
583
|
+
// captures itself instead of relying on the model to call `remember`.
|
|
584
|
+
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
585
|
+
try {
|
|
586
|
+
const got = await extractMemories(client, CONFIG.subAgentModel || CONFIG.model, messages);
|
|
587
|
+
if (got.length)
|
|
588
|
+
console.log(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* memory extraction must never break the session */
|
|
592
|
+
}
|
|
593
|
+
}
|
|
131
594
|
// Done already streamed its answer to the screen; everything else gets a
|
|
132
595
|
// one-line human explanation. The session continues either way — except a
|
|
133
596
|
// fatal API error, which no amount of chatting will fix.
|
|
@@ -139,7 +602,10 @@ async function main() {
|
|
|
139
602
|
break; // a bad key won't fix itself mid-session
|
|
140
603
|
}
|
|
141
604
|
}
|
|
605
|
+
await runHooks("SessionEnd", {}); // cleanup hooks (tiny 1.5s budget — the user may be leaving via Ctrl+C)
|
|
606
|
+
emit("agent_session_end"); // close the books
|
|
142
607
|
rl.close(); // release stdin
|
|
608
|
+
tuiCleanup(); // restore terminal
|
|
143
609
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
144
610
|
}
|
|
145
611
|
main(); // kick everything off
|