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