kronk-cli 0.1.3 → 0.2.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/index.js CHANGED
@@ -8,31 +8,22 @@ import { pickDefault, ensureLoaded } from './boot.js';
8
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
9
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
10
10
  import { projectContext } from './context.js';
11
+ import { forRequest } from './reasoning.js';
11
12
  import { compact, report } from './compact.js';
12
13
  import { loadServers, McpHub, reportFailures } from './mcp.js';
13
14
  import { resolveSandbox, sandbox } from './tools.js';
15
+ import { parseArgv } from './argv.js';
16
+ import { runSetup } from './setup.js';
14
17
 
15
18
  // ---- argv -------------------------------------------------------------
16
- const argv = process.argv.slice(2);
17
- const flag = (...names) => {
18
- const i = argv.findIndex((a) => names.includes(a));
19
- if (i === -1) return false;
20
- argv.splice(i, 1);
21
- return true;
22
- };
23
- let AUTO_YES = flag('-y', '--yes');
24
- if (flag('--no-think')) config.noThink = true;
25
- // --auto: run the whole task unattended (implies --yes)
26
- const AUTO = flag('-a', '--auto');
27
- if (AUTO) AUTO_YES = true;
28
-
29
- const SHOW_MODELS = flag('--models', '-l', '--list');
30
- const SHOW_MCP = flag('--mcp-list');
31
- const NO_CONTEXT = flag('--no-context');
32
- if (flag('--no-compact')) config.autoCompact = false;
33
- if (flag('--no-warm')) config.warm = false;
19
+ const args = parseArgv(process.argv.slice(2));
20
+ if (args.error) {
21
+ // A usage error is not a conversation: stderr, exit 2, nothing sent anywhere.
22
+ console.error(`\n${args.error}\n`);
23
+ process.exit(2);
24
+ }
34
25
 
35
- if (flag('-h', '--help')) {
26
+ if (args.help) {
36
27
  console.log(`
37
28
  kronk-cli — a terminal agent for local models served by Kronk
38
29
 
@@ -41,6 +32,11 @@ if (flag('-h', '--help')) {
41
32
  kronk-cli "<prompt>" run one prompt and exit
42
33
  <cmd> | kronk-cli "<prompt>" pipe stdin in as extra context
43
34
 
35
+ SUBCOMMANDS
36
+ kronk-cli setup [--model <id>] [--context <n>] [-y] [--dry-run]
37
+ pull the model, write its /AGENT profile to
38
+ ~/.kronk/models/model_config.yaml, restart Kronk
39
+
44
40
  OPTIONS
45
41
  -l, --models list the models Kronk is serving, then exit
46
42
  --no-context skip the startup scan of the working directory
@@ -55,14 +51,19 @@ if (flag('-h', '--help')) {
55
51
  --no-think disable the model's reasoning pass (faster)
56
52
  --steps <n> cap tool calls per task (default: unlimited)
57
53
  -h, --help this message
54
+ -- end option parsing; everything after is the prompt
58
55
 
59
56
  ENVIRONMENT
60
57
  KRONK_URL default http://localhost:11435/v1
61
58
  KRONK_TOKEN any non-empty value when Kronk runs open
62
59
  KRONK_MODEL overrides the default model
60
+ KRONK_MODEL_CONFIG path to Kronk's model_config.yaml, used by setup
63
61
  KRONK_MAX_TOKENS output cap per response (default 8192)
64
62
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
65
63
  KRONK_NO_THINK set to 1 to disable reasoning
64
+ KRONK_PRESERVE_THINKING
65
+ false to stop pinning earlier think blocks in the
66
+ prompt (smaller prompts, cache lost on every turn)
66
67
  KRONK_WARM false to skip the boot-time model preload
67
68
  KRONK_AUTO_COMPACT false to disable automatic compaction
68
69
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
@@ -71,34 +72,22 @@ if (flag('-h', '--help')) {
71
72
  `);
72
73
  process.exit(0);
73
74
  }
74
- const opt = (name) => {
75
- const i = argv.findIndex((a) => a === name);
76
- if (i === -1) return null;
77
- const v = argv[i + 1];
78
- argv.splice(i, 2);
79
- return v;
80
- };
81
- const modelArg = opt('--model') ?? opt('-m');
82
- if (modelArg) config.model = modelArg;
75
+
76
+ // --auto: run the whole task unattended (implies --yes)
77
+ const AUTO = args.auto;
78
+ const AUTO_YES = args.yes || AUTO;
79
+ const SHOW_MODELS = args.models;
80
+ const SHOW_MCP = args.mcpList;
81
+ const NO_CONTEXT = args.noContext;
83
82
  // `--mcp` alone attaches everything configured; `--mcp nx,kronk` narrows it.
84
- let MCP_ON = false;
85
- let MCP_WANTED = null;
86
- {
87
- const i = argv.findIndex((a) => a === '--mcp');
88
- if (i !== -1) {
89
- MCP_ON = true;
90
- const next = argv[i + 1];
91
- if (next && !next.startsWith('-')) {
92
- MCP_WANTED = next.split(',').map((x) => x.trim()).filter(Boolean);
93
- argv.splice(i, 2);
94
- } else {
95
- argv.splice(i, 1);
96
- }
97
- }
98
- }
83
+ const MCP_ON = args.mcp;
84
+ const MCP_WANTED = args.mcpNames;
99
85
 
100
- const stepsArg = opt('--steps');
101
- if (stepsArg) config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(stepsArg) ? Infinity : Number(stepsArg);
86
+ if (args.noThink) config.noThink = true;
87
+ if (args.noCompact) config.autoCompact = false;
88
+ if (args.noWarm) config.warm = false;
89
+ if (args.model) config.model = args.model;
90
+ if (args.steps !== null) config.maxSteps = args.steps;
102
91
 
103
92
  async function boot() {
104
93
  let ids;
@@ -137,9 +126,13 @@ async function boot() {
137
126
 
138
127
  if (config.warm) await ensureLoaded(ids);
139
128
 
140
- const { configured, native } = await modelLimits(config.model);
129
+ const {
130
+ configured, native, preserveThinking, samplingDiff,
131
+ } = await modelLimits(config.model);
141
132
  config.contextWindow = configured;
142
133
  config.nativeContext = native;
134
+ config.templatePreservesThinking = preserveThinking;
135
+ config.samplingOverride = samplingDiff;
143
136
  return ids;
144
137
  }
145
138
 
@@ -320,7 +313,7 @@ async function oneShot(prompt) {
320
313
  return false;
321
314
  };
322
315
  try {
323
- await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp });
316
+ await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp, auto: AUTO });
324
317
  } catch (e) {
325
318
  if (e.name !== 'AbortError') { console.error(c.red(` ${e.message}`)); process.exitCode = 1; }
326
319
  } finally {
@@ -332,11 +325,28 @@ async function main() {
332
325
  // Before anything reaches the network, on every path through the program.
333
326
  warnIfInsecure();
334
327
 
328
+ // Subcommands are dispatched before the one-shot path below, so `setup` is
329
+ // never mistaken for a one-word prompt and sent to the model.
330
+ if (args.words[0] === 'setup') {
331
+ if (args.words.length > 1) {
332
+ console.error(`\n setup takes no arguments — got: ${args.words.slice(1).join(' ')}\n`);
333
+ process.exitCode = 2;
334
+ return;
335
+ }
336
+ process.exitCode = await runSetup({
337
+ model: args.model,
338
+ context: args.context,
339
+ yes: args.yes,
340
+ dryRun: args.dryRun,
341
+ });
342
+ return;
343
+ }
344
+
335
345
  if (SHOW_MODELS) { await showModels(); return; }
336
346
  if (SHOW_MCP) { await showMcp(); process.exit(0); }
337
347
 
338
348
  // non-interactive: `kronk-cli "prompt"` or `echo prompt | kronk-cli`
339
- const inline = argv.join(' ').trim();
349
+ const inline = args.words.join(' ').trim();
340
350
  // With an inline prompt, stdin is optional extra context — don't block on it.
341
351
  // Without one, stdin IS the prompt, so wait longer before giving up.
342
352
  const piped = await readStdin(inline ? 200 : 10_000);
@@ -356,6 +366,17 @@ async function main() {
356
366
  await boot();
357
367
  const { content, ctx } = await systemMessage(AUTO);
358
368
  console.log(banner(config.model, config.baseUrl));
369
+
370
+ // Information, not a failure: the profile is doing exactly what it was
371
+ // told to, it's just not what the model's own GGUF recommends. One-shot
372
+ // mode never reaches this line because it never prints the banner either.
373
+ if (config.samplingOverride) {
374
+ const named = config.samplingOverride
375
+ .map((d) => `${d.param} ${d.effective} (model recommends ${d.model})`)
376
+ .join(', ');
377
+ console.log(c.grey(` note profile overrides the model's own sampling: ${named}`));
378
+ }
379
+
359
380
  const mcp = await startMcp();
360
381
 
361
382
  if (ctx) {
@@ -375,6 +396,9 @@ async function main() {
375
396
  : c.grey(`paths + ${backend}`)}\n`);
376
397
 
377
398
  const messages = [{ role: 'system', content }];
399
+ // The system prompt is the one record of which mode we are in — /auto rewrites
400
+ // it — so both the status line and the turn read the answer from there.
401
+ const isAuto = () => messages[0].content.startsWith(SYSTEM_AUTO);
378
402
 
379
403
  // Ctrl-C aborts the in-flight request instead of killing the process.
380
404
  let ac = null;
@@ -393,9 +417,11 @@ async function main() {
393
417
  for (;;) {
394
418
  const status = statusLine({
395
419
  model: config.model,
396
- auto: autoApprove && messages[0].content.startsWith(SYSTEM_AUTO),
420
+ auto: autoApprove && isAuto(),
397
421
  yes: autoApprove,
398
422
  noThink: config.noThink,
423
+ // Only news when the model could have had it and the user said no.
424
+ noPreserve: config.templatePreservesThinking && !config.noThink && !config.preserveThinking,
399
425
  mcp: mcp?.routes.size ? [...mcp.servers.keys()].join(',') : null,
400
426
  steps: config.maxSteps,
401
427
  used: config.lastUsed,
@@ -425,9 +451,8 @@ async function main() {
425
451
  continue;
426
452
  }
427
453
  if (input === '/auto') {
428
- const now = !messages[0].content.startsWith(SYSTEM_AUTO);
429
- const primer = messages[0].content.slice(
430
- (messages[0].content.startsWith(SYSTEM_AUTO) ? SYSTEM_AUTO : SYSTEM).length);
454
+ const now = !isAuto();
455
+ const primer = messages[0].content.slice((isAuto() ? SYSTEM_AUTO : SYSTEM).length);
431
456
  messages[0] = { role: 'system', content: (now ? SYSTEM_AUTO : SYSTEM) + primer };
432
457
  autoApprove = now;
433
458
  console.log(c.grey(` autonomous mode ${now ? 'on — tools auto-approved, runs to completion' : 'off'}`));
@@ -455,14 +480,18 @@ async function main() {
455
480
  if (input === '/compact') {
456
481
  if (messages.length < 2) { console.log(c.grey(' nothing to compact')); continue; }
457
482
  const sp = new AbortController();
458
- const res = await compact(messages, { model: config.model, signal: sp.signal });
483
+ const res = await compact(messages, { model: config.model, signal: sp.signal, auto: isAuto() });
459
484
  if (res.failed) { console.log(c.red(' compaction produced nothing — conversation unchanged')); continue; }
460
485
  if (!res.skipped) messages.splice(0, messages.length, ...res.messages);
461
486
  console.log(report(res));
462
487
  continue;
463
488
  }
464
489
  if (input === '/context') {
465
- const used = await tokenize(config.model, messages.map((m) => m.content ?? '').join('\n'));
490
+ // Count what the next request would actually carry, replayed reasoning
491
+ // included — the history holds reasoning that is never sent, and a meter
492
+ // that charged for it would read high for the whole session.
493
+ const used = await tokenize(config.model, forRequest(messages, isAuto())
494
+ .map((m) => `${m.reasoning_content ?? ''}${m.content ?? ''}`).join('\n'));
466
495
  console.log(` ${fmtContext(used, config.contextWindow) || c.grey('unknown')}`);
467
496
  console.log(c.grey(` window: ${config.contextWindow?.toLocaleString() ?? '?'} tokens`
468
497
  + (config.nativeContext ? ` · model supports up to ${config.nativeContext.toLocaleString()}` : '')));
@@ -495,7 +524,7 @@ async function main() {
495
524
  messages.push({ role: 'user', content: input });
496
525
  ac = new AbortController();
497
526
  try {
498
- await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp });
527
+ await runTurn({ messages, model: config.model, signal: ac.signal, approve, mcp, auto: isAuto() });
499
528
  } catch (e) {
500
529
  if (e.name === 'AbortError') messages.push({ role: 'assistant', content: '(interrupted)' });
501
530
  else console.error(c.red(`\n ${e.message}`));
package/src/plan.js ADDED
@@ -0,0 +1,167 @@
1
+ import { c } from './ui.js';
2
+
3
+ /**
4
+ * The checklist for the task currently being run.
5
+ *
6
+ * Module state rather than a message, because `compactInto` splices the whole
7
+ * transcript when the window fills — a plan that lived only in `messages`
8
+ * would be summarised away on exactly the long runs that need it. What the
9
+ * model sees each round is a snapshot of this store, appended to the tool
10
+ * result that round produced; see `carryChecklist` for why it is appended and
11
+ * never moved.
12
+ */
13
+
14
+ /** Long enough for any real ticket; short enough that a runaway list is caught. */
15
+ export const MAX_ITEMS = 40;
16
+
17
+ const STATUSES = ['todo', 'doing', 'done'];
18
+
19
+ /** Every checklist snapshot and every nudge opens with this, so both are greppable. */
20
+ export const REMINDER_TAG = 'CHECKLIST —';
21
+
22
+ let items = [];
23
+
24
+ export const plan = () => items;
25
+ export const openItems = () => items.filter((i) => i.status !== 'done');
26
+
27
+ /** Called at the start of every turn: a plan belongs to one task, not to a session. */
28
+ export function clearPlan() { items = []; }
29
+
30
+ /** The stored plan as text — what `set_plan` hands back, so the model sees what it committed to. */
31
+ export function render() {
32
+ if (!items.length) return 'plan: (empty)';
33
+ const done = items.length - openItems().length;
34
+ const rows = items.map((i, n) => `${n + 1}. [${i.status}] ${i.text}`);
35
+ return [`plan — ${done}/${items.length} done`, ...rows].join('\n');
36
+ }
37
+
38
+ /**
39
+ * Replace the checklist wholesale.
40
+ *
41
+ * Full replacement, never a patch: partial-update semantics are where a small
42
+ * model quietly drops half its list. Overlong lists are cut rather than
43
+ * refused, and the loss is reported back in the result so it is not silent.
44
+ */
45
+ export function setPlan(raw) {
46
+ if (!Array.isArray(raw)) throw new Error('set_plan needs an "items" array of {text, status}');
47
+
48
+ const kept = raw.slice(0, MAX_ITEMS).map((item, n) => {
49
+ const text = String(item?.text ?? '').trim();
50
+ if (!text) throw new Error(`set_plan: item ${n + 1} has no text`);
51
+ const status = item.status ?? 'todo';
52
+ if (!STATUSES.includes(status)) {
53
+ throw new Error(`set_plan: item ${n + 1} has status "${status}" — use todo, doing or done`);
54
+ }
55
+ return { text, status };
56
+ });
57
+
58
+ items = kept;
59
+ const dropped = raw.length - kept.length;
60
+ if (!dropped) return render();
61
+ return `${render()}\n\nnote: ${raw.length} items were sent and the list is capped at ${MAX_ITEMS}`
62
+ + `, so the last ${dropped} were dropped. Send a shorter plan if they matter.`;
63
+ }
64
+
65
+ const MARK = { todo: '·', doing: '▸', done: '✓' };
66
+
67
+ /** The plan on screen: one line each, greyed except whatever is being worked on. */
68
+ export function planLines() {
69
+ return items.map((i) => {
70
+ const line = ` ${MARK[i.status]} ${i.text}`;
71
+ return i.status === 'doing' ? line : c.grey(line);
72
+ });
73
+ }
74
+
75
+ /** What was left unfinished, in yellow, when the turn ends anyway. */
76
+ export function outstandingLines() {
77
+ const open = openItems();
78
+ if (!open.length) return [];
79
+ return [
80
+ c.yellow(` ⚠ ${open.length} of ${items.length} checklist items were not finished:`),
81
+ ...open.map((i) => c.yellow(` ${MARK[i.status]} ${i.text}`)),
82
+ ];
83
+ }
84
+
85
+ const tally = () => `${items.length - openItems().length}/${items.length} items done`;
86
+ const openLines = () => openItems().map((i) => `- [${i.status}] ${i.text}`);
87
+
88
+ /**
89
+ * The snapshot appended to a round's last tool result.
90
+ *
91
+ * Phrased as a point in time on purpose: earlier rounds keep the snapshot they
92
+ * were given, so several sit in the history at once and only the last one is
93
+ * current. Saying so is what stops a stale one being read as the truth.
94
+ */
95
+ function snapshotText() {
96
+ return [
97
+ `${REMINDER_TAG} where the plan stands at this step. ${tally()}. Still open:`,
98
+ ...openLines(),
99
+ 'An earlier snapshot above is out of date — this is the current one.',
100
+ 'Keep working through the open items, and record each one with set_plan as it is finished.',
101
+ ].join('\n');
102
+ }
103
+
104
+ function nudgeText() {
105
+ return [
106
+ `${REMINDER_TAG} you stopped with work outstanding. ${tally()}. Still open:`,
107
+ ...openLines(),
108
+ 'Do not summarise yet. Carry on with the next open item. If one genuinely cannot be'
109
+ + ' done, say in your reply why not, and only then mark it done with set_plan.',
110
+ ].join('\n');
111
+ }
112
+
113
+ /** A nudge is the only checklist text that is a message of its own. */
114
+ export const isNudge = (m) =>
115
+ m?.role === 'user' && typeof m.content === 'string' && m.content.startsWith(REMINDER_TAG);
116
+
117
+ /** Tool results already carrying a snapshot, held by identity. */
118
+ const carried = new WeakSet();
119
+
120
+ /**
121
+ * Append the checklist to the last tool result of the round that just ran.
122
+ *
123
+ * Append-only, and that is the entire point. This used to be a `user` message
124
+ * of its own, spliced out of the middle of `messages` and re-pushed at the end
125
+ * every round. Removing a message changes the rendered prompt from that point
126
+ * on, and Kronk's incremental prompt cache cannot recover past the change: it
127
+ * keeps the longest common prefix and re-prefills the rest. Measured over four
128
+ * rounds against Kronk 1.31.9 with preserve_thinking on, moving the message
129
+ * gave cached 0 / 607 / 607 / 607 with re-prefill 724 / 193 / 269 / 345 —
130
+ * pinned at the first reminder and growing without bound — where appending
131
+ * gives cached 607 / 715 / 814 / 913 with re-prefill 113 / 104 / 104 / 104.
132
+ * In the field that was a median 14.3s to first token against 0.5s.
133
+ *
134
+ * So: never remove or move anything already in `messages`. Only append. A tool
135
+ * result created this round has not been sent yet, so growing it extends the
136
+ * prefix instead of rewriting it, and it is still the last thing the model
137
+ * reads. Older rounds keep their own snapshot; going back to strip them is the
138
+ * very edit that costs the cache.
139
+ */
140
+ export function carryChecklist(messages) {
141
+ if (!openItems().length) return;
142
+
143
+ // The tail only. A `role: 'tool'` message further back belongs to a round
144
+ // that has already gone out, and editing that is the eviction described
145
+ // above. A round that produced no tool result has nowhere to put this.
146
+ const last = messages.at(-1);
147
+ if (last?.role !== 'tool') return;
148
+
149
+ // Identity, not a search for the tag in the text: a tool result can hold the
150
+ // tag legitimately — a grep of this file does — and appending twice to one
151
+ // message is a bug, not a second snapshot.
152
+ if (carried.has(last)) return;
153
+ carried.add(last);
154
+ last.content = `${last.content}\n\n${snapshotText()}`;
155
+ }
156
+
157
+ /**
158
+ * Hand the open items back as a `user` message when the model stops early.
159
+ *
160
+ * This one stays a message, because a new message on the end is exactly what
161
+ * the cache tolerates: everything before it is untouched. It is pushed and
162
+ * never removed again.
163
+ */
164
+ export function pushNudge(messages) {
165
+ if (!openItems().length) return;
166
+ messages.push({ role: 'user', content: nudgeText() });
167
+ }
@@ -0,0 +1,118 @@
1
+ import { config } from './config.js';
2
+
3
+ /**
4
+ * Index of the last *real* user message, or -1 when there is none.
5
+ *
6
+ * Tool results are `role: 'tool'`, so they do not move it: one user prompt and
7
+ * the whole tool loop it kicked off share a single boundary. That is the same
8
+ * boundary the Qwen3.6 template computes as `ns.last_query_index`, on purpose —
9
+ * see `forRequest` below.
10
+ */
11
+ export function lastUserIndex(messages) {
12
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
13
+ if (messages[i].role === 'user') return i;
14
+ }
15
+ return -1;
16
+ }
17
+
18
+ /**
19
+ * Whether this request should carry the current task's reasoning back.
20
+ *
21
+ * `auto` is autonomous mode, the same flag `runTurn` already threads through
22
+ * for the task-plan nudge — not a second source of truth for "am I
23
+ * autonomous". The default this resolves to is autonomous-only: with no
24
+ * override, `config.replayReasoning` is `undefined` and `auto` alone decides.
25
+ * `config.replayReasoning` set to `true` or `false` overrides that default in
26
+ * either direction — replay on in the REPL, or off in `--auto` — which is why
27
+ * it is read as three states in src/config.js rather than coerced to a
28
+ * boolean. See that default's rationale on `forRequest` below.
29
+ *
30
+ * Read per request, because `/think` flips `noThink` mid-session.
31
+ *
32
+ * Gated on `templatePreservesThinking` — the same startup detection the
33
+ * `preserve_thinking` request field already uses — because a template that
34
+ * does not declare the parameter is also not one that reads
35
+ * `message.reasoning_content`: it drops the blocks on the floor and the replay
36
+ * is pure prompt overhead for nothing.
37
+ *
38
+ * Deliberately *not* gated on `config.preserveThinking`. That flag only pins
39
+ * the blocks at or before the boundary; everything after it renders under the
40
+ * template's own `loop.index0 > ns.last_query_index` arm regardless. On the
41
+ * fixture measured under `forRequest`, `preserve_thinking: false` still costs
42
+ * the same +107 tokens for the current tool loop, and costs nothing at all for
43
+ * the history the template throws away. The pin and the replay answer
44
+ * different questions; turning one off must not silently turn the other off.
45
+ */
46
+ export const shouldReplayReasoning = (auto = false) =>
47
+ (config.replayReasoning ?? auto) && config.templatePreservesThinking && !config.noThink;
48
+
49
+ /**
50
+ * The message list as it goes on the wire: reasoning is kept for the assistant
51
+ * messages that belong to the current tool loop and stripped from every one at
52
+ * or before the last real user message.
53
+ *
54
+ * ---- why this boundary and not another ----
55
+ *
56
+ * Within one task the model reasons about tool result N before it chooses tool
57
+ * N+1. Dropping that, which is what this agent did before, makes it re-derive
58
+ * its plan from the tool output alone at every step; that is the loss that
59
+ * actually degrades agentic behaviour.
60
+ *
61
+ * Current-turn reasoning is append-only. Those tokens are new on every step
62
+ * anyway, so they were never part of the cached prefix and replaying them
63
+ * costs nothing in cache terms.
64
+ *
65
+ * Historical reasoning is prefix-resident. It inflates the cached prefix
66
+ * permanently and eats window. This agent compacts at 85% of the window, every
67
+ * compaction invalidates the cached prefix and forces a full prefill, so
68
+ * replaying all of history would partly undo the prefix stability that sending
69
+ * `preserve_thinking` bought.
70
+ *
71
+ * It also matches how the Messages API treats thinking alongside tool use:
72
+ * thinking is passed back with the tool result inside the turn, while earlier
73
+ * turns' blocks are stripped.
74
+ *
75
+ * Measured against Kronk 1.31.9 on the /AGENT profile, on a fixture of two
76
+ * user turns and a two-call tool loop, `preserve_thinking: true`: dropping all
77
+ * reasoning 207 prompt tokens, this policy 314 (+107), replaying all of
78
+ * history 368 (+161). The +54 that separates the last two is the part that
79
+ * would sit in the prefix for the rest of the session and grow with it.
80
+ *
81
+ * ---- why autonomous-only, not on everywhere ----
82
+ *
83
+ * The cost above lands at the *next* prompt, not this one: stripping the
84
+ * previous task's blocks at the boundary rewrites the prefix a newer user
85
+ * message sits after, which throws away the cached prefix and forces a full
86
+ * re-prefill. Measured on a live server, first turn after a second prompt,
87
+ * prompt tokens cached vs. re-prefilled:
88
+ *
89
+ * never replay cached 906, re-prefill 60
90
+ * replay current task cached 757, re-prefill 516 <- cache reset to the system prompt
91
+ * replay everything cached 1827, re-prefill 367
92
+ *
93
+ * `--auto` runs exactly one user prompt through the whole tool loop, so that
94
+ * boundary is never crossed in a run: this policy's within-task benefit is
95
+ * free there, and the reset above never happens. The REPL is a user typing
96
+ * repeatedly, so every prompt after the first pays it — measured 4.4-7.4 s to
97
+ * first token against 0.7-1.2 s once cached. Hence the default: on for
98
+ * `--auto`, off for the REPL. `config.replayReasoning` overrides it either way
99
+ * — see `shouldReplayReasoning` above.
100
+ *
101
+ * The array is rebuilt but the untouched messages are shared, not copied: the
102
+ * caller's history keeps its reasoning for the steps that come next.
103
+ */
104
+ export function forRequest(messages, auto = false) {
105
+ const boundary = lastUserIndex(messages);
106
+ const replay = shouldReplayReasoning(auto);
107
+ return messages.map((m, i) => {
108
+ if (m.reasoning_content === undefined) return m;
109
+ // An empty string is stripped as well as dropped history: the template
110
+ // renders an empty <think> block for a message that carries no reasoning
111
+ // either way, so sending the key buys nothing and only invites a server
112
+ // that validates it more strictly than this one to reject the request.
113
+ if (replay && i > boundary && m.reasoning_content) return m;
114
+ const stripped = { ...m };
115
+ delete stripped.reasoning_content;
116
+ return stripped;
117
+ });
118
+ }