kronk-cli 0.1.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/src/distill.js ADDED
@@ -0,0 +1,112 @@
1
+ import { streamChat, tokenize } from './client.js';
2
+ import { config } from './config.js';
3
+ import { c } from './ui.js';
4
+
5
+ const PROMPT = `You are reading the raw output of a command another agent just ran.
6
+ Condense it into the smallest report that keeps every fact the agent needs.
7
+
8
+ Structure your reply exactly like this:
9
+
10
+ STATUS: one line — did it succeed or fail, and the headline numbers.
11
+ FAILURES: every error and warning, quoted verbatim, one per line. Write "none" if there were none.
12
+ NOTES: anything else that changes what the agent should do next. Usually empty.
13
+
14
+ Rules:
15
+ - Never soften or summarize an error. Copy the text, file paths, line numbers and codes exactly.
16
+ - Collapse repeated progress lines into a single count: "built 400 packages" — never list them.
17
+ - Drop progress bars, spinners, timings, dependency resolution noise, decorative output.
18
+ - If anything failed, STATUS must say so. Do not lead with partial success.
19
+ - Report only. No causes, no fixes, no suggestions.`;
20
+
21
+ /** Lines that almost always matter, whatever the summarizer decides. */
22
+ const SIGNAL = new RegExp([
23
+ '\\berror\\b', '\\bfailed?\\b', '\\bfailure\\b', '\\bwarn(ing)?\\b',
24
+ '\\bexception\\b', '\\btraceback\\b', '\\bpanic\\b', '\\bfatal\\b',
25
+ '\\bassert', '\\bcannot\\b', '\\bunable to\\b', '\\bnot found\\b',
26
+ '\\bdenied\\b', '\\btimed? ?out\\b', '\\bECONN', '\\bENOENT',
27
+ 'TS\\d{4}', '\\b[A-Z]{2,}\\d{3,}\\b', // TS2345, ESLint-ish codes
28
+ '^\\s*[✗✘×]', '\\bexit code\\b',
29
+ ].join('|'), 'i');
30
+
31
+ const NOISE = /^\s*(\[[\d/ ]+\]|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|\d+%|\s*$)/;
32
+
33
+ /**
34
+ * Pull the lines that carry a failure straight out of the raw text.
35
+ *
36
+ * A summarizer reading ten thousand tokens of build chatter can miss three
37
+ * error lines at the end — it did, and confidently reported "FAILURES: none".
38
+ * Grep is not clever but it does not overlook things, so the needles are
39
+ * extracted deterministically and the model's prose is what gets appended.
40
+ */
41
+ export function keyLines(raw, limit = 60) {
42
+ const hits = [];
43
+ const seen = new Set();
44
+ for (const line of raw.split('\n')) {
45
+ const t = line.trimEnd();
46
+ if (!t || NOISE.test(t) || !SIGNAL.test(t)) continue;
47
+ const key = t.trim();
48
+ if (seen.has(key)) continue;
49
+ seen.add(key);
50
+ hits.push(t.length > 500 ? `${t.slice(0, 500)}…` : t);
51
+ if (hits.length >= limit) { hits.push(`…(more matches suppressed)`); break; }
52
+ }
53
+ return hits;
54
+ }
55
+
56
+ /**
57
+ * Summarize a large tool result in a throwaway context.
58
+ *
59
+ * The point is what does NOT happen: the raw output never enters the main
60
+ * conversation, so a 40k-token build log costs the agent a few hundred tokens
61
+ * instead of a third of its window. This call's own context is discarded.
62
+ */
63
+ export async function distill(raw, { command, model, signal } = {}) {
64
+ const tailKeep = 2000;
65
+ let summary = '';
66
+
67
+ for await (const ev of streamChat({
68
+ model,
69
+ messages: [{
70
+ role: 'user',
71
+ content: `Command: ${command ?? '(unknown)'}\n\n--- raw output ---\n${raw}\n--- end ---\n\n${PROMPT}`,
72
+ }],
73
+ signal,
74
+ maxTokens: 1500,
75
+ noThink: true,
76
+ })) {
77
+ if (ev.type === 'text') summary += ev.value;
78
+ }
79
+
80
+ const signals = keyLines(raw);
81
+ const tail = raw.length > tailKeep ? raw.slice(-tailKeep) : raw;
82
+
83
+ return [
84
+ `[output distilled — ${raw.length.toLocaleString()} chars summarized in a separate context]`,
85
+ '',
86
+ signals.length
87
+ ? `--- error/warning lines, extracted verbatim (authoritative) ---\n${signals.join('\n')}`
88
+ : '--- no error or warning lines matched ---',
89
+ '',
90
+ summary.trim() ? `--- summary ---\n${summary.trim()}` : '',
91
+ '',
92
+ '--- last lines, verbatim ---',
93
+ tail.trim(),
94
+ '',
95
+ 'Trust the extracted lines over the summary if they disagree.',
96
+ ].filter(Boolean).join('\n');
97
+ }
98
+
99
+ /** Worth distilling? Small results are cheaper left alone. */
100
+ export function shouldDistill(result) {
101
+ return config.distill && typeof result === 'string' && result.length >= config.distillAt;
102
+ }
103
+
104
+ export async function maybeDistill(result, opts) {
105
+ if (!shouldDistill(result)) return result;
106
+ const before = await tokenize(opts.model, result);
107
+ const digest = await distill(result, opts);
108
+ if (!digest || digest.length >= result.length) return result;
109
+ const after = await tokenize(opts.model, digest);
110
+ console.log(c.grey(` distilled ${before.toLocaleString()} → ${after.toLocaleString()} tokens (separate context)`));
111
+ return digest;
112
+ }
package/src/index.js ADDED
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env node
2
+ import readline from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { config, DEFAULT_MODEL } from './config.js';
6
+ import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
7
+ import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
8
+ import { c, banner, fmtContext, statusLine } from './ui.js';
9
+ import { projectContext } from './context.js';
10
+ import { compact, report } from './compact.js';
11
+ import { loadServers, McpHub, reportFailures } from './mcp.js';
12
+
13
+ // ---- argv -------------------------------------------------------------
14
+ const argv = process.argv.slice(2);
15
+ const flag = (...names) => {
16
+ const i = argv.findIndex((a) => names.includes(a));
17
+ if (i === -1) return false;
18
+ argv.splice(i, 1);
19
+ return true;
20
+ };
21
+ let AUTO_YES = flag('-y', '--yes');
22
+ if (flag('--no-think')) config.noThink = true;
23
+ // --auto: run the whole task unattended (implies --yes)
24
+ const AUTO = flag('-a', '--auto');
25
+ if (AUTO) AUTO_YES = true;
26
+
27
+ const SHOW_MODELS = flag('--models', '-l', '--list');
28
+ const SHOW_MCP = flag('--mcp-list');
29
+ const NO_CONTEXT = flag('--no-context');
30
+ if (flag('--no-compact')) config.autoCompact = false;
31
+
32
+ if (flag('-h', '--help')) {
33
+ console.log(`
34
+ kronk-cli — a terminal agent for local models served by Kronk
35
+
36
+ USAGE
37
+ kronk-cli start the interactive REPL
38
+ kronk-cli "<prompt>" run one prompt and exit
39
+ <cmd> | kronk-cli "<prompt>" pipe stdin in as extra context
40
+
41
+ OPTIONS
42
+ -l, --models list the models Kronk is serving, then exit
43
+ --no-context skip the startup scan of the working directory
44
+ --no-compact never auto-compact; fail instead when the window fills
45
+ --mcp [names] attach MCP servers; bare for all, or a comma list
46
+ --mcp-list show configured MCP servers and their tools, then exit
47
+ -m, --model <id> model to use; substring is enough, /AGENT profiles win
48
+ default: ${DEFAULT_MODEL}
49
+ -a, --auto autonomous: approve tools automatically, finish the task
50
+ -y, --yes approve tools automatically (no autonomous prompt)
51
+ --no-think disable the model's reasoning pass (faster)
52
+ --steps <n> cap tool calls per task (default: unlimited)
53
+ -h, --help this message
54
+
55
+ ENVIRONMENT
56
+ KRONK_URL default http://localhost:11435/v1
57
+ KRONK_TOKEN any non-empty value when Kronk runs open
58
+ KRONK_MODEL overrides the default model
59
+ KRONK_MAX_TOKENS output cap per response (default 8192)
60
+ KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
61
+ KRONK_NO_THINK set to 1 to disable reasoning
62
+ KRONK_AUTO_COMPACT false to disable automatic compaction
63
+ KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
64
+
65
+ Config file: ~/.kronk-cli.json
66
+ `);
67
+ process.exit(0);
68
+ }
69
+ const opt = (name) => {
70
+ const i = argv.findIndex((a) => a === name);
71
+ if (i === -1) return null;
72
+ const v = argv[i + 1];
73
+ argv.splice(i, 2);
74
+ return v;
75
+ };
76
+ const modelArg = opt('--model') ?? opt('-m');
77
+ if (modelArg) config.model = modelArg;
78
+ // `--mcp` alone attaches everything configured; `--mcp nx,kronk` narrows it.
79
+ let MCP_ON = false;
80
+ let MCP_WANTED = null;
81
+ {
82
+ const i = argv.findIndex((a) => a === '--mcp');
83
+ if (i !== -1) {
84
+ MCP_ON = true;
85
+ const next = argv[i + 1];
86
+ if (next && !next.startsWith('-')) {
87
+ MCP_WANTED = next.split(',').map((x) => x.trim()).filter(Boolean);
88
+ argv.splice(i, 2);
89
+ } else {
90
+ argv.splice(i, 1);
91
+ }
92
+ }
93
+ }
94
+
95
+ const stepsArg = opt('--steps');
96
+ if (stepsArg) config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(stepsArg) ? Infinity : Number(stepsArg);
97
+
98
+ /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
99
+ function pickDefault(ids) {
100
+ const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
101
+ const agent = chat.filter((id) => id.endsWith('/AGENT'));
102
+ const pool = agent.length ? agent : chat;
103
+ return pool.sort((a, b) => b.length - a.length)[0] ?? null;
104
+ }
105
+
106
+ async function boot() {
107
+ let ids;
108
+ try {
109
+ ids = await listModels();
110
+ } catch (e) {
111
+ console.error(c.red(`\n Cannot reach Kronk at ${config.baseUrl}`));
112
+ console.error(c.grey(` ${e.message}`));
113
+ console.error(c.grey(' Start it with: kronk server start --detach\n'));
114
+ process.exit(1);
115
+ }
116
+ if (!ids.length) {
117
+ console.error(c.red('\n Kronk is running but has no models.'));
118
+ console.error(c.grey(' Pull one: kronk model pull unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M\n'));
119
+ process.exit(1);
120
+ }
121
+ // Fall back to the configured default before guessing.
122
+ if (!config.model && ids.includes(DEFAULT_MODEL)) config.model = DEFAULT_MODEL;
123
+
124
+ if (config.model) {
125
+ // exact id wins; otherwise accept a unique-enough substring
126
+ // Exact id wins. Otherwise take a substring match, preferring an /AGENT
127
+ // profile — each profile is a SEPARATE resident copy in the pool, so
128
+ // picking the wrong one silently loads a second 20 GB instance.
129
+ const subs = ids.filter((id) => id.includes(config.model));
130
+ const hit = ids.find((id) => id === config.model)
131
+ ?? subs.find((id) => id.endsWith('/AGENT'))
132
+ ?? subs[0];
133
+ if (hit) config.model = hit;
134
+ else {
135
+ console.error(c.yellow(` no model matching "${config.model}" — falling back`));
136
+ config.model = null;
137
+ }
138
+ }
139
+ if (!config.model) config.model = pickDefault(ids);
140
+
141
+ const { configured, native } = await modelLimits(config.model);
142
+ config.contextWindow = configured;
143
+ config.nativeContext = native;
144
+ return ids;
145
+ }
146
+
147
+ const gb = (n) => `${(n / 1e9).toFixed(1)} GB`;
148
+
149
+ /** Connect the MCP servers the user asked for. Never fatal. */
150
+ async function startMcp() {
151
+ if (!MCP_ON) return null;
152
+ const all = await loadServers(process.cwd());
153
+ const specs = MCP_WANTED
154
+ ? Object.fromEntries(Object.entries(all).filter(([n]) => MCP_WANTED.includes(n)))
155
+ : all;
156
+
157
+ const missing = (MCP_WANTED ?? []).filter((n) => !all[n]);
158
+ for (const n of missing) console.log(c.yellow(` mcp ${n}: not configured`));
159
+ if (!Object.keys(specs).length) return null;
160
+
161
+ const hub = await new McpHub().connect(specs);
162
+ reportFailures(hub.failures);
163
+ const n = hub.routes.size;
164
+ if (n) {
165
+ console.log(c.grey(' mcp ') + hub.summary() + c.grey(` · ${n} tools`));
166
+ if (n > 25) {
167
+ console.log(c.yellow(` ${n} MCP tools is a lot for a local model — narrow it with --mcp <names>`));
168
+ }
169
+ }
170
+ process.on('exit', () => hub.close());
171
+ return hub;
172
+ }
173
+
174
+ /** `--mcp-list` — what is configured, what connects, what it exposes. */
175
+ async function showMcp() {
176
+ const specs = await loadServers(process.cwd());
177
+ const names = Object.keys(specs);
178
+ if (!names.length) {
179
+ console.log(c.grey('\n No MCP servers configured.'));
180
+ console.log(c.grey(' Looked in ~/.claude.json, ./.mcp.json, ~/.kronk-cli.json, ./.kronk-cli.json\n'));
181
+ return;
182
+ }
183
+ console.log(c.grey(`\n ${names.length} configured — connecting…\n`));
184
+ const hub = await new McpHub().connect(specs);
185
+ for (const name of names) {
186
+ const server = hub.servers.get(name);
187
+ const spec = specs[name];
188
+ const via = spec.url ?? `${spec.command} ${(spec.args ?? []).join(' ')}`.trim();
189
+ if (!server) {
190
+ const f = hub.failures.find((x) => x.name === name);
191
+ console.log(` ${c.red('✗')} ${c.bold(name)} ${c.grey(via)}`);
192
+ console.log(c.red(` ${f?.error.slice(0, 200) ?? 'failed'}`));
193
+ continue;
194
+ }
195
+ console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(via)}`);
196
+ for (const t of server.tools) {
197
+ console.log(` ${c.grey(`${name}__`)}${t.name}`);
198
+ }
199
+ }
200
+ console.log(c.grey(`\n attach with: kronk-cli --mcp ${names.slice(0, 2).join(',')}\n`));
201
+ hub.close();
202
+ }
203
+
204
+ /** `kronk-cli --models` — what Kronk is serving, and what is resident. */
205
+ async function showModels() {
206
+ let ids, details, loaded;
207
+ try {
208
+ [ids, details, loaded] = await Promise.all([listModels(), listModelDetails(), listLoaded()]);
209
+ } catch (e) {
210
+ console.error(c.red(` Cannot reach Kronk at ${config.baseUrl}`));
211
+ console.error(c.grey(` ${e.message}`));
212
+ process.exit(1);
213
+ }
214
+ if (!ids.length) {
215
+ console.log(c.yellow(' Kronk is running but serving no models.'));
216
+ console.log(c.grey(' kronk model pull unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M'));
217
+ return;
218
+ }
219
+
220
+ const byId = new Map(details.map((d) => [d.id, d]));
221
+ const live = new Map((Array.isArray(loaded) ? loaded : []).map((l) => [l.id, l]));
222
+ const selected = config.model ?? (ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0]);
223
+ const w = Math.max(...ids.map((i) => i.length));
224
+
225
+ console.log();
226
+ for (const id of ids) {
227
+ const d = byId.get(id);
228
+ const l = live.get(id);
229
+ const mark = id === selected ? c.green('●') : c.grey('○');
230
+ const tags = [];
231
+ if (d?.size) tags.push(gb(d.size));
232
+ if (d?.has_projection) tags.push('vision');
233
+ if (l) tags.push(c.cyan(`loaded ${gb(l.vram_total)}${l.active_streams ? ` · ${l.active_streams} active` : ''}`));
234
+ console.log(` ${mark} ${id.padEnd(w)} ${c.grey(tags.join(' · '))}`);
235
+ }
236
+
237
+ const totalLive = [...live.values()].reduce((a, b) => a + (b.vram_total ?? 0), 0);
238
+ if (totalLive) console.log(c.grey(`\n resident: ${gb(totalLive)}`));
239
+ console.log(c.grey(` default: ${selected}`));
240
+ console.log(c.grey(` select: kronk-cli -m <substring>\n`));
241
+ }
242
+
243
+ const HELP = `
244
+ ${c.bold('/models')} list models Kronk is serving
245
+ ${c.bold('/model <id>')} switch model
246
+ ${c.bold('/file <path>')} add a file to the conversation as context
247
+ ${c.bold('/thinking')} show/hide the model's reasoning
248
+ ${c.bold('/think')} turn reasoning off entirely (much faster)
249
+ ${c.bold('/auto')} autonomous mode: auto-approve tools, run to completion
250
+ ${c.bold('/steps [n|off]')} cap tool calls per task (default: unlimited)
251
+ ${c.bold('/mcp')} list attached MCP servers and their tools
252
+ ${c.bold('/context')} how much of the context window is used
253
+ ${c.bold('/compact')} replace the conversation with a summary of itself
254
+ ${c.bold('/clear')} reset the conversation
255
+ ${c.bold('/exit')} quit
256
+ `;
257
+
258
+ /**
259
+ * System prompt plus a primer about the directory we were launched in: layout,
260
+ * git state, and any AGENTS.md / CLAUDE.md the project ships. Gathered once so
261
+ * the model does not have to spend its first tool call working out where it is.
262
+ */
263
+ async function systemMessage(auto) {
264
+ const base = auto ? SYSTEM_AUTO : SYSTEM;
265
+ if (NO_CONTEXT) return { content: base, ctx: null };
266
+ const ctx = await projectContext(process.cwd());
267
+ const budget = config.contextWindow
268
+ ? `\n\nYour context window is ${config.contextWindow.toLocaleString()} tokens, shared by `
269
+ + `everything in this conversation: these instructions, file contents you read, command `
270
+ + `output, and your own replies. When you plan work that must fit in one context, size it `
271
+ + `against that number and say what you assumed.`
272
+ : '';
273
+ return { content: `${base}${budget}\n\n---\n\n${ctx.text}`, ctx };
274
+ }
275
+
276
+ /**
277
+ * Read piped stdin, if any.
278
+ *
279
+ * Guarded by a timeout: when this runs under CI, a background job, or any shell
280
+ * that hands us an open-but-idle pipe, an unguarded `for await` never returns
281
+ * and the process hangs before printing anything.
282
+ */
283
+ async function readStdin(timeoutMs) {
284
+ if (stdin.isTTY) return '';
285
+ let timer;
286
+ const collect = (async () => {
287
+ let out = '';
288
+ for await (const chunk of stdin) out += chunk;
289
+ return out.trim();
290
+ })();
291
+ const bail = new Promise((res) => {
292
+ timer = setTimeout(() => res(''), timeoutMs);
293
+ timer.unref?.();
294
+ });
295
+ try { return await Promise.race([collect, bail]); }
296
+ finally { clearTimeout(timer); }
297
+ }
298
+
299
+ /** One prompt, one answer, exit — for scripts and pipes. */
300
+ async function oneShot(prompt) {
301
+ await boot();
302
+ const mcp = await startMcp();
303
+ const { content } = await systemMessage(AUTO);
304
+ const messages = [
305
+ { role: 'system', content },
306
+ { role: 'user', content: prompt },
307
+ ];
308
+ const ac = new AbortController();
309
+ process.on('SIGINT', () => ac.abort());
310
+ const approve = async (name) => {
311
+ if (AUTO_YES) return true;
312
+ console.log(c.yellow(` ✗ ${name} needs approval; re-run with --yes to allow it`));
313
+ return false;
314
+ };
315
+ try {
316
+ await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp });
317
+ } catch (e) {
318
+ if (e.name !== 'AbortError') { console.error(c.red(` ${e.message}`)); process.exitCode = 1; }
319
+ } finally {
320
+ mcp?.close();
321
+ }
322
+ }
323
+
324
+ async function main() {
325
+ if (SHOW_MODELS) { await showModels(); return; }
326
+ if (SHOW_MCP) { await showMcp(); process.exit(0); }
327
+
328
+ // non-interactive: `kronk-cli "prompt"` or `echo prompt | kronk-cli`
329
+ const inline = argv.join(' ').trim();
330
+ // With an inline prompt, stdin is optional extra context — don't block on it.
331
+ // Without one, stdin IS the prompt, so wait longer before giving up.
332
+ const piped = await readStdin(inline ? 200 : 10_000);
333
+ const oneShotPrompt = inline && piped ? `${inline}\n\n${piped}` : (inline || piped);
334
+ if (oneShotPrompt) { await oneShot(oneShotPrompt); return; }
335
+
336
+ const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
337
+ await boot();
338
+ const { content, ctx } = await systemMessage(AUTO);
339
+ console.log(banner(config.model, config.baseUrl));
340
+ const mcp = await startMcp();
341
+
342
+ if (ctx) {
343
+ const bits = [process.cwd().replace(process.env.HOME ?? '', '~')];
344
+ if (ctx.isGit) bits.push('git');
345
+ if (ctx.agentFile) bits.push(c.green(ctx.agentFile));
346
+ if (config.contextWindow) bits.push(c.grey(`${(config.contextWindow / 1000).toFixed(0)}k ctx`));
347
+ console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}\n`);
348
+ }
349
+
350
+ const messages = [{ role: 'system', content }];
351
+
352
+ // Ctrl-C aborts the in-flight request instead of killing the process.
353
+ let ac = null;
354
+ rl.on('SIGINT', () => {
355
+ if (ac) { ac.abort(); ac = null; console.log(c.yellow('\n interrupted')); }
356
+ else { console.log(); rl.close(); }
357
+ });
358
+
359
+ let autoApprove = AUTO_YES;
360
+ const approve = async (name) => {
361
+ if (autoApprove) return true;
362
+ const a = (await rl.question(c.yellow(` approve ${name}? [y/N] `))).trim().toLowerCase();
363
+ return a === 'y' || a === 'yes';
364
+ };
365
+
366
+ for (;;) {
367
+ const status = statusLine({
368
+ model: config.model,
369
+ auto: autoApprove && messages[0].content.startsWith(SYSTEM_AUTO),
370
+ yes: autoApprove,
371
+ noThink: config.noThink,
372
+ mcp: mcp?.routes.size ? [...mcp.servers.keys()].join(',') : null,
373
+ steps: config.maxSteps,
374
+ used: config.lastUsed,
375
+ window: config.contextWindow,
376
+ });
377
+
378
+ let line;
379
+ try { line = await rl.question(`\n${status}\n${c.cyan('›')} `); }
380
+ catch { break; } // rl closed
381
+ const input = line.trim();
382
+ if (!input) continue;
383
+
384
+ if (input === '/exit' || input === '/quit') break;
385
+ if (input === '/help') { console.log(HELP); continue; }
386
+ if (input === '/clear') {
387
+ messages.length = 1;
388
+ config.lastUsed = 0;
389
+ console.log(c.grey(' conversation cleared'));
390
+ continue;
391
+ }
392
+ if (input.startsWith('/steps')) {
393
+ const raw = input.split(/\s+/)[1];
394
+ if (raw !== undefined) {
395
+ config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(raw) ? Infinity : Number(raw);
396
+ }
397
+ console.log(c.grey(` step cap: ${Number.isFinite(config.maxSteps) ? config.maxSteps : 'unlimited'}`));
398
+ continue;
399
+ }
400
+ if (input === '/auto') {
401
+ const now = !messages[0].content.startsWith(SYSTEM_AUTO);
402
+ const primer = messages[0].content.slice(
403
+ (messages[0].content.startsWith(SYSTEM_AUTO) ? SYSTEM_AUTO : SYSTEM).length);
404
+ messages[0] = { role: 'system', content: (now ? SYSTEM_AUTO : SYSTEM) + primer };
405
+ autoApprove = now;
406
+ console.log(c.grey(` autonomous mode ${now ? 'on — tools auto-approved, runs to completion' : 'off'}`));
407
+ continue;
408
+ }
409
+ if (input === '/think') {
410
+ config.noThink = !config.noThink;
411
+ console.log(c.grey(` reasoning ${config.noThink ? 'disabled (faster)' : 'enabled'}`));
412
+ continue;
413
+ }
414
+ if (input === '/thinking') {
415
+ config.showThinking = !config.showThinking;
416
+ console.log(c.grey(` thinking display ${config.showThinking ? 'on' : 'off'}`));
417
+ continue;
418
+ }
419
+ if (input === '/models') { await showModels(); continue; }
420
+ if (input === '/mcp') {
421
+ if (!mcp || !mcp.routes.size) { console.log(c.grey(' no MCP servers attached — start with --mcp')); continue; }
422
+ for (const [name, server] of mcp.servers) {
423
+ console.log(c.green(` ● ${name}`) + c.grey(` · ${server.tools.length} tools`));
424
+ for (const t of server.tools) console.log(c.grey(` ${name}__${t.name}`));
425
+ }
426
+ continue;
427
+ }
428
+ if (input === '/compact') {
429
+ if (messages.length < 2) { console.log(c.grey(' nothing to compact')); continue; }
430
+ const sp = new AbortController();
431
+ const res = await compact(messages, { model: config.model, signal: sp.signal });
432
+ if (res.failed) { console.log(c.red(' compaction produced nothing — conversation unchanged')); continue; }
433
+ if (!res.skipped) messages.splice(0, messages.length, ...res.messages);
434
+ console.log(report(res));
435
+ continue;
436
+ }
437
+ if (input === '/context') {
438
+ const used = await tokenize(config.model, messages.map((m) => m.content ?? '').join('\n'));
439
+ console.log(` ${fmtContext(used, config.contextWindow) || c.grey('unknown')}`);
440
+ console.log(c.grey(` window: ${config.contextWindow?.toLocaleString() ?? '?'} tokens`
441
+ + (config.nativeContext ? ` · model supports up to ${config.nativeContext.toLocaleString()}` : '')));
442
+ console.log(c.grey(` messages: ${messages.length}`));
443
+ continue;
444
+ }
445
+ if (input.startsWith('/model ')) {
446
+ const want = input.slice(7).trim();
447
+ const all = await listModels();
448
+ const subs = all.filter((m) => m.includes(want));
449
+ const hit = all.find((m) => m === want)
450
+ ?? subs.find((m) => m.endsWith('/AGENT'))
451
+ ?? subs[0];
452
+ if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
453
+ config.model = hit;
454
+ console.log(c.green(` switched to ${hit}`));
455
+ continue;
456
+ }
457
+ if (input.startsWith('/file ')) {
458
+ const path = input.slice(6).trim();
459
+ try {
460
+ const body = await readFile(path, 'utf8');
461
+ messages.push({ role: 'user', content: `Contents of ${path}:\n\n\`\`\`\n${body}\n\`\`\`` });
462
+ console.log(c.grey(` added ${path} (${body.length} bytes)`));
463
+ } catch (e) { console.log(c.red(` ${e.message}`)); }
464
+ continue;
465
+ }
466
+ if (input.startsWith('/')) { console.log(c.red(` unknown command — /help`)); continue; }
467
+
468
+ messages.push({ role: 'user', content: input });
469
+ ac = new AbortController();
470
+ try {
471
+ await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp });
472
+ } catch (e) {
473
+ if (e.name === 'AbortError') messages.push({ role: 'assistant', content: '(interrupted)' });
474
+ else console.error(c.red(`\n ${e.message}`));
475
+ } finally {
476
+ ac = null;
477
+ }
478
+ }
479
+
480
+ rl.close();
481
+ mcp?.close();
482
+ console.log(c.grey(' bye'));
483
+ }
484
+
485
+ main();