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.
@@ -0,0 +1,218 @@
1
+ import { def, TOOLS } from './tools.js';
2
+ import { config } from './config.js';
3
+ import { c } from './ui.js';
4
+
5
+ /**
6
+ * Sub-agents: one delegated task, one throwaway context.
7
+ *
8
+ * The point is the same one src/distill.js makes about command output, moved
9
+ * up a level. A survey that reads thirty files to answer one question costs
10
+ * the main conversation thirty files' worth of window, permanently, and it is
11
+ * still paying for them ten turns later. Handing that survey to a sub-agent
12
+ * costs the conversation exactly one tool result: the report. Everything the
13
+ * sub-agent read is discarded with its context.
14
+ *
15
+ * So this is not parallelism — a local Kronk server has one resident copy of
16
+ * the model and calls queue behind each other, so two sub-agents at once is
17
+ * slower than two in sequence, not faster. It is context economy. Delegate
18
+ * what is expensive to read and cheap to conclude.
19
+ */
20
+
21
+ /** Look, never touch. Every sub-agent gets at least these. */
22
+ const READ_ONLY = ['read_file', 'list_dir', 'search'];
23
+
24
+ /**
25
+ * Rules every sub-agent is held to, whatever it is allowed to touch.
26
+ *
27
+ * The first two carry the whole contract. A sub-agent that reports "I found
28
+ * the bug" without saying where is a sub-agent whose entire context was spent
29
+ * for nothing — the caller cannot go and look, because the files it read are
30
+ * gone.
31
+ */
32
+ const CONTRACT = `You are a sub-agent of kronk-cli, running fully offline on the user's machine.
33
+
34
+ You have been given one self-contained task and a context of your own.
35
+
36
+ - Nobody sees your work. The agent that delegated this cannot see the files you read, the commands you ran, or your reasoning — your final reply is the only thing it ever receives.
37
+ - So the reply has to stand alone: the answer, the exact paths and line numbers behind it, whatever failed, and whatever you had to assume. Quote the few lines that matter verbatim; never paste whole files, because not having to hold them is the reason you were given this task.
38
+ - Do not ask questions. There is nobody to answer one. Take the most reasonable reading of the task, act on it, and say what you assumed.
39
+ - Do not stop half way and report progress. Finish the task, then report.
40
+ - Keep the reply under about 30 lines.
41
+ - The working directory is the user's project root. Paths are relative to it. You are already inside it. Do not \`cd\` above it.`;
42
+
43
+ /**
44
+ * The roles a sub-agent can be given.
45
+ *
46
+ * Two, not ten. Each one is a line the delegating model has to read and choose
47
+ * between on every call, and a local model asked to pick between eight
48
+ * near-synonyms picks badly — the split that actually changes what can happen
49
+ * is "can it touch anything", so that is the split.
50
+ */
51
+ export const AGENTS = {
52
+ explore: {
53
+ use: 'reading, searching, tracing how something works, answering a question about the code',
54
+ tools: READ_ONLY,
55
+ rules: '- You can only read: read_file, list_dir and search. You cannot run commands and you cannot change anything. If the task needs a command run or a file written, say so in your reply rather than trying.',
56
+ },
57
+ code: {
58
+ use: 'making a change, running a build or a test suite, reproducing a failure',
59
+ tools: [...READ_ONLY, 'write_file', 'bash'],
60
+ rules: '- You can also write files and run commands. The user is still asked before each one, and a refusal is an answer: report it, do not look for a way around it.\n'
61
+ + '- After you write code, RUN it and fix what breaks. Never report that something works unless you have executed it and seen the output.',
62
+ },
63
+ };
64
+
65
+ const agentList = () => Object.entries(AGENTS)
66
+ .map(([name, a]) => `"${name}" — ${a.use}`).join('; ');
67
+
68
+ /**
69
+ * The instruction the delegating model actually reads.
70
+ *
71
+ * Same reasoning as set_plan's description in src/tools.js: a description is
72
+ * the one place a small model reliably looks, so the whole protocol lives
73
+ * here — what it is for, that the sub-agent starts blank, and that a vague
74
+ * prompt is the failure mode.
75
+ */
76
+ export const TASK_TOOL = def('task',
77
+ 'Delegate one self-contained piece of work to a sub-agent that runs in its own context. '
78
+ + 'Use it when getting the answer means reading or running a lot but the answer itself is '
79
+ + 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
80
+ + 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
81
+ + 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
82
+ + 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
83
+ + 'task over several vague ones.',
84
+ {
85
+ agent: {
86
+ type: 'string',
87
+ enum: Object.keys(AGENTS),
88
+ description: `Which sub-agent to run: ${agentList()}.`,
89
+ },
90
+ prompt: {
91
+ type: 'string',
92
+ description: 'The whole task, written for someone who knows nothing about this '
93
+ + 'conversation: what to do, where to look, and exactly what to report back.',
94
+ },
95
+ },
96
+ ['agent', 'prompt']);
97
+
98
+ /**
99
+ * The delegation tool, when it is allowed at all.
100
+ *
101
+ * Empty below the top level, and that is the recursion guard: a sub-agent is
102
+ * never handed the tool that would let it spawn one. Nothing else enforces
103
+ * depth, and nothing else needs to.
104
+ */
105
+ export function taskTools(depth = 0) {
106
+ return depth === 0 && config.subagents ? [TASK_TOOL] : [];
107
+ }
108
+
109
+ /** The built-ins this role is allowed, in the order src/tools.js defines them. */
110
+ export function subagentTools(agent) {
111
+ const allowed = new Set(AGENTS[agent]?.tools ?? READ_ONLY);
112
+ return TOOLS.filter((t) => allowed.has(t.function.name));
113
+ }
114
+
115
+ /**
116
+ * The sub-agent's system prompt: the contract, its role's rules, and the same
117
+ * project primer the main agent was given at startup.
118
+ *
119
+ * The primer is worth its tokens — without it every sub-agent spends its first
120
+ * two steps working out what the project is, in a context it only gets one of.
121
+ */
122
+ export function systemFor(agent) {
123
+ const spec = AGENTS[agent];
124
+ const primer = config.projectPrimer ? `\n\n---\n\n${config.projectPrimer}` : '';
125
+ return `${CONTRACT}\n${spec.rules}${primer}`;
126
+ }
127
+
128
+ /** Nest one line of a sub-agent's output under the call that started it. */
129
+ const nest = (line) => String(line).split('\n')
130
+ .map((l) => `${c.grey(' │')} ${l}`).join('\n');
131
+
132
+ const MAX_REPORT_LINES = 14;
133
+
134
+ /** The report on screen, dimmed and bounded — the caller received all of it. */
135
+ function reportLines(text) {
136
+ const lines = text.split('\n');
137
+ const shown = lines.slice(0, MAX_REPORT_LINES).map((l) => c.grey(` ${l}`));
138
+ const hidden = lines.length - shown.length;
139
+ if (hidden > 0) {
140
+ shown.push(c.grey(` …${hidden} more line${hidden === 1 ? '' : 's'} (the agent received all of it)`));
141
+ }
142
+ return shown;
143
+ }
144
+
145
+ /**
146
+ * The sub-agent's last word, which is the entire value of the call.
147
+ *
148
+ * A turn always ends on an assistant message — see `endTurn` in src/agent.js —
149
+ * so the tail is the report. An empty one is reported as the error it is
150
+ * rather than handed back as a blank tool result the caller would have to
151
+ * interpret.
152
+ */
153
+ function reportFrom(messages) {
154
+ const last = [...messages].reverse().find((m) => m.role === 'assistant');
155
+ const text = String(last?.content ?? '').trim();
156
+ if (!text) return 'error: the sub-agent finished without reporting anything. Do the work here, or delegate again with a more specific prompt.';
157
+ return text;
158
+ }
159
+
160
+ /**
161
+ * Run one delegated task to completion and hand back only its report.
162
+ *
163
+ * `run` is `runTurn` from src/agent.js, passed in rather than imported: the
164
+ * dispatch for this tool lives in that loop, so importing it back would be a
165
+ * cycle for nothing. `approve` and `grant` are the caller's own, unchanged —
166
+ * delegation is not a way to get a command past the user.
167
+ */
168
+ export async function runTask(args, {
169
+ run, model, signal, approve, grant, out = console.log, maxSteps = config.subagentSteps,
170
+ }) {
171
+ const agent = args?.agent ?? 'explore';
172
+ const prompt = String(args?.prompt ?? '').trim();
173
+ if (!AGENTS[agent]) {
174
+ return `error: unknown agent "${agent}" — use one of: ${Object.keys(AGENTS).join(', ')}`;
175
+ }
176
+ if (!prompt) return 'error: task needs a prompt saying what to do and what to report back';
177
+
178
+ const messages = [
179
+ { role: 'system', content: systemFor(agent) },
180
+ { role: 'user', content: prompt },
181
+ ];
182
+
183
+ // A sub-agent on another model gets that model's window and output cap, not
184
+ // the session's. Compacting a 32k sub-agent at 85% of the main model's 131k
185
+ // means never compacting it at all.
186
+ const own = config.subagentModel ? config.subagentLimits : null;
187
+
188
+ const done = await run({
189
+ messages,
190
+ // A smaller model for the grunt work is the whole reason this is a knob:
191
+ // the survey is reading and grepping, the synthesis is not.
192
+ model: config.subagentModel ?? model,
193
+ window: own ? own.contextWindow : config.contextWindow,
194
+ maxTokens: own?.maxTokens ?? config.maxTokens,
195
+ signal,
196
+ approve,
197
+ grant,
198
+ // No MCP: a local model already struggles past ~25 tools, and the
199
+ // sub-agent's list is meant to be the short one.
200
+ mcp: null,
201
+ tools: subagentTools(agent),
202
+ // The checklist is module state belonging to the task the user asked for.
203
+ // A sub-agent that called set_plan would clear it out from under the agent
204
+ // that delegated to it.
205
+ plan: false,
206
+ // Its reasoning and prose are not printed: they are not the answer, and a
207
+ // second stream interleaved with the caller's is unreadable. Tool calls
208
+ // still show, indented, so the run is not a black box.
209
+ stream: false,
210
+ out: (line) => out(nest(line)),
211
+ maxSteps,
212
+ depth: 1,
213
+ });
214
+
215
+ const report = reportFrom(done);
216
+ reportLines(report).forEach((l) => out(l));
217
+ return report;
218
+ }
package/src/theme.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * What colour to print in, given the terminal we were launched from.
3
+ *
4
+ * The status line, the context meter and every hint under it were bright black
5
+ * (SGR 90). On a light background that is a readable grey; on a dark one most
6
+ * terminals render it around #555, which is a few points of contrast away from
7
+ * the background it sits on — the line the REPL shows before every prompt was
8
+ * the hardest thing on screen to read. Bright black is the only grey the
9
+ * sixteen-colour palette has, so the fix is not a different code but a
10
+ * different palette per background, chosen from the 256-colour cube.
11
+ *
12
+ * Three ways to learn the background, in order of how much we trust them:
13
+ * what the user said (`KRONK_THEME`, `theme` in ~/.kronk-cli.json), what the
14
+ * terminal advertises (`COLORFGBG`), and what it answers when asked (OSC 11).
15
+ * The last one is a round trip over the tty, so it happens once at startup and
16
+ * never blocks for longer than a blink.
17
+ */
18
+
19
+ /**
20
+ * Palettes, as SGR parameters. Each is chosen for contrast against its own
21
+ * background, not for fidelity to the sixteen-colour name it replaces: `red`
22
+ * on a light background is a darker red than on a dark one, and both are still
23
+ * unmistakably red.
24
+ *
25
+ * The `grey`/`dim` pair is the whole point. 245 on dark and 242 on light both
26
+ * land near 4.5:1 against a typical terminal background, where 90 on dark is
27
+ * closer to 1.9:1.
28
+ */
29
+ export const PALETTES = {
30
+ 256: {
31
+ dark: {
32
+ dim: '38;5;244', bold: '1', italic: '3', strike: '9',
33
+ red: '38;5;203', green: '38;5;114', yellow: '38;5;221', blue: '38;5;111',
34
+ magenta: '38;5;176', cyan: '38;5;116', grey: '38;5;245',
35
+ },
36
+ light: {
37
+ dim: '38;5;244', bold: '1', italic: '3', strike: '9',
38
+ red: '38;5;160', green: '38;5;28', yellow: '38;5;136', blue: '38;5;26',
39
+ magenta: '38;5;90', cyan: '38;5;30', grey: '38;5;242',
40
+ },
41
+ },
42
+ // Sixteen colours: no cube to pick from, so the only lever is whether a hue
43
+ // comes from the normal or the bright half. Light keeps exactly what this
44
+ // program printed before the palettes existed.
45
+ 16: {
46
+ dark: {
47
+ dim: '37', bold: '1', italic: '3', strike: '9',
48
+ red: '91', green: '92', yellow: '93', blue: '94',
49
+ magenta: '95', cyan: '96', grey: '37',
50
+ },
51
+ light: {
52
+ dim: '90', bold: '1', italic: '3', strike: '9',
53
+ red: '31', green: '32', yellow: '33', blue: '34',
54
+ magenta: '35', cyan: '36', grey: '90',
55
+ },
56
+ },
57
+ };
58
+
59
+ /** Anything we cannot classify is dark: it is what most terminals ship as. */
60
+ export const DEFAULT_THEME = 'dark';
61
+
62
+ const THEMES = new Set(['dark', 'light']);
63
+
64
+ /**
65
+ * How many colours we may use. 0 means print no escapes at all, which is what
66
+ * NO_COLOR, a dumb terminal, a redirected stdout and `FORCE_COLOR=0` each ask
67
+ * for; any other FORCE_COLOR overrides the redirect, so a pipe into `less -R`
68
+ * and the test suite can both see what a terminal would.
69
+ */
70
+ export function colorDepth(env = process.env, stream = process.stdout) {
71
+ if (env.NO_COLOR) return 0;
72
+ if (env.TERM === 'dumb') return 0;
73
+ if (env.FORCE_COLOR === '0') return 0;
74
+ if (env.FORCE_COLOR === undefined && !stream?.isTTY) return 0;
75
+ const term = env.TERM ?? '';
76
+ if (env.COLORTERM || /-256(color)?\b/.test(term) || /^(alacritty|kitty|wezterm|xterm-ghostty)/.test(term)) {
77
+ return 256;
78
+ }
79
+ return 16;
80
+ }
81
+
82
+ /**
83
+ * The background as `COLORFGBG` describes it — `foreground;background`, or
84
+ * `foreground;bold;background` from rxvt. The background is the last field,
85
+ * as a palette index: the dark half of the sixteen, plus bright black, means
86
+ * a dark terminal. `default` is the one honest answer some terminals give and
87
+ * it tells us nothing, so it stays unclassified rather than guessed at.
88
+ */
89
+ export function parseColorFgBg(value) {
90
+ if (typeof value !== 'string') return null;
91
+ const bg = value.split(';').pop()?.trim();
92
+ if (!bg || !/^\d+$/.test(bg)) return null;
93
+ const n = Number(bg);
94
+ if (n > 15) return null;
95
+ return [0, 1, 2, 3, 4, 5, 6, 8].includes(n) ? 'dark' : 'light';
96
+ }
97
+
98
+ /**
99
+ * The reply to an OSC 11 query: `ESC ] 11 ; rgb:RRRR/GGGG/BBBB`, an optional
100
+ * alpha component, then BEL or ST. Components are 1 to 4 hex digits wide
101
+ * depending on the terminal — xterm answers in 16 bits, others in 8.
102
+ */
103
+ // eslint-disable-next-line no-control-regex -- ESC is what the reply starts with
104
+ const OSC11 = /\x1b\]11;rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?(?:\x07|\x1b\\)?/i;
105
+
106
+ /**
107
+ * The reply found in `buf`: its relative luminance, and where it sat, so the
108
+ * caller can hand back whatever else was in the buffer. Each component is
109
+ * scaled by its own width — a short reply is not a near-black one.
110
+ */
111
+ function matchOsc11(buf) {
112
+ const m = typeof buf === 'string' ? OSC11.exec(buf) : null;
113
+ if (!m) return null;
114
+ const [r, g, b] = m.slice(1, 4).map((h) => parseInt(h, 16) / (16 ** h.length - 1));
115
+ return {
116
+ luminance: 0.2126 * r + 0.7152 * g + 0.0722 * b,
117
+ rest: buf.slice(0, m.index) + buf.slice(m.index + m[0].length),
118
+ };
119
+ }
120
+
121
+ /** The relative luminance of an OSC 11 reply, or null if this is not one. */
122
+ export function parseOsc11(reply) {
123
+ return matchOsc11(reply)?.luminance ?? null;
124
+ }
125
+
126
+ /** Mid-grey is the boundary; a terminal sitting exactly on it reads as light. */
127
+ export const themeForLuminance = (lum) => (lum >= 0.5 ? 'light' : 'dark');
128
+
129
+ /**
130
+ * A theme somebody named, or null for "work it out". `auto` is spelled out
131
+ * rather than left to an empty value so a config file can turn a forced theme
132
+ * back off without deleting the key, and a typo falls through to detection
133
+ * rather than pinning the wrong palette.
134
+ */
135
+ export const namedTheme = (value) => {
136
+ const asked = String(value ?? '').trim().toLowerCase();
137
+ return THEMES.has(asked) ? asked : null;
138
+ };
139
+
140
+ /** What the environment alone can tell us, or null for "ask the terminal". */
141
+ export function detectTheme(env = process.env) {
142
+ return namedTheme(env.KRONK_THEME) ?? parseColorFgBg(env.COLORFGBG);
143
+ }
144
+
145
+ /**
146
+ * Ask the terminal for its background colour and wait, briefly.
147
+ *
148
+ * This puts stdin in raw mode for as long as the answer takes, so it runs
149
+ * once, before the REPL's readline exists, and only when both ends are a tty.
150
+ * A terminal that does not implement OSC 11 says nothing at all, which is why
151
+ * there is a timeout rather than a sentinel to wait for; 100 ms is far longer
152
+ * than a local round trip and short enough to be invisible.
153
+ *
154
+ * Anything read that is not part of the reply is somebody typing ahead, and it
155
+ * is pushed back onto stdin before this resolves. Without that, a fast typist
156
+ * — or a script piping commands in — loses whatever landed in the window, and
157
+ * loses it silently.
158
+ */
159
+ export function probeBackground({ input = process.stdin, output = process.stdout, timeoutMs = 100 } = {}) {
160
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== 'function') {
161
+ return Promise.resolve(null);
162
+ }
163
+ return new Promise((resolve) => {
164
+ const wasRaw = input.isRaw;
165
+ let buf = '';
166
+ let settled = false;
167
+ const finish = (value, rest) => {
168
+ if (settled) return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ input.off('data', onData);
172
+ if (!wasRaw) input.setRawMode(false);
173
+ input.pause();
174
+ if (rest) input.unshift?.(Buffer.from(rest, 'latin1'));
175
+ resolve(value);
176
+ };
177
+ const onData = (chunk) => {
178
+ buf += chunk.toString('latin1');
179
+ const hit = matchOsc11(buf);
180
+ if (hit) finish(themeForLuminance(hit.luminance), hit.rest);
181
+ // Whatever this is, it is not the reply and no longer plausibly the
182
+ // start of one. Stop reading and give it back.
183
+ else if (buf.length > 64) finish(null, buf);
184
+ };
185
+ const timer = setTimeout(() => finish(null, buf), timeoutMs);
186
+ input.setRawMode(true);
187
+ input.resume();
188
+ input.on('data', onData);
189
+ output.write('\x1b]11;?\x07');
190
+ });
191
+ }
192
+
193
+ let depth = colorDepth();
194
+ let active = detectTheme() ?? DEFAULT_THEME;
195
+
196
+ /** Which palette is in force. Read by the tests and by `/theme`. */
197
+ export const theme = () => active;
198
+
199
+ /** Depth 0 means every colour helper is the identity function. */
200
+ export const colorsOn = () => depth > 0;
201
+
202
+ /** Set both explicitly. Tests use it; nothing in the program has to guess. */
203
+ export function useTheme({ name, colors } = {}) {
204
+ if (THEMES.has(name)) active = name;
205
+ if (colors !== undefined) depth = colors ? (colors === true ? 256 : colors) : 0;
206
+ return active;
207
+ }
208
+
209
+ /**
210
+ * Settle the palette for this session: what the user asked for, what the
211
+ * environment said, or failing both what the terminal answers. Startup awaits this once; everything that
212
+ * prints reads `ansi` per call, so the banner and every later line agree.
213
+ */
214
+ export async function resolveTheme({ prefer, ...options } = {}) {
215
+ const known = namedTheme(prefer) ?? detectTheme();
216
+ if (known) { active = known; return active; }
217
+ if (depth === 0) return active; // nothing to colour, don't touch the tty
218
+ active = (await probeBackground(options)) ?? DEFAULT_THEME;
219
+ return active;
220
+ }
221
+
222
+ /** The SGR parameters for a palette entry, or null when colour is off. */
223
+ export function ansi(name) {
224
+ if (depth === 0) return null;
225
+ return PALETTES[depth === 256 ? 256 : 16][active][name] ?? null;
226
+ }
package/src/tools.js CHANGED
@@ -206,7 +206,13 @@ export function safe(p) {
206
206
  return abs;
207
207
  }
208
208
 
209
- const def = (name, description, properties, required) => ({
209
+ /**
210
+ * A tool definition in the shape the OpenAI-compatible API wants.
211
+ *
212
+ * Exported because src/subagent.js defines a tool too — one that is only
213
+ * offered at the top level, so it cannot live in TOOLS below.
214
+ */
215
+ export const def = (name, description, properties, required) => ({
210
216
  type: 'function',
211
217
  function: { name, description, parameters: { type: 'object', properties, required } },
212
218
  });
@@ -284,6 +290,7 @@ export function describe(name, args) {
284
290
  case 'search': return `search /${args.pattern}/ in ${args.path ?? '.'}`;
285
291
  case 'bash': return `bash: ${args.cmd}`;
286
292
  case 'set_plan': return `plan: ${args.items?.length ?? 0} items`;
293
+ case 'task': return `task ${args.agent ?? 'explore'}: ${String(args.prompt ?? '').split('\n')[0]}`;
287
294
  default: return `${name}(${JSON.stringify(args)})`;
288
295
  }
289
296
  }
package/src/ui.js CHANGED
@@ -1,16 +1,27 @@
1
- const on = process.stdout.isTTY && !process.env.NO_COLOR;
2
- const wrap = (code) => (s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
1
+ import { ansi } from './theme.js';
2
+
3
+ /**
4
+ * Each colour resolves its escape when it is called, not when this module is
5
+ * loaded: the background is only known after the terminal has been asked, and
6
+ * that answer arrives after the first import. See src/theme.js.
7
+ */
8
+ const wrap = (name) => (s) => {
9
+ const code = ansi(name);
10
+ return code ? `\x1b[${code}m${s}\x1b[0m` : s;
11
+ };
3
12
 
4
13
  export const c = {
5
- dim: wrap('2'),
6
- bold: wrap('1'),
7
- red: wrap('31'),
8
- green: wrap('32'),
9
- yellow: wrap('33'),
10
- blue: wrap('34'),
11
- magenta: wrap('35'),
12
- cyan: wrap('36'),
13
- grey: wrap('90'),
14
+ dim: wrap('dim'),
15
+ bold: wrap('bold'),
16
+ italic: wrap('italic'),
17
+ strike: wrap('strike'),
18
+ red: wrap('red'),
19
+ green: wrap('green'),
20
+ yellow: wrap('yellow'),
21
+ blue: wrap('blue'),
22
+ magenta: wrap('magenta'),
23
+ cyan: wrap('cyan'),
24
+ grey: wrap('grey'),
14
25
  };
15
26
 
16
27
  export const banner = (model, url) => `