kronk-cli 0.3.1 → 0.5.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/client.js CHANGED
@@ -75,10 +75,41 @@ export function samplingOverride(metadata, sampling) {
75
75
  return diffs.length ? diffs : null;
76
76
  }
77
77
 
78
+ export const NO_LIMITS = {
79
+ configured: null, native: null, preserveThinking: false, samplingDiff: null, maxTokens: null,
80
+ };
81
+
82
+ /**
83
+ * Everything about one model that the rest of the program is allowed to
84
+ * assume, read out of that model's own profile — pure, so the whole surface
85
+ * is testable without a server.
86
+ *
87
+ * `maxTokens` is the profile's own output cap. Kronk reports 0 for a model
88
+ * whose profile does not set one, which is "no opinion" and not a limit of
89
+ * zero, so it comes back null and the caller keeps its default. Anything else
90
+ * is what the person who wrote the profile decided this model may generate,
91
+ * and it is not ours to halve.
92
+ */
93
+ export function parseLimits(d) {
94
+ if (!d) return NO_LIMITS;
95
+ const sampling = d.model_config?.['sampling-parameters'];
96
+ const profileMax = num(sampling?.max_tokens);
97
+ const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
98
+ const template = d.metadata?.['tokenizer.chat_template'];
99
+ return {
100
+ configured: d.model_config?.['context-window'] ?? null,
101
+ native: nativeKey ? Number(d.metadata[nativeKey]) : null,
102
+ preserveThinking: typeof template === 'string' && template.includes('preserve_thinking'),
103
+ samplingDiff: samplingOverride(d.metadata, sampling),
104
+ maxTokens: profileMax && profileMax > 0 ? profileMax : null,
105
+ };
106
+ }
107
+
78
108
  /**
79
- * Effective context window for a model id, the model's native maximum,
80
- * whether its chat template understands `preserve_thinking`, and whether the
81
- * profile is overriding the model's own sampling values.
109
+ * Effective context window for a model id, the model's native maximum, the
110
+ * output cap its profile sets, whether its chat template understands
111
+ * `preserve_thinking`, and whether the profile is overriding the model's own
112
+ * sampling values.
82
113
  * The id contains slashes, so it must be percent-encoded — Kronk's route takes
83
114
  * one path segment and 404s on a raw id.
84
115
  *
@@ -93,20 +124,9 @@ export function samplingOverride(metadata, sampling) {
93
124
  */
94
125
  export async function modelLimits(id) {
95
126
  try {
96
- const d = await (await req(`/kronk/models/${encodeURIComponent(id)}`)).json();
97
- const configured = d.model_config?.['context-window'] ?? null;
98
- const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
99
- const native = nativeKey ? Number(d.metadata[nativeKey]) : null;
100
- const template = d.metadata?.['tokenizer.chat_template'];
101
- const preserveThinking = typeof template === 'string' && template.includes('preserve_thinking');
102
- const samplingDiff = samplingOverride(d.metadata, d.model_config?.['sampling-parameters']);
103
- return {
104
- configured, native, preserveThinking, samplingDiff,
105
- };
127
+ return parseLimits(await (await req(`/kronk/models/${encodeURIComponent(id)}`)).json());
106
128
  } catch {
107
- return {
108
- configured: null, native: null, preserveThinking: false, samplingDiff: null,
109
- };
129
+ return NO_LIMITS;
110
130
  }
111
131
  }
112
132
 
package/src/compact.js CHANGED
@@ -73,16 +73,19 @@ function transcript(messages) {
73
73
  * reasoning-replay view of the current task as the wire request that
74
74
  * triggered it, not a second, independently-computed answer.
75
75
  */
76
- export async function compact(messages, { model, signal, auto = false } = {}) {
76
+ export async function compact(messages, {
77
+ model, signal, auto = false, window = config.contextWindow, maxTokens = config.maxTokens,
78
+ } = {}) {
77
79
  const system = messages[0];
78
80
  const raw = transcript(forRequest(messages, auto));
79
81
  if (!raw.trim()) return { messages, before: 0, after: 0 };
80
82
 
81
83
  const before = await tokenize(model, [system.content, raw].join('\n'));
82
84
 
83
- // Leave room for the instruction and the summary itself.
84
- const window = config.contextWindow ?? 32768;
85
- const { text: body, elided } = fit(raw, Math.floor(window * 0.6));
85
+ // Leave room for the instruction and the summary itself. The window belongs
86
+ // to the model this compaction runs on, which is not always the one the
87
+ // session started with — see applyLimits in src/config.js.
88
+ const { text: body, elided } = fit(raw, Math.floor((window ?? 32768) * 0.6));
86
89
  if (elided) console.log(c.grey(` transcript too large for one pass — elided ${elided.toLocaleString()} chars from the middle`));
87
90
 
88
91
  let summary = '';
@@ -92,7 +95,7 @@ export async function compact(messages, { model, signal, auto = false } = {}) {
92
95
  { role: 'user', content: `${body}\n\n---\n\n${PROMPT}` },
93
96
  ],
94
97
  signal,
95
- maxTokens: Math.min(4096, config.maxTokens),
98
+ maxTokens: Math.min(4096, maxTokens),
96
99
  noThink: true,
97
100
  })) {
98
101
  if (ev.type === 'text') summary += ev.value;
package/src/config.js CHANGED
@@ -14,6 +14,11 @@ function fileConfig() {
14
14
 
15
15
  const file = fileConfig();
16
16
 
17
+ /** Only ever used when neither the user nor the model's profile has a number. */
18
+ export const DEFAULT_MAX_TOKENS = 8192;
19
+
20
+ const maxTokensRaw = process.env.KRONK_MAX_TOKENS ?? file.maxTokens;
21
+
17
22
  /** Used when nothing is passed on the command line or in the environment. */
18
23
  export const DEFAULT_MODEL = 'unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT';
19
24
 
@@ -28,7 +33,13 @@ export const config = {
28
33
  baseUrl: process.env.KRONK_URL ?? file.baseUrl ?? 'http://localhost:11435/v1',
29
34
  token: process.env.KRONK_TOKEN ?? file.token ?? 'kronk',
30
35
  model: process.env.KRONK_MODEL ?? file.model ?? null, // null → DEFAULT_MODEL, then auto-pick
31
- maxTokens: Number(process.env.KRONK_MAX_TOKENS ?? file.maxTokens ?? 8192),
36
+ maxTokens: Number(maxTokensRaw ?? DEFAULT_MAX_TOKENS),
37
+ // Whether the number above is the user's answer or ours. When it is ours,
38
+ // boot replaces it with whatever the chosen model's own /AGENT profile
39
+ // allows — see applyLimits. Without this flag there is no way to tell a
40
+ // deliberate 8192 from the default one, and the profile would silently
41
+ // overrule a number the user typed.
42
+ maxTokensExplicit: maxTokensRaw !== undefined,
32
43
  // Unlimited by default — a run stops when the model is done or you press Ctrl-C.
33
44
  // Set --steps / KRONK_MAX_STEPS to opt into a cap.
34
45
  maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
@@ -50,12 +61,24 @@ export const config = {
50
61
  // Kronk admits a model on its first inference request, not at server start.
51
62
  // Pay that cold load at boot rather than on the first typed prompt.
52
63
  warm: (process.env.KRONK_WARM ?? String(file.warm ?? 'true')) !== 'false',
64
+ // 'auto' asks the terminal what colour its background is; 'dark' or 'light'
65
+ // pins the palette when it answers wrong or cannot answer at all. See
66
+ // src/theme.js — the value is only ever read there.
67
+ theme: process.env.KRONK_THEME ?? file.theme ?? 'auto',
53
68
  autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
54
69
  compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
55
70
  // Large tool output is summarized in a throwaway context so the raw text
56
71
  // never enters the conversation. Set KRONK_DISTILL=false to keep it whole.
57
72
  distill: (process.env.KRONK_DISTILL ?? String(file.distill ?? 'true')) !== 'false',
58
73
  distillAt: Number(process.env.KRONK_DISTILL_AT ?? file.distillAt ?? 8000),
74
+ // Delegation. A sub-agent runs one task in a context of its own and hands
75
+ // back only its report, so what it read never enters this conversation —
76
+ // src/subagent.js has the argument. The step cap is finite where the main
77
+ // one is not: nobody is watching a sub-agent, and its report is worth less
78
+ // than the window it would spend earning it.
79
+ subagents: (process.env.KRONK_SUBAGENTS ?? String(file.subagents ?? 'true')) !== 'false',
80
+ subagentModel: process.env.KRONK_SUBAGENT_MODEL ?? file.subagentModel ?? null,
81
+ subagentSteps: Number(process.env.KRONK_SUBAGENT_STEPS ?? file.subagentSteps ?? 40),
59
82
  // Kronk's per-model runtime settings. `setup` is the only thing that writes
60
83
  // it; the override exists so tests never go near the real one.
61
84
  modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
@@ -66,13 +89,39 @@ export const config = {
66
89
  .filter((p) => typeof p === 'string' && p.trim())
67
90
  .map((p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p)),
68
91
  lastUsed: 0,
69
- contextWindow: null, // filled in at boot from Kronk
92
+ // The startup scan of the working directory, kept so a sub-agent starts
93
+ // knowing what the project is instead of spending its first two steps on it.
94
+ projectPrimer: null,
95
+ contextWindow: null, // filled in at boot from the chosen model's profile
70
96
  nativeContext: null,
97
+ // The sub-agent model's own limits, when one is configured. A sub-agent on
98
+ // a 32k model must not compact against the main model's 131k window.
99
+ subagentLimits: null,
71
100
  templatePreservesThinking: false, // filled in at boot from the model's template
72
101
  samplingOverride: null, // filled in at boot: params where the profile overrides the model's own
73
102
  rcPath: RC,
74
103
  };
75
104
 
105
+ /**
106
+ * Point the session's limits at one model's profile.
107
+ *
108
+ * Every one of these was resolved once at boot and then treated as a property
109
+ * of the program rather than of the model, so `/model` could move the session
110
+ * to a 32k model while compaction still aimed at 131k and `preserve_thinking`
111
+ * was still sent to a template that does not declare it. They are properties
112
+ * of whichever model is selected now.
113
+ */
114
+ export function applyLimits({
115
+ configured, native, preserveThinking, samplingDiff, maxTokens,
116
+ } = {}) {
117
+ config.contextWindow = configured ?? null;
118
+ config.nativeContext = native ?? null;
119
+ config.templatePreservesThinking = Boolean(preserveThinking);
120
+ config.samplingOverride = samplingDiff ?? null;
121
+ if (!config.maxTokensExplicit) config.maxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
122
+ return config;
123
+ }
124
+
76
125
  /**
77
126
  * Whether this request should pin the earlier think blocks: the user wants it,
78
127
  * the model's template declares the parameter, and reasoning is on at all —
package/src/distill.js CHANGED
@@ -103,10 +103,13 @@ export function shouldDistill(result) {
103
103
 
104
104
  export async function maybeDistill(result, opts) {
105
105
  if (!shouldDistill(result)) return result;
106
+ // `out` so a sub-agent's distillation prints inside its own indented block
107
+ // rather than at the caller's margin. See runTask in src/subagent.js.
108
+ const out = opts.out ?? console.log;
106
109
  const before = await tokenize(opts.model, result);
107
110
  const digest = await distill(result, opts);
108
111
  if (!digest || digest.length >= result.length) return result;
109
112
  const after = await tokenize(opts.model, digest);
110
- console.log(c.grey(` distilled ${before.toLocaleString()} → ${after.toLocaleString()} tokens (separate context)`));
113
+ out(c.grey(` distilled ${before.toLocaleString()} → ${after.toLocaleString()} tokens (separate context)`));
111
114
  return digest;
112
115
  }
package/src/index.js CHANGED
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import readline from 'node:readline/promises';
3
2
  import { stdin, stdout } from 'node:process';
4
3
  import { homedir } from 'node:os';
5
4
  import { readFile } from 'node:fs/promises';
6
- import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
5
+ import { config, DEFAULT_MODEL, applyLimits, warnIfInsecure } from './config.js';
7
6
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
8
7
  import { pickDefault, ensureLoaded } from './boot.js';
9
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
10
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
10
+ import { resolveTheme, theme, useTheme } from './theme.js';
11
11
  import { projectContext } from './context.js';
12
12
  import { forRequest } from './reasoning.js';
13
13
  import { compact, report } from './compact.js';
14
14
  import { loadServers, McpHub, reportFailures } from './mcp.js';
15
15
  import { resolveSandbox, sandbox } from './tools.js';
16
16
  import { parseArgv } from './argv.js';
17
+ import { deferredPrompt } from './prompt.js';
18
+ import { AGENTS } from './subagent.js';
17
19
  import { runSetup } from './setup.js';
18
20
 
19
21
  // ---- argv -------------------------------------------------------------
@@ -50,6 +52,7 @@ if (args.help) {
50
52
  -a, --auto autonomous: approve tools automatically, finish the task
51
53
  -y, --yes approve tools automatically (no autonomous prompt)
52
54
  --no-think disable the model's reasoning pass (faster)
55
+ --no-subagents remove the task tool; no delegation
53
56
  --steps <n> cap tool calls per task (default: unlimited)
54
57
  -h, --help this message
55
58
  -- end option parsing; everything after is the prompt
@@ -59,15 +62,23 @@ if (args.help) {
59
62
  KRONK_TOKEN any non-empty value when Kronk runs open
60
63
  KRONK_MODEL overrides the default model
61
64
  KRONK_MODEL_CONFIG path to Kronk's model_config.yaml, used by setup
62
- KRONK_MAX_TOKENS output cap per response (default 8192)
65
+ KRONK_MAX_TOKENS output cap per response; overrides the cap the
66
+ chosen model's own profile sets (8192 if it sets none)
63
67
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
64
68
  KRONK_NO_THINK set to 1 to disable reasoning
65
69
  KRONK_PRESERVE_THINKING
66
70
  false to stop pinning earlier think blocks in the
67
71
  prompt (smaller prompts, cache lost on every turn)
72
+ KRONK_THEME dark or light to pin the palette; default auto, which
73
+ asks the terminal for its background colour
68
74
  KRONK_WARM false to skip the boot-time model preload
69
75
  KRONK_AUTO_COMPACT false to disable automatic compaction
70
76
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
77
+ KRONK_SUBAGENTS false to remove the task tool
78
+ KRONK_SUBAGENT_MODEL
79
+ model sub-agents run on (default: the main model)
80
+ KRONK_SUBAGENT_STEPS
81
+ tool-call cap for one delegated task (default 40)
71
82
 
72
83
  Config file: ~/.kronk-cli.json
73
84
  `);
@@ -88,6 +99,7 @@ const MCP_ON = args.mcp;
88
99
  const MCP_WANTED = args.mcpNames;
89
100
 
90
101
  if (args.noThink) config.noThink = true;
102
+ if (args.noSubagents) config.subagents = false;
91
103
  if (args.noCompact) config.autoCompact = false;
92
104
  if (args.noWarm) config.warm = false;
93
105
  if (args.model) config.model = args.model;
@@ -128,18 +140,51 @@ async function boot() {
128
140
  }
129
141
  if (!config.model) config.model = pickDefault(ids);
130
142
 
143
+ // Resolved against what Kronk is actually serving, exactly like the main
144
+ // model: a substring in the config file that matches nothing would otherwise
145
+ // only surface as a 404 in the middle of somebody's first delegated task.
146
+ if (config.subagentModel) {
147
+ const subs = ids.filter((id) => id.includes(config.subagentModel));
148
+ const hit = ids.find((id) => id === config.subagentModel)
149
+ ?? subs.find((id) => id.endsWith('/AGENT'))
150
+ ?? subs[0];
151
+ if (hit) config.subagentModel = hit;
152
+ else {
153
+ console.error(c.yellow(` no model matching "${config.subagentModel}" for sub-agents`
154
+ + ' — they will use the main model'));
155
+ config.subagentModel = null;
156
+ }
157
+ }
158
+
131
159
  if (config.warm) await ensureLoaded(ids);
132
160
 
133
- const {
134
- configured, native, preserveThinking, samplingDiff,
135
- } = await modelLimits(config.model);
136
- config.contextWindow = configured;
137
- config.nativeContext = native;
138
- config.templatePreservesThinking = preserveThinking;
139
- config.samplingOverride = samplingDiff;
161
+ // After `ensureLoaded`, which may have fallen back to a different model
162
+ // than the one asked for. Reading the profile before that point would
163
+ // describe a model this session is not using.
164
+ applyLimits(await modelLimits(config.model));
165
+
166
+ // The sub-agent model's profile is its own. Resolved once here rather than
167
+ // on the first delegation, where the wait would land mid-task.
168
+ config.subagentLimits = config.subagentModel
169
+ ? await modelLimits(config.subagentModel)
170
+ : null;
140
171
  return ids;
141
172
  }
142
173
 
174
+ /**
175
+ * Move the session onto another model, limits and all.
176
+ *
177
+ * `/model` used to assign `config.model` and stop there, so a switch to a 32k
178
+ * model kept compacting against the previous model's 131k window and kept
179
+ * sending `preserve_thinking` to a template that never declared it. Every
180
+ * limit is a property of the selected model, so every limit is re-read here.
181
+ */
182
+ async function switchModel(id) {
183
+ config.model = id;
184
+ applyLimits(await modelLimits(id));
185
+ return id;
186
+ }
187
+
143
188
  const gb = (n) => `${(n / 1e9).toFixed(1)} GB`;
144
189
 
145
190
  /** Connect the MCP servers the user asked for. Never fatal. */
@@ -244,8 +289,10 @@ const HELP = `
244
289
  ${c.bold('/think')} turn reasoning off entirely (much faster)
245
290
  ${c.bold('/auto')} autonomous mode: auto-approve tools, run to completion
246
291
  ${c.bold('/steps [n|off]')} cap tool calls per task (default: unlimited)
292
+ ${c.bold('/agents')} sub-agents you can delegate to with the task tool
247
293
  ${c.bold('/mcp')} list attached MCP servers and their tools
248
294
  ${c.bold('/context')} how much of the context window is used
295
+ ${c.bold('/theme [d|l]')} dark or light palette, for when the terminal guessed wrong
249
296
  ${c.bold('/compact')} replace the conversation with a summary of itself
250
297
  ${c.bold('/clear')} reset the conversation
251
298
  ${c.bold('/exit')} quit
@@ -260,6 +307,9 @@ async function systemMessage(auto) {
260
307
  const base = auto ? SYSTEM_AUTO : SYSTEM;
261
308
  if (NO_CONTEXT) return { content: base, ctx: null };
262
309
  const ctx = await projectContext(process.cwd());
310
+ // Kept for the sub-agents, which get the same primer without paying for the
311
+ // scan again — see systemFor in src/subagent.js.
312
+ config.projectPrimer = ctx.text;
263
313
  const budget = config.contextWindow
264
314
  ? `\n\nYour context window is ${config.contextWindow.toLocaleString()} tokens, shared by `
265
315
  + `everything in this conversation: these instructions, file contents you read, command `
@@ -379,7 +429,26 @@ async function main() {
379
429
  return;
380
430
  }
381
431
 
382
- const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
432
+ // Before readline takes stdin, because settling the palette may mean asking
433
+ // the terminal for its background colour and reading the reply in raw mode.
434
+ await resolveTheme({ prefer: config.theme, input: stdin, output: stdout });
435
+
436
+ // Not created yet: the interface is opened by the first question, below.
437
+ // Everything between here and there — the model load, the project scan, the
438
+ // MCP connections — is time somebody may spend typing, and readline emits
439
+ // lines whether or not anything is listening. See src/prompt.js.
440
+ let ac = null;
441
+ const rl = deferredPrompt({
442
+ input: stdin,
443
+ output: stdout,
444
+ historySize: 500,
445
+ // Ctrl-C aborts the in-flight request instead of killing the process.
446
+ onSigint: () => {
447
+ if (ac) { ac.abort(); ac = null; console.log(c.yellow('\n interrupted')); }
448
+ else { console.log(); rl.close(); }
449
+ },
450
+ });
451
+
383
452
  await boot();
384
453
  const { content, ctx } = await systemMessage(AUTO);
385
454
  console.log(banner(config.model, config.baseUrl));
@@ -417,13 +486,6 @@ async function main() {
417
486
  // it — so both the status line and the turn read the answer from there.
418
487
  const isAuto = () => messages[0].content.startsWith(SYSTEM_AUTO);
419
488
 
420
- // Ctrl-C aborts the in-flight request instead of killing the process.
421
- let ac = null;
422
- rl.on('SIGINT', () => {
423
- if (ac) { ac.abort(); ac = null; console.log(c.yellow('\n interrupted')); }
424
- else { console.log(); rl.close(); }
425
- });
426
-
427
489
  let autoApprove = AUTO_YES;
428
490
  const approve = async (name) => {
429
491
  if (autoApprove) return true;
@@ -500,6 +562,31 @@ async function main() {
500
562
  console.log(c.grey(` thinking display ${config.showThinking ? 'on' : 'off'}`));
501
563
  continue;
502
564
  }
565
+ if (input === '/agents') {
566
+ if (!config.subagents) {
567
+ console.log(c.grey(' delegation is off — drop --no-subagents / KRONK_SUBAGENTS=false'));
568
+ continue;
569
+ }
570
+ console.log(c.grey('\n the model delegates with the task tool — one task, its own context,'));
571
+ console.log(c.grey(' and only its report comes back. You still approve its writes and commands.'));
572
+ for (const [name, a] of Object.entries(AGENTS)) {
573
+ console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(`· ${a.tools.join(', ')}`)}`);
574
+ console.log(c.grey(` for ${a.use}`));
575
+ }
576
+ console.log(c.grey(` model: ${config.subagentModel ?? config.model} · `
577
+ + `cap ${config.subagentSteps} steps per task\n`));
578
+ continue;
579
+ }
580
+ if (input === '/theme' || input.startsWith('/theme ')) {
581
+ // Everything prints through src/ui.js, which reads the palette per call,
582
+ // so the next line drawn is already in the new one — nothing to redraw.
583
+ const want = input.slice(6).trim().toLowerCase();
584
+ const name = { d: 'dark', dark: 'dark', l: 'light', light: 'light' }[want];
585
+ if (want && !name) { console.log(c.yellow(' /theme dark | /theme light')); continue; }
586
+ if (name) useTheme({ name });
587
+ console.log(c.grey(` ${theme()} palette`));
588
+ continue;
589
+ }
503
590
  if (input === '/models') { await showModels(); continue; }
504
591
  if (input === '/mcp') {
505
592
  if (!mcp || !mcp.routes.size) { console.log(c.grey(' no MCP servers attached — start with --mcp')); continue; }
@@ -538,8 +625,11 @@ async function main() {
538
625
  ?? subs.find((m) => m.endsWith('/AGENT'))
539
626
  ?? subs[0];
540
627
  if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
541
- config.model = hit;
628
+ await switchModel(hit);
542
629
  console.log(c.green(` switched to ${hit}`));
630
+ console.log(c.grey(` window ${config.contextWindow?.toLocaleString() ?? 'unknown'}`
631
+ + ` · output cap ${config.maxTokens.toLocaleString()}`
632
+ + `${config.maxTokensExplicit ? ' (yours)' : ' (from its profile)'}`));
543
633
  continue;
544
634
  }
545
635
  if (input.startsWith('/file ')) {
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Markdown, rendered for the terminal as it streams.
3
+ *
4
+ * The models write markdown whether or not anything is going to render it, so
5
+ * an answer arrived as `### The letter is a notification` and `**binding**`
6
+ * with the punctuation left in the reader's way. This turns the handful of
7
+ * constructs that actually show up in an answer — headings, emphasis, code,
8
+ * lists, quotes, rules, links — into the attributes a terminal already has.
9
+ *
10
+ * Deliberately not a markdown parser. It is a line renderer with one bit of
11
+ * state, whether a fenced code block is open, which is all that spans lines in
12
+ * the subset above. Anything it does not recognise is left exactly as written,
13
+ * which is the only safe answer for text that was never markdown to begin with.
14
+ *
15
+ * Line-buffered, because inline emphasis cannot be decided until the line is
16
+ * whole: `**` is bold or two asterisks depending on what comes next. Output
17
+ * therefore appears a line at a time rather than a token at a time. With colour
18
+ * off — piped, redirected, NO_COLOR — nothing is rewritten at all and the bytes
19
+ * are the model's own.
20
+ */
21
+ import { c } from './ui.js';
22
+ import { colorsOn } from './theme.js';
23
+
24
+ const FENCE = /^\s*(?:```|~~~)\s*([\w+-]*)\s*$/;
25
+ const HEADING = /^(#{1,6})\s+(.*)$/;
26
+ const BULLET = /^(\s*)[-*+]\s+(.*)$/;
27
+ const ORDERED = /^(\s*)(\d{1,9})([.)])\s+(.*)$/;
28
+ const QUOTE = /^\s*>\s?(.*)$/;
29
+ const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
30
+
31
+ /** How wide a `---` is drawn. Narrow enough to sit inside any terminal. */
32
+ const RULE_WIDTH = 40;
33
+
34
+ /**
35
+ * Inline spans, in one pass over the parts that are not code.
36
+ *
37
+ * Code first and separately: whatever is between backticks is content, not
38
+ * markup, and running the emphasis rules over it would eat the asterisks in a
39
+ * `**kwargs` or a glob.
40
+ */
41
+ export function renderInline(text) {
42
+ return text
43
+ .split(/(`[^`]+`)/)
44
+ .map((part, i) => {
45
+ if (i % 2) return c.cyan(part.slice(1, -1));
46
+ return part
47
+ // Links before emphasis: the label can contain either.
48
+ .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => `${c.blue(label)} ${c.grey(url)}`)
49
+ .replace(/\*\*([^\s*][^*]*?)\*\*/g, (_, s) => c.bold(s))
50
+ .replace(/__([^\s_][^_]*?)__/g, (_, s) => c.bold(s))
51
+ .replace(/~~([^\s~][^~]*?)~~/g, (_, s) => c.strike(s))
52
+ // Emphasis last, and only where the marker is not part of a word:
53
+ // snake_case identifiers and 3 * 4 must survive unchanged.
54
+ .replace(/(^|[\s(])\*([^\s*][^*]*?)\*(?=$|[\s).,;:!?])/g, (_, pre, s) => `${pre}${c.italic(s)}`)
55
+ .replace(/(^|[\s(])_([^\s_][^_]*?)_(?=$|[\s).,;:!?])/g, (_, pre, s) => `${pre}${c.italic(s)}`);
56
+ })
57
+ .join('');
58
+ }
59
+
60
+ /**
61
+ * One line, given the state carried from the lines before it. Mutates `state`,
62
+ * which is what a fenced block needs and the only reason state exists.
63
+ */
64
+ export function renderLine(line, state) {
65
+ const fence = FENCE.exec(line);
66
+ if (fence) {
67
+ if (state.fence) { state.fence = null; return c.grey(' ╰────'); }
68
+ state.fence = fence[1] || 'code';
69
+ return c.grey(` ╭─ ${state.fence}`);
70
+ }
71
+ // Inside a fence every character is the code's own — no emphasis, no
72
+ // reflowing, just a gutter to show where the block runs.
73
+ if (state.fence) return `${c.grey(' │ ')}${line}`;
74
+
75
+ if (RULE.test(line)) return c.grey('─'.repeat(RULE_WIDTH));
76
+
77
+ const heading = HEADING.exec(line);
78
+ if (heading) {
79
+ const [, hashes, body] = heading;
80
+ const text = c.bold(renderInline(body));
81
+ return hashes.length <= 2 ? c.cyan(text) : text;
82
+ }
83
+
84
+ const quote = QUOTE.exec(line);
85
+ if (quote) return `${c.grey('│ ')}${c.grey(renderInline(quote[1]))}`;
86
+
87
+ const bullet = BULLET.exec(line);
88
+ if (bullet) return `${bullet[1]}${c.grey('•')} ${renderInline(bullet[2])}`;
89
+
90
+ const ordered = ORDERED.exec(line);
91
+ if (ordered) {
92
+ const [, indent, n, , rest] = ordered;
93
+ return `${indent}${c.grey(`${n}.`)} ${renderInline(rest)}`;
94
+ }
95
+
96
+ return renderInline(line);
97
+ }
98
+
99
+ /**
100
+ * A renderer for streamed text: feed it deltas, write what comes back, and
101
+ * call `flush` when the stream ends to get the last partial line.
102
+ *
103
+ * `enabled` defaults to whether colour is on at all, so the piped and
104
+ * NO_COLOR paths hand back exactly what the model wrote.
105
+ */
106
+ export function markdownStream({ enabled = colorsOn() } = {}) {
107
+ const state = { fence: null };
108
+ let held = '';
109
+ return {
110
+ write(chunk) {
111
+ if (!enabled) return chunk;
112
+ held += chunk;
113
+ let out = '';
114
+ let at;
115
+ while ((at = held.indexOf('\n')) !== -1) {
116
+ out += `${renderLine(held.slice(0, at), state)}\n`;
117
+ held = held.slice(at + 1);
118
+ }
119
+ return out;
120
+ },
121
+ flush() {
122
+ if (!enabled || !held) { const rest = enabled ? '' : held; held = ''; return rest; }
123
+ const out = renderLine(held, state);
124
+ held = '';
125
+ return out;
126
+ },
127
+ /** Whether a fenced block is still open — the tail of a truncated answer. */
128
+ get inCode() { return state.fence !== null; },
129
+ };
130
+ }
131
+
132
+ /** The whole thing at once. The streaming path is the one the REPL uses. */
133
+ export function renderMarkdown(text, options) {
134
+ const md = markdownStream(options);
135
+ return md.write(text) + md.flush();
136
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The line reader: opened only when something is waiting on a line, and never
3
+ * dropping one that arrives before then.
4
+ *
5
+ * `readline` starts consuming stdin the moment it is created and emits every
6
+ * line it parses, listener or no listener. Startup is not instant — the model
7
+ * list, a cold load that can run to tens of seconds, the project scan, the MCP
8
+ * connections — so creating the interface up front meant every line typed in
9
+ * that window was emitted into nothing: no echo left, no error, and a prompt
10
+ * that arrived as though you had typed nothing at all.
11
+ *
12
+ * Two things fix that, and both are needed. Creation is deferred to the first
13
+ * question, which leaves those keystrokes in the terminal's own input buffer
14
+ * until there is something to read them. And once the interface exists, lines
15
+ * are queued rather than answered directly by `rl.question`, because a paste
16
+ * or a fast typist can hand readline several lines in one read and only the
17
+ * first would have had a listener waiting for it.
18
+ *
19
+ * One interface is created and kept, so history, line editing and Ctrl-C are
20
+ * readline's own, exactly as before.
21
+ */
22
+ import readline from 'node:readline';
23
+
24
+ /** What a pending question rejects with when the reader closes under it. */
25
+ export class PromptClosed extends Error {
26
+ constructor() { super('the prompt was closed'); this.name = 'PromptClosed'; }
27
+ }
28
+
29
+ export function deferredPrompt({ input, output, historySize = 500, onSigint } = {}) {
30
+ let rl = null;
31
+ let closed = false;
32
+ let waiting = null; // the question currently unanswered, if any
33
+ const queued = []; // lines that arrived with nobody asking
34
+
35
+ const open = () => {
36
+ if (rl) return rl;
37
+ rl = readline.createInterface({ input, output, historySize });
38
+ rl.on('line', (line) => {
39
+ if (waiting) { const w = waiting; waiting = null; w.resolve(line); }
40
+ else queued.push(line);
41
+ });
42
+ rl.on('close', () => {
43
+ closed = true;
44
+ if (waiting) { const w = waiting; waiting = null; w.reject(new PromptClosed()); }
45
+ });
46
+ // Registered with the interface rather than by the caller, so there is no
47
+ // moment in which the interface exists and Ctrl-C is unhandled.
48
+ if (onSigint) rl.on('SIGINT', () => onSigint());
49
+ return rl;
50
+ };
51
+
52
+ return {
53
+ /**
54
+ * Ask, opening the interface if this is the first time. A line that was
55
+ * typed ahead answers immediately — echoed after the prompt, so the
56
+ * transcript reads the way it would have if the timing had been kinder.
57
+ */
58
+ question(query) {
59
+ if (closed) return Promise.reject(new PromptClosed());
60
+ const reader = open();
61
+ if (queued.length) {
62
+ const line = queued.shift();
63
+ output.write(`${query}${line}\n`);
64
+ return Promise.resolve(line);
65
+ }
66
+ // setPrompt rather than a bare write: readline needs to know the prompt
67
+ // to redraw the line correctly when it is edited.
68
+ reader.setPrompt(query);
69
+ reader.prompt();
70
+ return new Promise((resolve, reject) => { waiting = { resolve, reject }; });
71
+ },
72
+ /** Closing one that was never opened is not an error, it is a no-op. */
73
+ close() { closed = true; rl?.close(); },
74
+ /** For tests, and for anything that wants to know without opening one. */
75
+ get open() { return rl !== null; },
76
+ /** Lines typed ahead that no question has taken yet. */
77
+ get pending() { return queued.length; },
78
+ };
79
+ }