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/README.md +384 -10
- package/package.json +1 -1
- package/src/agent.js +118 -23
- package/src/argv.js +160 -0
- package/src/boot.js +59 -0
- package/src/client.js +109 -4
- package/src/compact.js +22 -5
- package/src/config.js +38 -0
- package/src/index.js +99 -63
- package/src/plan.js +167 -0
- package/src/reasoning.js +118 -0
- package/src/setup.js +535 -0
- package/src/tools.js +73 -13
- package/src/ui.js +28 -1
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 (
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 =
|
|
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) {
|
|
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 &&
|
|
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 = !
|
|
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
|
-
|
|
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
|
+
}
|
package/src/reasoning.js
ADDED
|
@@ -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
|
+
}
|