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/agent.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { streamChat } from './client.js';
2
2
  import { TOOLS, NEEDS_APPROVAL, runTool, describe, preview, mcpNeedsApproval } from './tools.js';
3
- import { config } from './config.js';
4
- import { c, fmtUsage, spinner, liveLine } from './ui.js';
3
+ import { config, shouldPreserveThinking } from './config.js';
4
+ import { forRequest } from './reasoning.js';
5
+ import { c, fmtUsage, spinner, liveLine, toolResultLines } from './ui.js';
5
6
  import { compact, isOverflow, report } from './compact.js';
6
7
  import { maybeDistill } from './distill.js';
8
+ import {
9
+ carryChecklist, clearPlan, openItems, outstandingLines, planLines, pushNudge,
10
+ } from './plan.js';
7
11
 
8
12
  export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
9
13
 
@@ -11,35 +15,84 @@ Rules:
11
15
  - Inspect before you answer. Use read_file / list_dir / search rather than guessing at code.
12
16
  - Prefer one decisive action over narrating options.
13
17
  - Keep prose short. Code blocks should be complete and runnable.
14
- - The working directory is the user's project root. Paths are relative to it.`;
18
+ - The working directory is the user's project root. Paths are relative to it. You are already inside it. Do not \`cd\` above it. If a command reports that this is not a git repository, you have left the project — return to it rather than searching elsewhere.`;
15
19
 
16
20
  export const SYSTEM_AUTO = `${SYSTEM}
17
21
 
18
22
  You are running autonomously on a whole task. Finish it before you stop.
23
+ - Before your first edit, call set_plan with one item per acceptance criterion, requirement or checkbox in the request. Copy the wording of the request; do not paraphrase it into something easier.
24
+ - If the request calls a step required, mandatory, or a first step, it is an item, and it is done before the work it gates.
25
+ - Update the plan with set_plan after each item. Record progress with the tool, not in prose.
19
26
  - Do not ask the user questions. Make a reasonable choice and proceed.
20
27
  - After you write code, RUN it with bash and fix whatever breaks.
21
28
  - Never claim something works unless you have executed it and seen the output.
22
29
  - Work in small steps: one file or one command per tool call.
23
- - When the task is genuinely done, reply with a short summary of what you changed.`;
30
+ - Do not reply with a summary while any item is not done. If an item cannot be completed, say in your reply why it was not possible, and only then mark it done — never silently.
31
+ - Before the final reply, re-read the original request and check every item against it.
32
+ - When every item is genuinely done, reply with a short summary of what you changed.`;
24
33
 
25
34
  /**
26
- * Run one user turn to completion, looping while the model requests tools.
27
- * `approve(name, args)` returns a boolean; used for mutating tools.
35
+ * The reasoning half of an assistant message, or nothing at all.
36
+ *
37
+ * A model that emitted no reasoning — `--no-think`, or a non-reasoning model —
38
+ * must produce exactly the message this agent has always produced, so the key
39
+ * is absent rather than empty.
28
40
  */
29
- /** Compact in place, preserving the caller's array identity. */
30
- async function compactInto(messages, model, signal) {
31
- const res = await compact(messages, { model, signal });
41
+ const thought = (reasoning) => (reasoning ? { reasoning_content: reasoning } : {});
42
+
43
+ /**
44
+ * Compact in place, preserving the caller's array identity.
45
+ *
46
+ * `auto` is passed through so the summarizer sees the same reasoning-replay
47
+ * view as the wire request that just overflowed — see `compact` in
48
+ * src/compact.js.
49
+ */
50
+ async function compactInto(messages, model, signal, auto) {
51
+ const res = await compact(messages, { model, signal, auto });
32
52
  if (res.failed || res.skipped) { console.log(report(res)); return false; }
33
53
  messages.splice(0, messages.length, ...res.messages);
34
54
  console.log(report(res));
35
55
  return true;
36
56
  }
37
57
 
38
- export async function runTurn({ messages, model, signal, approve, mcp, maxSteps = config.maxSteps }) {
58
+ /** How many times a premature "done" is handed back before the turn ends anyway. */
59
+ const MAX_NUDGES = 2;
60
+
61
+ /**
62
+ * Say what was not finished, and hand the transcript back fit to be continued.
63
+ *
64
+ * Nothing is taken out of it on the way. A turn always ends on an assistant
65
+ * message — the reply, or the step-cap note — so a nudge is never left sitting
66
+ * directly before the next typed prompt.
67
+ */
68
+ function endTurn(messages) {
69
+ outstandingLines().forEach((l) => console.log(l));
70
+ return messages;
71
+ }
72
+
73
+ /**
74
+ * Run one user turn to completion, looping while the model requests tools.
75
+ *
76
+ * `approve(name, args)` returns a boolean; used for mutating tools. `auto` is
77
+ * autonomous mode — the system prompt in force, not `--yes` — and decides
78
+ * both whether a premature "done" is handed back or accepted, and (via
79
+ * src/reasoning.js) whether the current task's reasoning defaults to being
80
+ * replayed on the wire.
81
+ */
82
+ export async function runTurn({
83
+ messages, model, signal, approve, mcp, auto = false, maxSteps = config.maxSteps,
84
+ }) {
39
85
  const tools = mcp ? [...TOOLS, ...mcp.toolDefs()] : TOOLS;
40
86
  let totalUsage = null;
41
87
  let step = 0;
42
88
  let compacted = false;
89
+ let nudges = 0;
90
+
91
+ // A plan belongs to one task, not to a session. Only the store is cleared:
92
+ // a checklist the previous turn left in the transcript is history, it is
93
+ // already in the prompt prefix, and rewriting history is what evicts the
94
+ // prompt cache — see `carryChecklist`.
95
+ clearPlan();
43
96
 
44
97
  for (;;) {
45
98
  step += 1;
@@ -47,19 +100,33 @@ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps
47
100
  console.log(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
48
101
  console.log(c.grey(' raise it with --steps N, or /steps N in the REPL'));
49
102
  messages.push({ role: 'assistant', content: '(stopped: step cap reached)' });
50
- return messages;
103
+ return endTurn(messages);
51
104
  }
52
105
  let sp = spinner('thinking');
53
106
  let text = '';
107
+ let reasoning = '';
54
108
  let calls = [];
55
109
  let wroteAnything = false;
56
110
  let inReasoning = false;
57
111
 
58
112
  try {
59
113
  for await (const ev of streamChat({
60
- model, messages, tools, signal, maxTokens: config.maxTokens, noThink: config.noThink,
114
+ model,
115
+ // The history keeps every step's reasoning; only the current task's
116
+ // share of it goes on the wire. See src/reasoning.js.
117
+ messages: forRequest(messages, auto),
118
+ tools,
119
+ signal,
120
+ maxTokens: config.maxTokens,
121
+ noThink: config.noThink,
122
+ // Re-read every step: this has to hold for the tool-loop follow-ups too,
123
+ // and `/think` can flip the answer between one turn and the next.
124
+ preserveThinking: shouldPreserveThinking(),
61
125
  })) {
62
126
  if (ev.type === 'reasoning') {
127
+ // Accumulated before the display check: whether the user watches the
128
+ // model think has nothing to do with whether the model gets it back.
129
+ reasoning += ev.value;
63
130
  if (!config.showThinking) continue;
64
131
  if (sp) { sp.stop(); sp = null; }
65
132
  if (!inReasoning) { process.stdout.write(c.grey('\n ┄ thinking ┄\n ')); inReasoning = true; }
@@ -88,7 +155,15 @@ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps
88
155
  if (isOverflow(e) && !compacted) {
89
156
  compacted = true;
90
157
  console.log(c.yellow('\n context full — compacting and retrying'));
91
- if (await compactInto(messages, model, signal)) { step -= 1; continue; }
158
+ // The plan itself is module state, so compaction cannot reach it. The
159
+ // snapshot it just summarised away is not put back: there is no tool
160
+ // result left to carry one, and inserting a message here would be the
161
+ // rewrite `carryChecklist` exists to avoid. The next round's tool
162
+ // results carry the plan again.
163
+ if (await compactInto(messages, model, signal, auto)) {
164
+ step -= 1;
165
+ continue;
166
+ }
92
167
  }
93
168
  throw e;
94
169
  } finally {
@@ -106,7 +181,7 @@ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps
106
181
  const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
107
182
  if (used / config.contextWindow >= config.compactAt) {
108
183
  console.log(c.yellow(` context ${Math.round((used / config.contextWindow) * 100)}% full — compacting`));
109
- if (!await compactInto(messages, model, signal)) config.autoCompact = false;
184
+ if (!await compactInto(messages, model, signal, auto)) config.autoCompact = false;
110
185
  }
111
186
  }
112
187
 
@@ -116,16 +191,28 @@ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps
116
191
  // Reasoning models sometimes spend the whole budget thinking and emit no
117
192
  // answer. Say so rather than returning silence.
118
193
  console.log(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
119
- messages.push({ role: 'assistant', content: '(no answer produced)' });
194
+ messages.push({ role: 'assistant', content: '(no answer produced)', ...thought(reasoning) });
120
195
  } else {
121
- messages.push({ role: 'assistant', content: text });
196
+ messages.push({ role: 'assistant', content: text, ...thought(reasoning) });
122
197
  }
123
- return messages;
198
+
199
+ // A plan with open items means it stopped early. Hand the list back and
200
+ // keep going — but only when nobody is watching, and only twice: past
201
+ // that the model is not going to finish, and looping is worse than
202
+ // reporting what is left.
203
+ if (auto && openItems().length && nudges < MAX_NUDGES) {
204
+ nudges += 1;
205
+ pushNudge(messages);
206
+ console.log(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
207
+ continue;
208
+ }
209
+ return endTurn(messages);
124
210
  }
125
211
 
126
212
  messages.push({
127
213
  role: 'assistant',
128
214
  content: text,
215
+ ...thought(reasoning),
129
216
  tool_calls: calls.map((t) => ({
130
217
  id: t.id, type: 'function',
131
218
  function: { name: t.name, arguments: t.args || '{}' },
@@ -176,15 +263,23 @@ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps
176
263
  live.done();
177
264
  }
178
265
 
179
- result = await maybeDistill(result, {
180
- model, signal, command: args.cmd ?? describe(call.name, args),
181
- });
266
+ // set_plan hands back a rendering of what the harness just stored; paying a
267
+ // second model call to paraphrase our own text would be pure waste.
268
+ if (call.name !== 'set_plan') {
269
+ result = await maybeDistill(result, {
270
+ model, signal, command: args.cmd ?? describe(call.name, args),
271
+ });
272
+ }
182
273
  const failed = result.startsWith('error:');
183
- console.log(failed
184
- ? c.red(` ✗ ${result.split('\n')[0]}`)
185
- : c.grey(` ✓ ${result.split('\n').length} lines`));
274
+ if (failed) toolResultLines(result).forEach((l) => console.log(l));
275
+ else if (call.name === 'set_plan') planLines().forEach((l) => console.log(l));
276
+ else console.log(c.grey(` ✓ ${result.split('\n').length} lines`));
186
277
  messages.push({ role: 'tool', tool_call_id: call.id, content: result });
187
278
  }
279
+
280
+ // On the last tool result rather than in a message of its own, so the
281
+ // round only ever adds to the prompt. Nothing already sent is touched.
282
+ carryChecklist(messages);
188
283
  // loop: the model now sees the tool output
189
284
  }
190
285
  }
package/src/argv.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Command-line parsing, kept out of index.js so it can be imported and tested
3
+ * without running the program.
4
+ *
5
+ * One left-to-right pass, each token consumed exactly once. The multi-pass
6
+ * version this replaces spliced tokens out of argv in source order, so
7
+ * `--model --no-think x` had already lost `--no-think` by the time the model
8
+ * option looked at its neighbour and happily took the prompt as a model id.
9
+ */
10
+
11
+ /**
12
+ * The one place that knows how an option is spelled. Both the parser and the
13
+ * "did you mean" suggestion read from it, so a new option cannot be understood
14
+ * by one and unknown to the other.
15
+ *
16
+ * kind: 'flag' takes nothing, 'value' requires the next token, 'optional' takes
17
+ * the next token only when it does not look like an option.
18
+ */
19
+ const SPECS = [
20
+ { names: ['-h', '--help'], kind: 'flag', key: 'help' },
21
+ { names: ['-l', '--models', '--list'], kind: 'flag', key: 'models' },
22
+ { names: ['--mcp-list'], kind: 'flag', key: 'mcpList' },
23
+ { names: ['--no-context'], kind: 'flag', key: 'noContext' },
24
+ { names: ['--no-compact'], kind: 'flag', key: 'noCompact' },
25
+ { names: ['--no-warm'], kind: 'flag', key: 'noWarm' },
26
+ { names: ['--no-think'], kind: 'flag', key: 'noThink' },
27
+ { names: ['-a', '--auto'], kind: 'flag', key: 'auto' },
28
+ { names: ['-y', '--yes'], kind: 'flag', key: 'yes' },
29
+ { names: ['--dry-run'], kind: 'flag', key: 'dryRun' },
30
+ { names: ['-m', '--model'], kind: 'value', key: 'model' },
31
+ { names: ['--steps'], kind: 'value', key: 'steps' },
32
+ { names: ['--context'], kind: 'value', key: 'context' },
33
+ { names: ['--mcp'], kind: 'optional', key: 'mcp' },
34
+ ];
35
+
36
+ const BY_NAME = new Map(SPECS.flatMap((s) => s.names.map((n) => [n, s])));
37
+
38
+ /** Every recognised spelling, sorted so suggestions break ties predictably. */
39
+ export const KNOWN = [...BY_NAME.keys()].sort();
40
+
41
+ /**
42
+ * A leading dash with no whitespace after it. The no-whitespace part is what
43
+ * keeps prose working: `kronk-cli "- fix the dashes bug"` is one argv token and
44
+ * whoever typed it has no idea an escape hatch exists. A bare `-` is left alone
45
+ * too — it is the conventional stdin placeholder.
46
+ */
47
+ const OPTIONISH = /^-\S+$/;
48
+
49
+ const STEPS_WORDS = /^(0|off|none|inf|unlimited)$/i;
50
+
51
+ /** Plain Levenshtein distance. Small inputs, no dependency, no cleverness. */
52
+ function distance(a, b) {
53
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
54
+ for (let i = 1; i <= a.length; i++) {
55
+ const row = [i];
56
+ for (let j = 1; j <= b.length; j++) {
57
+ const sub = prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
58
+ row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, sub);
59
+ }
60
+ prev = row;
61
+ }
62
+ return prev[b.length];
63
+ }
64
+
65
+ /**
66
+ * The flag an unknown token was probably meant to be, or null.
67
+ *
68
+ * A wrong suggestion is worse than none, so only two rules apply: one more dash
69
+ * turns it into a known flag (`-auto` → `--auto`, the whole observed failure
70
+ * mode), or it is within two edits of a known flag — nearest first, ties broken
71
+ * alphabetically so the same typo always gets the same answer.
72
+ */
73
+ export function suggest(token) {
74
+ if (BY_NAME.has(`-${token}`)) return `-${token}`;
75
+ let best = null;
76
+ let bestAt = 3;
77
+ for (const name of KNOWN) { // sorted, so the first of a tie wins
78
+ const d = distance(token, name);
79
+ if (d < bestAt) { best = name; bestAt = d; }
80
+ }
81
+ return best;
82
+ }
83
+
84
+ const unknown = (token) => {
85
+ const hint = suggest(token);
86
+ return [
87
+ `unknown option: ${token}`,
88
+ ...(hint ? [`did you mean ${hint}?`] : []),
89
+ 'kronk-cli --help for the full list',
90
+ ].map((l) => ` ${l}`).join('\n');
91
+ };
92
+
93
+ /**
94
+ * Parse argv into a plain result. Never prints, never exits: the caller decides
95
+ * what a usage error looks like, which is what makes this testable.
96
+ *
97
+ * On a usage error `error` is the message to print, and nothing else in the
98
+ * result should be acted on.
99
+ */
100
+ export function parseArgv(argv) {
101
+ const out = {
102
+ error: null,
103
+ help: false, models: false, mcpList: false,
104
+ noContext: false, noCompact: false, noWarm: false, noThink: false,
105
+ auto: false, yes: false, dryRun: false,
106
+ model: null,
107
+ context: null,
108
+ steps: null,
109
+ mcp: false, mcpNames: null,
110
+ words: [],
111
+ };
112
+ const fail = (message) => ({ ...out, error: message });
113
+
114
+ for (let i = 0; i < argv.length; i++) {
115
+ const token = argv[i];
116
+
117
+ // Consumed here, before the unknown-option check below can ever see it.
118
+ if (token === '--') { out.words.push(...argv.slice(i + 1)); break; }
119
+
120
+ const spec = BY_NAME.get(token);
121
+ if (!spec) {
122
+ if (OPTIONISH.test(token)) return fail(unknown(token));
123
+ out.words.push(token); // prose, or a bare word for a subcommand
124
+ continue;
125
+ }
126
+
127
+ if (spec.kind === 'flag') { out[spec.key] = true; continue; }
128
+
129
+ const next = argv[i + 1];
130
+ if (spec.kind === 'optional') {
131
+ out.mcp = true;
132
+ if (next !== undefined && !next.startsWith('-')) {
133
+ out.mcpNames = next.split(',').map((x) => x.trim()).filter(Boolean);
134
+ i++;
135
+ }
136
+ continue;
137
+ }
138
+
139
+ // 'value': the option must get one. `--steps -1` lands here rather than in
140
+ // the value check below — one uniform rule, one predictable message.
141
+ if (next === undefined || OPTIONISH.test(next)) {
142
+ return fail(` ${token} needs a value`);
143
+ }
144
+ i++;
145
+
146
+ if (spec.key === 'steps') {
147
+ // An unparseable cap used to become NaN, which read as "unlimited" — the
148
+ // most permissive answer possible, in a tool that runs shell commands.
149
+ if (STEPS_WORDS.test(next)) out.steps = Infinity;
150
+ else if (/^\d+$/.test(next)) out.steps = Number(next);
151
+ else {
152
+ return fail(' --steps takes a non-negative integer, or one of: 0, off, none, inf, unlimited');
153
+ }
154
+ continue;
155
+ }
156
+ out[spec.key] = next;
157
+ }
158
+
159
+ return out;
160
+ }
package/src/boot.js ADDED
@@ -0,0 +1,59 @@
1
+ import { config, DEFAULT_MODEL } from './config.js';
2
+ import { listLoaded, warm } from './client.js';
3
+ import { c, spinner } from './ui.js';
4
+
5
+ /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
6
+ export function pickDefault(ids) {
7
+ const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
8
+ const agent = chat.filter((id) => id.endsWith('/AGENT'));
9
+ const pool = agent.length ? agent : chat;
10
+ return pool.sort((a, b) => b.length - a.length)[0] ?? null;
11
+ }
12
+
13
+ /**
14
+ * Get the chosen model resident before the first prompt.
15
+ *
16
+ * A freshly started Kronk serves model *ids* but holds nothing in VRAM — it
17
+ * admits a model on its first inference request, and there is no endpoint that
18
+ * does it sooner. Left alone, that 10–25 s cold load lands on the first thing
19
+ * you type, looking like a hang. Do it here, where a spinner explains the wait
20
+ * and where a model that will not fit can still fall back to one that will.
21
+ *
22
+ * Never fatal: if nothing warms, the original pick stands and the first turn
23
+ * reports the real error. A warm-up is a convenience, not a gate.
24
+ *
25
+ * Returns the id left in `config.model`.
26
+ */
27
+ export async function ensureLoaded(ids, log = console.error) {
28
+ const loaded = await listLoaded();
29
+ const resident = new Set((Array.isArray(loaded) ? loaded : []).map((l) => l.id));
30
+ if (resident.has(config.model)) return config.model;
31
+
32
+ const chosen = config.model;
33
+ // chosen → configured default → best guess; each distinct id tried once.
34
+ const chain = [...new Set([
35
+ chosen,
36
+ ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : null,
37
+ pickDefault(ids),
38
+ ])].filter(Boolean);
39
+
40
+ for (const id of chain) {
41
+ if (resident.has(id)) { config.model = id; return id; }
42
+ const t0 = Date.now();
43
+ const spin = spinner(`loading ${id.split('/').pop()} — first run takes 10-30s`);
44
+ try {
45
+ await warm(id);
46
+ spin.stop();
47
+ const how = `${((Date.now() - t0) / 1000).toFixed(1)}s${id === chosen ? '' : ' · fallback'}`;
48
+ log(c.grey(` loaded ${id} · ${how}`));
49
+ config.model = id;
50
+ return id;
51
+ } catch (e) {
52
+ spin.stop();
53
+ log(c.yellow(` ${id} failed to load — ${e.message.split('\n')[0].slice(0, 160)}`));
54
+ }
55
+ }
56
+ // Nothing would load. Keep the original pick and let the first turn say why.
57
+ config.model = chosen;
58
+ return chosen;
59
+ }
package/src/client.js CHANGED
@@ -15,10 +15,81 @@ export async function listModels() {
15
15
  return data.map((m) => m.id);
16
16
  }
17
17
 
18
+ // Which model-metadata key names each sampling parameter, and how the same
19
+ // value is spelled in `model_config['sampling-parameters']` once the profile
20
+ // has been applied.
21
+ const SAMPLING_KEYS = [
22
+ { meta: 'general.sampling.temp', effective: 'temperature', label: 'temperature' },
23
+ { meta: 'general.sampling.top_k', effective: 'top_k', label: 'top_k' },
24
+ { meta: 'general.sampling.top_p', effective: 'top_p', label: 'top_p' },
25
+ ];
26
+
27
+ // Metadata travels GGUF -> YAML -> JSON before it reaches here, and that chain
28
+ // can land a value a few float64 ULPs from where it started — observed on this
29
+ // exact pair: Number('0.95') and 0.9500001 differ by ~1.0e-7. The 1e-9 anchor
30
+ // suggested for this feature is tighter than that observed noise and would
31
+ // misfire on the very case it exists to swallow, so the tolerance here is
32
+ // 1e-6: an order of magnitude above the measured round-trip noise, and still
33
+ // four-plus orders of magnitude below the smallest override a person would
34
+ // plausibly type (e.g. 0.6 vs 1).
35
+ const SAMPLING_TOLERANCE = 1e-6;
36
+
37
+ // `Number(null)` and `Number('')` are both 0, so coercing first and testing
38
+ // Number.isFinite afterwards reads an absent value as a deliberate zero and
39
+ // warns about a parameter nobody set. Kronk does emit both shapes in
40
+ // `model_config` — `reasoning_effort` and `grammar` come back as empty strings
41
+ // on this server today — so reject them before coercing, not after.
42
+ function num(v) {
43
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
44
+ if (typeof v !== 'string' || v.trim() === '') return null;
45
+ const n = Number(v);
46
+ return Number.isFinite(n) ? n : null;
47
+ }
48
+
49
+ /**
50
+ * Compare a model's own sampling metadata (GGUF values, always strings) against
51
+ * the effective sampling-parameters Kronk is actually applying (profile-merged,
52
+ * always numbers). Pure — no I/O — so this is the whole testable surface for
53
+ * the startup warning: the boot path just hands it what `/kronk/models/{id}`
54
+ * already returned.
55
+ *
56
+ * A side that is missing, or does not parse to a finite number, is "no
57
+ * opinion" rather than a difference — a model with no sampling metadata, or a
58
+ * profile field this build doesn't recognise, must never produce a warning.
59
+ *
60
+ * Returns null when there is nothing to report, or the list of parameters
61
+ * that disagree — each with the model's own value and the effective one —
62
+ * for the caller to render as a single line.
63
+ */
64
+ export function samplingOverride(metadata, sampling) {
65
+ if (!metadata || !sampling) return null;
66
+ const diffs = [];
67
+ for (const { meta, effective, label } of SAMPLING_KEYS) {
68
+ const modelValue = num(metadata[meta]);
69
+ const effectiveValue = num(sampling[effective]);
70
+ if (modelValue === null || effectiveValue === null) continue;
71
+ if (Math.abs(modelValue - effectiveValue) > SAMPLING_TOLERANCE) {
72
+ diffs.push({ param: label, model: modelValue, effective: effectiveValue });
73
+ }
74
+ }
75
+ return diffs.length ? diffs : null;
76
+ }
77
+
18
78
  /**
19
- * Effective context window for a model id, plus the model's native maximum.
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.
20
82
  * The id contains slashes, so it must be percent-encoded — Kronk's route takes
21
83
  * one path segment and 404s on a raw id.
84
+ *
85
+ * The template is the only reliable source for `preserveThinking`: Kronk
86
+ * reports `model_config["chat-template-kwargs"]` as null even when the
87
+ * profile sets the flag, so what the server is already doing cannot be read
88
+ * back. The sampling comparison has no such gap — metadata and the effective
89
+ * values are both in this same response — so it needs no second request.
90
+ *
91
+ * Every failure answers "unknown" / "no warning", which the caller reads as
92
+ * "say nothing and proceed".
22
93
  */
23
94
  export async function modelLimits(id) {
24
95
  try {
@@ -26,8 +97,17 @@ export async function modelLimits(id) {
26
97
  const configured = d.model_config?.['context-window'] ?? null;
27
98
  const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
28
99
  const native = nativeKey ? Number(d.metadata[nativeKey]) : null;
29
- return { configured, native };
30
- } catch { return { configured: null, native: 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
+ };
106
+ } catch {
107
+ return {
108
+ configured: null, native: null, preserveThinking: false, samplingDiff: null,
109
+ };
110
+ }
31
111
  }
32
112
 
33
113
  /** Native Kronk model list: size, projector, validation. */
@@ -64,7 +144,9 @@ export async function tokenize(model, input) {
64
144
  * {type:'usage', value}
65
145
  * {type:'done', calls, finish}
66
146
  */
67
- export async function* streamChat({ model, messages, tools, signal, maxTokens, noThink }) {
147
+ export async function* streamChat({
148
+ model, messages, tools, signal, maxTokens, noThink, preserveThinking,
149
+ }) {
68
150
  const res = await req('/chat/completions', {
69
151
  method: 'POST',
70
152
  signal,
@@ -76,6 +158,7 @@ export async function* streamChat({ model, messages, tools, signal, maxTokens, n
76
158
  stream_options: { include_usage: true },
77
159
  max_completion_tokens: maxTokens,
78
160
  ...(noThink ? { enable_thinking: false } : {}),
161
+ ...(preserveThinking ? { chat_template_kwargs: { preserve_thinking: true } } : {}),
79
162
  }),
80
163
  });
81
164
 
@@ -113,3 +196,25 @@ export async function* streamChat({ model, messages, tools, signal, maxTokens, n
113
196
 
114
197
  yield { type: 'done', calls: [...calls.values()], finish };
115
198
  }
199
+
200
+ /**
201
+ * Load a model into the pool.
202
+ *
203
+ * Kronk has no explicit "load" endpoint — admission happens on the first
204
+ * inference request, so the cheapest possible completion *is* the load
205
+ * command. A 23 GB MoE takes ~10–25 s off disk. The reply is discarded;
206
+ * only whether it succeeded matters.
207
+ */
208
+ export async function warm(id, signal) {
209
+ const res = await req('/chat/completions', {
210
+ method: 'POST',
211
+ signal,
212
+ body: JSON.stringify({
213
+ model: id,
214
+ messages: [{ role: 'user', content: 'hi' }],
215
+ max_completion_tokens: 1,
216
+ enable_thinking: false,
217
+ }),
218
+ });
219
+ await res.text();
220
+ }
package/src/compact.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { streamChat, tokenize } from './client.js';
2
2
  import { config } from './config.js';
3
+ import { forRequest } from './reasoning.js';
3
4
  import { c } from './ui.js';
4
5
 
5
6
  const PROMPT = `Summarize the conversation above so it can be continued in a fresh context.
@@ -35,7 +36,15 @@ function fit(text, budgetTokens) {
35
36
  };
36
37
  }
37
38
 
38
- /** Flatten a message list into something the model can read back. */
39
+ /**
40
+ * Flatten a message list into something the model can read back.
41
+ *
42
+ * Reasoning is flattened alongside content rather than skipped — the summary
43
+ * is the only thing that survives, so anything left out here is lost for good.
44
+ * The caller passes the list as it goes on the wire, which means the reasoning
45
+ * a request had already dropped is not resurrected here just to be baked into
46
+ * the summary permanently.
47
+ */
39
48
  function transcript(messages) {
40
49
  return messages
41
50
  .filter((m) => m.role !== 'system')
@@ -44,7 +53,8 @@ function transcript(messages) {
44
53
  const calls = m.tool_calls?.length
45
54
  ? `\n[called ${m.tool_calls.map((t) => t.function.name).join(', ')}]`
46
55
  : '';
47
- return `${m.role}: ${m.content ?? ''}${calls}`;
56
+ const thought = m.reasoning_content ? `[reasoning]\n${m.reasoning_content}\n` : '';
57
+ return `${m.role}: ${thought}${m.content ?? ''}${calls}`;
48
58
  })
49
59
  .join('\n\n');
50
60
  }
@@ -54,11 +64,18 @@ function transcript(messages) {
54
64
  *
55
65
  * Tool messages are dropped rather than carried over: they are only valid when
56
66
  * paired with the assistant tool_calls that produced them, and a partial carry
57
- * leaves orphaned tool_call_ids that the API rejects.
67
+ * leaves orphaned tool_call_ids that the API rejects. Reasoning goes the same
68
+ * way for the same reason — it belongs to a tool loop that no longer exists
69
+ * after this — and the replacement below is built literally, so a compacted
70
+ * history cannot carry a `reasoning_content` from before the summary.
71
+ *
72
+ * `auto` is passed straight to `forRequest`: the summarizer must see the same
73
+ * reasoning-replay view of the current task as the wire request that
74
+ * triggered it, not a second, independently-computed answer.
58
75
  */
59
- export async function compact(messages, { model, signal } = {}) {
76
+ export async function compact(messages, { model, signal, auto = false } = {}) {
60
77
  const system = messages[0];
61
- const raw = transcript(messages);
78
+ const raw = transcript(forRequest(messages, auto));
62
79
  if (!raw.trim()) return { messages, before: 0, after: 0 };
63
80
 
64
81
  const before = await tokenize(model, [system.content, raw].join('\n'));