kronk-cli 0.1.2 → 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/config.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
4
 
5
5
  const RC = join(homedir(), '.kronk-cli.json');
6
+ const MODEL_CONFIG = join(homedir(), '.kronk', 'models', 'model_config.yaml');
6
7
 
7
8
  function fileConfig() {
8
9
  try { return JSON.parse(readFileSync(RC, 'utf8')); } catch { return {}; }
@@ -13,6 +14,13 @@ const file = fileConfig();
13
14
  /** Used when nothing is passed on the command line or in the environment. */
14
15
  export const DEFAULT_MODEL = 'unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT';
15
16
 
17
+ // Three states, not a boolean: unset means "no opinion, let src/reasoning.js
18
+ // pick the autonomous-only default"; set means "override it, in either
19
+ // direction". Collapsing unset into `true` or `false` here would make that
20
+ // distinction impossible to recover downstream.
21
+ const fileReplayReasoning = file.replayReasoning === undefined ? undefined : String(file.replayReasoning);
22
+ const replayReasoningRaw = process.env.KRONK_REPLAY_REASONING ?? fileReplayReasoning;
23
+
16
24
  export const config = {
17
25
  baseUrl: process.env.KRONK_URL ?? file.baseUrl ?? 'http://localhost:11435/v1',
18
26
  token: process.env.KRONK_TOKEN ?? file.token ?? 'kronk',
@@ -23,18 +31,48 @@ export const config = {
23
31
  maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
24
32
  showThinking: (process.env.KRONK_THINKING ?? String(file.showThinking ?? 'true')) !== 'false',
25
33
  noThink: (process.env.KRONK_NO_THINK ?? String(file.noThink ?? '')) === '1',
34
+ // The chat template drops earlier <think> blocks once a newer user message
35
+ // arrives, which rewrites the prefix and throws the whole session cache away.
36
+ // Pinning them costs the tokens the blocks occupy and saves the re-prefill.
37
+ preserveThinking:
38
+ (process.env.KRONK_PRESERVE_THINKING ?? String(file.preserveThinking ?? 'true')) !== 'false',
39
+ // Send the current task's own reasoning back with each tool-loop step, so
40
+ // the model does not re-derive its plan from tool output alone every time.
41
+ // Earlier turns are dropped at the boundary the template already uses —
42
+ // src/reasoning.js has the full argument, including why the *default* for
43
+ // this is computed there from `auto`, not here as a plain boolean.
44
+ // `undefined` here means "no override": src/reasoning.js decides. `true`
45
+ // or `false` here forces the answer regardless of autonomous vs. REPL.
46
+ replayReasoning: replayReasoningRaw === undefined ? undefined : replayReasoningRaw !== 'false',
47
+ // Kronk admits a model on its first inference request, not at server start.
48
+ // Pay that cold load at boot rather than on the first typed prompt.
49
+ warm: (process.env.KRONK_WARM ?? String(file.warm ?? 'true')) !== 'false',
26
50
  autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
27
51
  compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
28
52
  // Large tool output is summarized in a throwaway context so the raw text
29
53
  // never enters the conversation. Set KRONK_DISTILL=false to keep it whole.
30
54
  distill: (process.env.KRONK_DISTILL ?? String(file.distill ?? 'true')) !== 'false',
31
55
  distillAt: Number(process.env.KRONK_DISTILL_AT ?? file.distillAt ?? 8000),
56
+ // Kronk's per-model runtime settings. `setup` is the only thing that writes
57
+ // it; the override exists so tests never go near the real one.
58
+ modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
32
59
  lastUsed: 0,
33
60
  contextWindow: null, // filled in at boot from Kronk
34
61
  nativeContext: null,
62
+ templatePreservesThinking: false, // filled in at boot from the model's template
63
+ samplingOverride: null, // filled in at boot: params where the profile overrides the model's own
35
64
  rcPath: RC,
36
65
  };
37
66
 
67
+ /**
68
+ * Whether this request should pin the earlier think blocks: the user wants it,
69
+ * the model's template declares the parameter, and reasoning is on at all —
70
+ * with `--no-think` there is nothing to preserve. Read per request, because
71
+ * `/think` flips `noThink` in the middle of a session.
72
+ */
73
+ export const shouldPreserveThinking = () =>
74
+ config.preserveThinking && config.templatePreservesThinking && !config.noThink;
75
+
38
76
  /**
39
77
  * File contents and command output leave this process in request bodies, which
40
78
  * is the whole job. Over loopback that is fine. Pointed at a remote host over
package/src/index.js CHANGED
@@ -4,33 +4,26 @@ import { stdin, stdout } from 'node:process';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
6
6
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
7
+ import { pickDefault, ensureLoaded } from './boot.js';
7
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
8
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
9
10
  import { projectContext } from './context.js';
11
+ import { forRequest } from './reasoning.js';
10
12
  import { compact, report } from './compact.js';
11
13
  import { loadServers, McpHub, reportFailures } from './mcp.js';
12
14
  import { resolveSandbox, sandbox } from './tools.js';
15
+ import { parseArgv } from './argv.js';
16
+ import { runSetup } from './setup.js';
13
17
 
14
18
  // ---- argv -------------------------------------------------------------
15
- const argv = process.argv.slice(2);
16
- const flag = (...names) => {
17
- const i = argv.findIndex((a) => names.includes(a));
18
- if (i === -1) return false;
19
- argv.splice(i, 1);
20
- return true;
21
- };
22
- let AUTO_YES = flag('-y', '--yes');
23
- if (flag('--no-think')) config.noThink = true;
24
- // --auto: run the whole task unattended (implies --yes)
25
- const AUTO = flag('-a', '--auto');
26
- if (AUTO) AUTO_YES = true;
27
-
28
- const SHOW_MODELS = flag('--models', '-l', '--list');
29
- const SHOW_MCP = flag('--mcp-list');
30
- const NO_CONTEXT = flag('--no-context');
31
- if (flag('--no-compact')) config.autoCompact = 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
+ }
32
25
 
33
- if (flag('-h', '--help')) {
26
+ if (args.help) {
34
27
  console.log(`
35
28
  kronk-cli — a terminal agent for local models served by Kronk
36
29
 
@@ -39,10 +32,16 @@ if (flag('-h', '--help')) {
39
32
  kronk-cli "<prompt>" run one prompt and exit
40
33
  <cmd> | kronk-cli "<prompt>" pipe stdin in as extra context
41
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
+
42
40
  OPTIONS
43
41
  -l, --models list the models Kronk is serving, then exit
44
42
  --no-context skip the startup scan of the working directory
45
43
  --no-compact never auto-compact; fail instead when the window fills
44
+ --no-warm don't preload the model; let the first prompt trigger it
46
45
  --mcp [names] attach MCP servers; bare for all, or a comma list
47
46
  --mcp-list show configured MCP servers and their tools, then exit
48
47
  -m, --model <id> model to use; substring is enough, /AGENT profiles win
@@ -52,14 +51,20 @@ if (flag('-h', '--help')) {
52
51
  --no-think disable the model's reasoning pass (faster)
53
52
  --steps <n> cap tool calls per task (default: unlimited)
54
53
  -h, --help this message
54
+ -- end option parsing; everything after is the prompt
55
55
 
56
56
  ENVIRONMENT
57
57
  KRONK_URL default http://localhost:11435/v1
58
58
  KRONK_TOKEN any non-empty value when Kronk runs open
59
59
  KRONK_MODEL overrides the default model
60
+ KRONK_MODEL_CONFIG path to Kronk's model_config.yaml, used by setup
60
61
  KRONK_MAX_TOKENS output cap per response (default 8192)
61
62
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
62
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)
67
+ KRONK_WARM false to skip the boot-time model preload
63
68
  KRONK_AUTO_COMPACT false to disable automatic compaction
64
69
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
65
70
 
@@ -67,42 +72,22 @@ if (flag('-h', '--help')) {
67
72
  `);
68
73
  process.exit(0);
69
74
  }
70
- const opt = (name) => {
71
- const i = argv.findIndex((a) => a === name);
72
- if (i === -1) return null;
73
- const v = argv[i + 1];
74
- argv.splice(i, 2);
75
- return v;
76
- };
77
- const modelArg = opt('--model') ?? opt('-m');
78
- if (modelArg) config.model = modelArg;
79
- // `--mcp` alone attaches everything configured; `--mcp nx,kronk` narrows it.
80
- let MCP_ON = false;
81
- let MCP_WANTED = null;
82
- {
83
- const i = argv.findIndex((a) => a === '--mcp');
84
- if (i !== -1) {
85
- MCP_ON = true;
86
- const next = argv[i + 1];
87
- if (next && !next.startsWith('-')) {
88
- MCP_WANTED = next.split(',').map((x) => x.trim()).filter(Boolean);
89
- argv.splice(i, 2);
90
- } else {
91
- argv.splice(i, 1);
92
- }
93
- }
94
- }
95
75
 
96
- const stepsArg = opt('--steps');
97
- if (stepsArg) config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(stepsArg) ? Infinity : Number(stepsArg);
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;
82
+ // `--mcp` alone attaches everything configured; `--mcp nx,kronk` narrows it.
83
+ const MCP_ON = args.mcp;
84
+ const MCP_WANTED = args.mcpNames;
98
85
 
99
- /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
100
- function pickDefault(ids) {
101
- const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
102
- const agent = chat.filter((id) => id.endsWith('/AGENT'));
103
- const pool = agent.length ? agent : chat;
104
- return pool.sort((a, b) => b.length - a.length)[0] ?? null;
105
- }
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;
106
91
 
107
92
  async function boot() {
108
93
  let ids;
@@ -139,9 +124,15 @@ async function boot() {
139
124
  }
140
125
  if (!config.model) config.model = pickDefault(ids);
141
126
 
142
- const { configured, native } = await modelLimits(config.model);
127
+ if (config.warm) await ensureLoaded(ids);
128
+
129
+ const {
130
+ configured, native, preserveThinking, samplingDiff,
131
+ } = await modelLimits(config.model);
143
132
  config.contextWindow = configured;
144
133
  config.nativeContext = native;
134
+ config.templatePreservesThinking = preserveThinking;
135
+ config.samplingOverride = samplingDiff;
145
136
  return ids;
146
137
  }
147
138
 
@@ -322,7 +313,7 @@ async function oneShot(prompt) {
322
313
  return false;
323
314
  };
324
315
  try {
325
- 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 });
326
317
  } catch (e) {
327
318
  if (e.name !== 'AbortError') { console.error(c.red(` ${e.message}`)); process.exitCode = 1; }
328
319
  } finally {
@@ -334,21 +325,58 @@ async function main() {
334
325
  // Before anything reaches the network, on every path through the program.
335
326
  warnIfInsecure();
336
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
+
337
345
  if (SHOW_MODELS) { await showModels(); return; }
338
346
  if (SHOW_MCP) { await showMcp(); process.exit(0); }
339
347
 
340
348
  // non-interactive: `kronk-cli "prompt"` or `echo prompt | kronk-cli`
341
- const inline = argv.join(' ').trim();
349
+ const inline = args.words.join(' ').trim();
342
350
  // With an inline prompt, stdin is optional extra context — don't block on it.
343
351
  // Without one, stdin IS the prompt, so wait longer before giving up.
344
352
  const piped = await readStdin(inline ? 200 : 10_000);
345
353
  const oneShotPrompt = inline && piped ? `${inline}\n\n${piped}` : (inline || piped);
346
- if (oneShotPrompt) { await oneShot(oneShotPrompt); return; }
354
+ if (oneShotPrompt) {
355
+ // stdin has given us everything it is going to. When it is a pipe the
356
+ // caller never closes — a script, an editor task, a CI step — the read
357
+ // above stays pending and its handle would keep the process alive long
358
+ // after the answer was printed. Let go of it before answering.
359
+ stdin.pause();
360
+ stdin.unref?.();
361
+ await oneShot(oneShotPrompt);
362
+ return;
363
+ }
347
364
 
348
365
  const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
349
366
  await boot();
350
367
  const { content, ctx } = await systemMessage(AUTO);
351
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
+
352
380
  const mcp = await startMcp();
353
381
 
354
382
  if (ctx) {
@@ -368,6 +396,9 @@ async function main() {
368
396
  : c.grey(`paths + ${backend}`)}\n`);
369
397
 
370
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);
371
402
 
372
403
  // Ctrl-C aborts the in-flight request instead of killing the process.
373
404
  let ac = null;
@@ -386,9 +417,11 @@ async function main() {
386
417
  for (;;) {
387
418
  const status = statusLine({
388
419
  model: config.model,
389
- auto: autoApprove && messages[0].content.startsWith(SYSTEM_AUTO),
420
+ auto: autoApprove && isAuto(),
390
421
  yes: autoApprove,
391
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,
392
425
  mcp: mcp?.routes.size ? [...mcp.servers.keys()].join(',') : null,
393
426
  steps: config.maxSteps,
394
427
  used: config.lastUsed,
@@ -418,9 +451,8 @@ async function main() {
418
451
  continue;
419
452
  }
420
453
  if (input === '/auto') {
421
- const now = !messages[0].content.startsWith(SYSTEM_AUTO);
422
- const primer = messages[0].content.slice(
423
- (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);
424
456
  messages[0] = { role: 'system', content: (now ? SYSTEM_AUTO : SYSTEM) + primer };
425
457
  autoApprove = now;
426
458
  console.log(c.grey(` autonomous mode ${now ? 'on — tools auto-approved, runs to completion' : 'off'}`));
@@ -448,14 +480,18 @@ async function main() {
448
480
  if (input === '/compact') {
449
481
  if (messages.length < 2) { console.log(c.grey(' nothing to compact')); continue; }
450
482
  const sp = new AbortController();
451
- 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() });
452
484
  if (res.failed) { console.log(c.red(' compaction produced nothing — conversation unchanged')); continue; }
453
485
  if (!res.skipped) messages.splice(0, messages.length, ...res.messages);
454
486
  console.log(report(res));
455
487
  continue;
456
488
  }
457
489
  if (input === '/context') {
458
- 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'));
459
495
  console.log(` ${fmtContext(used, config.contextWindow) || c.grey('unknown')}`);
460
496
  console.log(c.grey(` window: ${config.contextWindow?.toLocaleString() ?? '?'} tokens`
461
497
  + (config.nativeContext ? ` · model supports up to ${config.nativeContext.toLocaleString()}` : '')));
@@ -488,7 +524,7 @@ async function main() {
488
524
  messages.push({ role: 'user', content: input });
489
525
  ac = new AbortController();
490
526
  try {
491
- 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() });
492
528
  } catch (e) {
493
529
  if (e.name === 'AbortError') messages.push({ role: 'assistant', content: '(interrupted)' });
494
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
+ }