kronk-cli 0.1.3 → 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 +332 -9
- package/package.json +1 -1
- package/src/agent.js +118 -23
- package/src/argv.js +160 -0
- package/src/client.js +87 -4
- package/src/compact.js +22 -5
- package/src/config.js +35 -0
- package/src/index.js +84 -55
- 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/setup.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kronk-cli setup` — the three things a first-time user has to know and
|
|
3
|
+
* currently has to do by hand: pull the model, give it an /AGENT profile in
|
|
4
|
+
* ~/.kronk/models/model_config.yaml, and restart Kronk, because that file is
|
|
5
|
+
* only read at server start.
|
|
6
|
+
*
|
|
7
|
+
* The YAML writer here is deliberately not a parser. It locates one insertion
|
|
8
|
+
* point by a structural scan and refuses whenever the answer is not obvious.
|
|
9
|
+
* A half-written parser would round-trip — and quietly corrupt — a file it does
|
|
10
|
+
* not understand, which is worse than printing the block and letting the user
|
|
11
|
+
* paste it.
|
|
12
|
+
*/
|
|
13
|
+
import readline from 'node:readline/promises';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { accessSync, constants, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
17
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
18
|
+
import { config, DEFAULT_MODEL } from './config.js';
|
|
19
|
+
import { listModels, modelLimits } from './client.js';
|
|
20
|
+
import { c } from './ui.js';
|
|
21
|
+
|
|
22
|
+
/** The documented defaults for an agent profile. See README, "Tip: use an /AGENT profile". */
|
|
23
|
+
const DEFAULT_CONTEXT = 131072;
|
|
24
|
+
const PROFILE_MAX_TOKENS = 16384;
|
|
25
|
+
const NSEQ_MAX = 2;
|
|
26
|
+
|
|
27
|
+
/** How long to wait for the server to answer again after a restart. */
|
|
28
|
+
const RESTART_TIMEOUT_MS = 90_000;
|
|
29
|
+
const RESTART_POLL_MS = 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `catalog show` prints the whole tokenizer vocabulary — megabytes of it. Only
|
|
33
|
+
* the header is ever read, so stop accumulating once it cannot still be there.
|
|
34
|
+
*/
|
|
35
|
+
const CAPTURE_CAP = 64 * 1024;
|
|
36
|
+
|
|
37
|
+
// ---- pure helpers -------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The catalog id for a model id. A catalog entry is `owner/name`; anything past
|
|
41
|
+
* that is a Kronk profile suffix (`/AGENT`), which the catalog cannot resolve.
|
|
42
|
+
*/
|
|
43
|
+
export function baseModelId(id) {
|
|
44
|
+
const parts = id.split('/');
|
|
45
|
+
return parts.length > 2 ? parts.slice(0, -1).join('/') : id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ids are plain enough to sit unquoted in YAML, but do not bet the file on it. */
|
|
49
|
+
const YAML_SAFE = /^[A-Za-z0-9._/@+-]+$/;
|
|
50
|
+
const yamlKey = (id) => (YAML_SAFE.test(id) ? id : `'${id.replace(/'/g, "''")}'`);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The profile block, as lines, at the two-space indent of a `models:` child.
|
|
54
|
+
*
|
|
55
|
+
* No `temperature`, `top_k` or `top_p`: current GGUFs carry the values their
|
|
56
|
+
* authors recommend and Kronk's AutoTune reads them, so an explicit block here
|
|
57
|
+
* would only override the model's own advice.
|
|
58
|
+
*/
|
|
59
|
+
export function profileEntry(id, contextWindow) {
|
|
60
|
+
return [
|
|
61
|
+
` ${yamlKey(id)}:`,
|
|
62
|
+
` context-window: ${contextWindow}`,
|
|
63
|
+
` nseq-max: ${NSEQ_MAX}`,
|
|
64
|
+
' chat-template-kwargs:',
|
|
65
|
+
' preserve_thinking: true',
|
|
66
|
+
' sampling-parameters:',
|
|
67
|
+
` max_tokens: ${PROFILE_MAX_TOKENS}`,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every line with its byte offsets and its own terminator, so nothing is re-joined. */
|
|
72
|
+
function splitLines(text) {
|
|
73
|
+
const rows = [];
|
|
74
|
+
let start = 0;
|
|
75
|
+
for (let i = 0; i <= text.length; i++) {
|
|
76
|
+
if (i < text.length && text[i] !== '\n') continue;
|
|
77
|
+
const raw = text.slice(start, i);
|
|
78
|
+
const crlf = raw.endsWith('\r');
|
|
79
|
+
rows.push({
|
|
80
|
+
text: crlf ? raw.slice(0, -1) : raw,
|
|
81
|
+
end: crlf ? i - 1 : i, // first byte of the terminator
|
|
82
|
+
term: i < text.length ? (crlf ? '\r\n' : '\n') : '',
|
|
83
|
+
});
|
|
84
|
+
start = i + 1;
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where the entry for `id` goes in an existing model_config.yaml.
|
|
93
|
+
*
|
|
94
|
+
* Top-level keys are lines matching `^[A-Za-z_]` with no leading whitespace, as
|
|
95
|
+
* the issue specifies; the children of `models:` run to the next such line.
|
|
96
|
+
*
|
|
97
|
+
* kind is one of:
|
|
98
|
+
* 'present' the profile key is already there — nothing to do
|
|
99
|
+
* 'insert' splice the entry in directly beneath the one `models:` key
|
|
100
|
+
* 'append' no `models:` key at all — add the key and the entry at the end
|
|
101
|
+
* 'refuse' more than one insertion point, or none that is unambiguous
|
|
102
|
+
*/
|
|
103
|
+
export function scanConfig(text, id) {
|
|
104
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
105
|
+
const rows = splitLines(text);
|
|
106
|
+
const heads = rows
|
|
107
|
+
.map((row, index) => ({ row, index }))
|
|
108
|
+
.filter(({ row }) => /^[A-Za-z_]/.test(row.text) && /^models\s*:/.test(row.text));
|
|
109
|
+
|
|
110
|
+
if (heads.length > 1) {
|
|
111
|
+
return { eol, kind: 'refuse', reason: `it has ${heads.length} top-level "models:" keys` };
|
|
112
|
+
}
|
|
113
|
+
if (!heads.length) return { eol, kind: 'append' };
|
|
114
|
+
|
|
115
|
+
const { row, index } = heads[0];
|
|
116
|
+
// `models: {}` or `models: [x]` is a mapping written inline. Its children are
|
|
117
|
+
// not lines, so there is no line to insert one beneath.
|
|
118
|
+
const inline = row.text.slice(row.text.indexOf(':') + 1).trim();
|
|
119
|
+
if (inline && !inline.startsWith('#')) {
|
|
120
|
+
return { eol, kind: 'refuse', reason: 'its "models:" key holds an inline value' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let end = rows.length;
|
|
124
|
+
for (let i = index + 1; i < rows.length; i++) {
|
|
125
|
+
if (/^[A-Za-z_]/.test(rows[i].text)) { end = i; break; }
|
|
126
|
+
}
|
|
127
|
+
// An exact match on the indented `<id>:` line. A commented-out `# <id>:` has a
|
|
128
|
+
// `#` where the id would start, so it never matches.
|
|
129
|
+
const key = new RegExp(`^\\s+${escapeRe(yamlKey(id))}\\s*:\\s*(#.*)?$`);
|
|
130
|
+
for (const child of rows.slice(index + 1, end)) {
|
|
131
|
+
if (key.test(child.text)) return { eol, kind: 'present' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { eol, kind: 'insert', head: row, next: rows[index + 1] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The file as it will be on disk. Every line the writer did not add is copied
|
|
139
|
+
* across untouched, terminator included — the entry is spliced into the string
|
|
140
|
+
* rather than the file being re-serialised from parsed lines.
|
|
141
|
+
*/
|
|
142
|
+
export function applyEntry(text, scan, entry) {
|
|
143
|
+
const { eol } = scan;
|
|
144
|
+
const block = entry.map((l) => l + eol).join('');
|
|
145
|
+
|
|
146
|
+
if (scan.kind === 'insert') {
|
|
147
|
+
const at = scan.head.end + scan.head.term.length;
|
|
148
|
+
// A file that ended on `models:` with no newline still needs one.
|
|
149
|
+
const lead = scan.head.term ? '' : eol;
|
|
150
|
+
// Keep whatever followed from being glued to the new entry.
|
|
151
|
+
const gap = scan.next && scan.next.text.trim() !== '' ? eol : '';
|
|
152
|
+
return text.slice(0, at) + lead + block + gap + text.slice(at);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let out = text;
|
|
156
|
+
if (out.length && !out.endsWith(eol)) out += eol; // finish the last line
|
|
157
|
+
if (out.length && !out.endsWith(eol + eol)) out += eol; // one blank line before the key
|
|
158
|
+
return `${out}models:${eol}${block}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A brand-new file. Nothing to preserve, so it gets the documented shape. */
|
|
162
|
+
export function newConfig(entry) {
|
|
163
|
+
return ['version: 1', '', 'models:', ...entry, ''].join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Copy the file aside before touching it, never over a backup that already
|
|
168
|
+
* exists — `.bak` and `.bak2` are sitting next to real configs in the wild.
|
|
169
|
+
* COPYFILE_EXCL makes "does it exist" and "claim it" one operation.
|
|
170
|
+
*/
|
|
171
|
+
export function backupFile(path) {
|
|
172
|
+
for (let n = 1; n <= 50; n++) {
|
|
173
|
+
const dest = `${path}.bak${n === 1 ? '' : n}`;
|
|
174
|
+
try {
|
|
175
|
+
copyFileSync(path, dest, constants.COPYFILE_EXCL);
|
|
176
|
+
return dest;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (e.code !== 'EEXIST') throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`${path}.bak … .bak50 all exist — clean some up first`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve an executable on PATH without spawning anything, so `--dry-run` can
|
|
186
|
+
* report a missing binary while keeping its promise to start no process.
|
|
187
|
+
*/
|
|
188
|
+
export function findOnPath(name, env = process.env) {
|
|
189
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
190
|
+
if (!dir) continue;
|
|
191
|
+
try {
|
|
192
|
+
const full = join(dir, name);
|
|
193
|
+
accessSync(full, constants.X_OK);
|
|
194
|
+
return full;
|
|
195
|
+
} catch {
|
|
196
|
+
// not here, keep looking
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- the kronk binary ---------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The only place this program starts the `kronk` binary. One door means a test
|
|
206
|
+
* can put a stub named `kronk` first on PATH and assert on the argv it saw.
|
|
207
|
+
*
|
|
208
|
+
* `stream: true` hands the child our terminal, which is what a 21 GB pull wants;
|
|
209
|
+
* otherwise its output is captured (up to CAPTURE_CAP) for parsing.
|
|
210
|
+
*/
|
|
211
|
+
export function runKronk(argv, { stream = false } = {}) {
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
const child = spawn('kronk', argv, {
|
|
214
|
+
stdio: ['ignore', stream ? 'inherit' : 'pipe', stream ? 'inherit' : 'pipe'],
|
|
215
|
+
});
|
|
216
|
+
let output = '';
|
|
217
|
+
const take = (d) => { if (output.length < CAPTURE_CAP) output += d; };
|
|
218
|
+
child.stdout?.setEncoding('utf8').on('data', take);
|
|
219
|
+
child.stderr?.setEncoding('utf8').on('data', take);
|
|
220
|
+
child.on('error', reject);
|
|
221
|
+
child.on('close', (code) => resolve({ code: code ?? 1, output }));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What the local catalog knows about a base id.
|
|
227
|
+
*
|
|
228
|
+
* `catalog show` exits 0 even for an id it cannot resolve, so presence is read
|
|
229
|
+
* from the body, not the status.
|
|
230
|
+
*/
|
|
231
|
+
async function catalogEntry(base) {
|
|
232
|
+
const { output } = await runKronk(['catalog', 'show', base, '--local']);
|
|
233
|
+
const size = /^Total Size:\s*(.+)$/m.exec(output)?.[1].trim() ?? null;
|
|
234
|
+
return { known: size !== null, size, downloaded: /^Downloaded:\s*true\s*$/m.test(output) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- output -------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
const line = (s = '') => console.log(s);
|
|
240
|
+
const step = (n, title) => line(`\n ${c.bold(`${n})`)} ${title}`);
|
|
241
|
+
const detail = (s) => line(c.grey(` ${s}`));
|
|
242
|
+
const note = (s) => line(` ${s}`);
|
|
243
|
+
const cmd = (s) => line(c.cyan(` ${s}`));
|
|
244
|
+
|
|
245
|
+
/** Show exactly the lines that are being added, or that the user must paste. */
|
|
246
|
+
function printBlock(lines) {
|
|
247
|
+
line();
|
|
248
|
+
for (const l of lines) line(c.grey(` ${l}`));
|
|
249
|
+
line();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- the walk -----------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run the whole setup path. Returns the process exit code rather than calling
|
|
256
|
+
* process.exit, so the caller stays in charge of how the program ends.
|
|
257
|
+
*/
|
|
258
|
+
export async function runSetup({ model, context, yes = false, dryRun = false } = {}) {
|
|
259
|
+
if (context !== null && context !== undefined && !/^[1-9]\d*$/.test(String(context))) {
|
|
260
|
+
console.error(c.red('\n --context takes a positive integer\n'));
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let rl = null;
|
|
265
|
+
let ended = false;
|
|
266
|
+
const ask = async (question) => {
|
|
267
|
+
if (yes) { note(`${question} ${c.grey('yes (--yes)')}`); return true; }
|
|
268
|
+
if (dryRun) { note(`${question} ${c.grey('skipped (--dry-run)')}`); return false; }
|
|
269
|
+
if (!rl) {
|
|
270
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
271
|
+
rl.once('close', () => { ended = true; });
|
|
272
|
+
}
|
|
273
|
+
// Silence is not consent. An input that has already ended — a closed pipe, a
|
|
274
|
+
// background job, CI without --yes — must decline, and say why: asking a
|
|
275
|
+
// dead readline yields a promise that never settles, and the program would
|
|
276
|
+
// otherwise exit 0 in the middle of the walk having said nothing.
|
|
277
|
+
if (ended) { note(`${question} ${c.grey('no answer — stdin ended, assuming no')}`); return false; }
|
|
278
|
+
let answer;
|
|
279
|
+
try {
|
|
280
|
+
answer = await Promise.race([
|
|
281
|
+
rl.question(` ${c.yellow(question)} `),
|
|
282
|
+
new Promise((resolve) => rl.once('close', () => resolve(''))),
|
|
283
|
+
]);
|
|
284
|
+
} catch { return false; }
|
|
285
|
+
// A pipe does not echo, so the transcript would otherwise run the question
|
|
286
|
+
// and its consequence together on one line with no answer between them.
|
|
287
|
+
if (!process.stdin.isTTY) line(answer.trim());
|
|
288
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
return await walk({ model, context, dryRun, ask });
|
|
293
|
+
} finally {
|
|
294
|
+
rl?.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function walk({ model, context, dryRun, ask }) {
|
|
299
|
+
line(`\n ${c.bold('kronk-cli setup')}${dryRun ? c.grey(' · dry run, nothing will change') : ''}`);
|
|
300
|
+
|
|
301
|
+
// 1 — the server. Setup never starts it: that is the user's decision to make.
|
|
302
|
+
step(1, 'Checking the Kronk server');
|
|
303
|
+
try {
|
|
304
|
+
const ids = await listModels();
|
|
305
|
+
detail(`${config.baseUrl} · serving ${ids.length} model${ids.length === 1 ? '' : 's'}`);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error(c.red(`\n Cannot reach Kronk at ${config.baseUrl}`));
|
|
308
|
+
console.error(c.grey(` ${e.message}`));
|
|
309
|
+
console.error(c.grey(' Start it with: kronk server start --detach\n'));
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 2 — the target, and the catalog id underneath it.
|
|
314
|
+
const target = model ?? config.model ?? DEFAULT_MODEL;
|
|
315
|
+
const base = baseModelId(target);
|
|
316
|
+
step(2, 'Resolving the target');
|
|
317
|
+
detail(`profile ${target}`);
|
|
318
|
+
detail(`catalog ${base}`);
|
|
319
|
+
|
|
320
|
+
// Every later step shells out, so find the binary before promising anything.
|
|
321
|
+
const binary = findOnPath('kronk');
|
|
322
|
+
const entry = profileEntry(target, await contextWindow(base, context));
|
|
323
|
+
if (!binary) {
|
|
324
|
+
console.error(c.red('\n No `kronk` binary on PATH.'));
|
|
325
|
+
console.error(c.grey(' Setup drives the real CLI; install it, then re-run, or do this by hand:\n'));
|
|
326
|
+
console.error(c.cyan(` kronk model pull ${base}`));
|
|
327
|
+
console.error(c.grey(`\n then add to ${config.modelConfigPath}:\n`));
|
|
328
|
+
console.error(c.grey(' models:'));
|
|
329
|
+
for (const l of entry) console.error(c.grey(` ${l}`));
|
|
330
|
+
console.error(c.grey('\n and restart the server:\n'));
|
|
331
|
+
console.error(c.cyan(' kronk server stop'));
|
|
332
|
+
console.error(c.cyan(' kronk server start --detach\n'));
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
detail(`binary ${binary}`);
|
|
336
|
+
|
|
337
|
+
// 3 — is it already on disk?
|
|
338
|
+
step(3, 'Checking whether the model is downloaded');
|
|
339
|
+
let downloaded = false;
|
|
340
|
+
if (dryRun) {
|
|
341
|
+
cmd(`would run: kronk catalog show ${base} --local`);
|
|
342
|
+
} else {
|
|
343
|
+
const found = await catalogEntry(base);
|
|
344
|
+
downloaded = found.downloaded;
|
|
345
|
+
if (!found.known) detail('not in the local catalog — a pull will resolve it');
|
|
346
|
+
else detail(`${found.size ?? 'unknown size'} · downloaded: ${found.downloaded}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 4 — the pull.
|
|
350
|
+
step(4, 'Downloading the model');
|
|
351
|
+
if (downloaded) {
|
|
352
|
+
detail('already downloaded — nothing to pull');
|
|
353
|
+
} else if (dryRun) {
|
|
354
|
+
cmd(`would run: kronk model pull ${base}`);
|
|
355
|
+
} else if (!await ask(`Pull ${base} now? [y/N]`)) {
|
|
356
|
+
note('Declined. Pull it later with:');
|
|
357
|
+
cmd(`kronk model pull ${base}`);
|
|
358
|
+
return 0;
|
|
359
|
+
} else {
|
|
360
|
+
line();
|
|
361
|
+
const { code } = await runKronk(['model', 'pull', base], { stream: true });
|
|
362
|
+
if (code !== 0) {
|
|
363
|
+
console.error(c.red(`\n kronk model pull ${base} exited ${code} — stopping here.`));
|
|
364
|
+
console.error(c.grey(' Nothing has been written to model_config.yaml.\n'));
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
detail('pull finished');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 5 — the profile.
|
|
371
|
+
step(5, 'Writing the /AGENT profile');
|
|
372
|
+
const wrote = await writeProfile({ path: config.modelConfigPath, entry, target, dryRun, ask });
|
|
373
|
+
if (wrote.code !== 0) return wrote.code;
|
|
374
|
+
if (wrote.declined) return 0;
|
|
375
|
+
if (!wrote.changed) {
|
|
376
|
+
line(c.green(`\n Nothing to do — ${target} is already set up.\n`));
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 6 — the restart. model_config.yaml is read at server start and never again.
|
|
381
|
+
step(6, 'Restarting Kronk');
|
|
382
|
+
detail('model_config.yaml is read only when the server starts, so the new');
|
|
383
|
+
detail('profile does nothing until Kronk is restarted.');
|
|
384
|
+
if (dryRun) {
|
|
385
|
+
cmd('would run: kronk server stop');
|
|
386
|
+
cmd('would run: kronk server start --detach');
|
|
387
|
+
line(c.grey('\n Dry run complete — nothing was written and nothing was started.\n'));
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
if (!await ask('Restart Kronk now? [y/N]')) {
|
|
391
|
+
note('Declined. The profile is written but not live. Restart with:');
|
|
392
|
+
cmd('kronk server stop');
|
|
393
|
+
cmd('kronk server start --detach');
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
line();
|
|
398
|
+
const stopped = await runKronk(['server', 'stop'], { stream: true });
|
|
399
|
+
if (stopped.code !== 0) detail(`kronk server stop exited ${stopped.code} — continuing`);
|
|
400
|
+
const started = await runKronk(['server', 'start', '--detach'], { stream: true });
|
|
401
|
+
if (started.code !== 0) {
|
|
402
|
+
console.error(c.red(`\n kronk server start --detach exited ${started.code}.`));
|
|
403
|
+
console.error(c.grey(` The profile is written to ${config.modelConfigPath}; start the server by hand.\n`));
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
detail('waiting for the server to answer…');
|
|
408
|
+
if (!await waitForServer()) {
|
|
409
|
+
console.error(c.yellow(`\n Kronk did not answer within ${RESTART_TIMEOUT_MS / 1000}s.`));
|
|
410
|
+
console.error(c.grey(' Check: kronk server logs\n'));
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
detail('server is back');
|
|
414
|
+
line(c.green(`\n Done. ${target} is configured.`));
|
|
415
|
+
line(c.grey(` Try it: kronk-cli -m ${target}\n`));
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 131072 unless the model says it cannot, or the user says otherwise. */
|
|
420
|
+
async function contextWindow(base, override) {
|
|
421
|
+
if (override !== null && override !== undefined) {
|
|
422
|
+
const want = Number(override);
|
|
423
|
+
const { native } = await modelLimits(base);
|
|
424
|
+
if (native && want > native) {
|
|
425
|
+
console.error(c.yellow(` warning: --context ${want} exceeds ${base}'s native maximum of ${native}`));
|
|
426
|
+
}
|
|
427
|
+
return want;
|
|
428
|
+
}
|
|
429
|
+
const { native } = await modelLimits(base);
|
|
430
|
+
return native && native < DEFAULT_CONTEXT ? native : DEFAULT_CONTEXT;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Step 5 on its own: read, scan, back up, write. Returns the exit code and
|
|
435
|
+
* whether anything changed — an unchanged file means there is nothing to
|
|
436
|
+
* restart for.
|
|
437
|
+
*/
|
|
438
|
+
async function writeProfile({ path, entry, target, dryRun, ask }) {
|
|
439
|
+
detail(`file ${path}`);
|
|
440
|
+
|
|
441
|
+
let text = null;
|
|
442
|
+
try {
|
|
443
|
+
text = readFileSync(path, 'utf8');
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (e.code !== 'ENOENT') {
|
|
446
|
+
console.error(c.red(`\n Cannot read ${path}`));
|
|
447
|
+
console.error(c.grey(` ${e.message}\n`));
|
|
448
|
+
return { code: 1, changed: false };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (text === null) {
|
|
453
|
+
const dir = dirname(path);
|
|
454
|
+
if (!existsSync(dir)) {
|
|
455
|
+
// Kronk creates this directory on its first run. Its absence is a
|
|
456
|
+
// different problem from a missing profile, so do not paper over it.
|
|
457
|
+
console.error(c.red(`\n ${dir} does not exist, so Kronk has never run here.`));
|
|
458
|
+
console.error(c.grey(' Start it once first: kronk server start --detach'));
|
|
459
|
+
console.error(c.grey(' Setup will not create Kronk\'s data directory for it.\n'));
|
|
460
|
+
return { code: 1, changed: false };
|
|
461
|
+
}
|
|
462
|
+
return commit({ path, next: newConfig(entry), shown: ['models:', ...entry], dryRun, ask, made: 'created' });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const scan = scanConfig(text, target);
|
|
466
|
+
|
|
467
|
+
if (scan.kind === 'present') {
|
|
468
|
+
detail(`${target} is already a profile in this file — leaving it alone`);
|
|
469
|
+
return { code: 0, changed: false };
|
|
470
|
+
}
|
|
471
|
+
if (scan.kind === 'refuse') {
|
|
472
|
+
console.error(c.red(`\n Refusing to edit ${path}:`));
|
|
473
|
+
console.error(c.grey(` ${scan.reason}, so there is no unambiguous place to add the profile.`));
|
|
474
|
+
console.error(c.grey(' Nothing was written. Add this to the right "models:" section by hand:'));
|
|
475
|
+
printBlock(entry);
|
|
476
|
+
|
|
477
|
+
return { code: 1, changed: false };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const shown = scan.kind === 'append' ? ['models:', ...entry] : entry;
|
|
481
|
+
return commit({ path, next: applyEntry(text, scan, entry), shown, dryRun, ask, made: 'updated' });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function commit({ path, next, shown, dryRun, ask, made }) {
|
|
485
|
+
note(`This block will be ${made === 'created' ? 'written' : 'added'}:`);
|
|
486
|
+
printBlock(shown);
|
|
487
|
+
|
|
488
|
+
if (dryRun) {
|
|
489
|
+
cmd(`would ${made === 'created' ? 'create' : 'update'}: ${path}`);
|
|
490
|
+
cmd(`would back up first: ${path}.bak…`);
|
|
491
|
+
return { code: 0, changed: true };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!await ask(`${made === 'created' ? 'Create' : 'Update'} ${path}? [y/N]`)) {
|
|
495
|
+
note('Declined. Nothing was written — paste the block above by hand if you prefer.');
|
|
496
|
+
return { code: 0, changed: false, declined: true };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (made === 'created') {
|
|
500
|
+
detail('no backup needed — the file does not exist yet');
|
|
501
|
+
} else {
|
|
502
|
+
try {
|
|
503
|
+
detail(`backup ${backupFile(path)}`);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
console.error(c.red(`\n Cannot back up ${path} — refusing to write without one.`));
|
|
506
|
+
console.error(c.grey(` ${e.message}\n`));
|
|
507
|
+
return { code: 1, changed: false };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
writeFileSync(path, next, 'utf8');
|
|
513
|
+
} catch (e) {
|
|
514
|
+
console.error(c.red(`\n Cannot write ${path}`));
|
|
515
|
+
console.error(c.grey(` ${e.message}\n`));
|
|
516
|
+
return { code: 1, changed: false };
|
|
517
|
+
}
|
|
518
|
+
detail(`${made} ${path}`);
|
|
519
|
+
return { code: 0, changed: true };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Poll /models until the restarted server answers, or give up and say so. */
|
|
523
|
+
async function waitForServer() {
|
|
524
|
+
const deadline = Date.now() + RESTART_TIMEOUT_MS;
|
|
525
|
+
for (;;) {
|
|
526
|
+
try {
|
|
527
|
+
await listModels();
|
|
528
|
+
return true;
|
|
529
|
+
} catch {
|
|
530
|
+
// still down
|
|
531
|
+
}
|
|
532
|
+
if (Date.now() >= deadline) return false;
|
|
533
|
+
await sleep(RESTART_POLL_MS);
|
|
534
|
+
}
|
|
535
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
|
|
|
5
5
|
import { realpathSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { homedir, tmpdir } from 'node:os';
|
|
7
7
|
import { detectBackend, sandboxArgv, cacheDirs } from './sandbox.js';
|
|
8
|
+
import { setPlan, MAX_ITEMS } from './plan.js';
|
|
8
9
|
import { c } from './ui.js';
|
|
9
10
|
|
|
10
11
|
const exec = promisify(execFile);
|
|
@@ -101,6 +102,29 @@ export const TOOLS = [
|
|
|
101
102
|
|
|
102
103
|
def('bash', 'Run a shell command in the working directory. Requires user approval.',
|
|
103
104
|
{ cmd: { type: 'string' } }, ['cmd']),
|
|
105
|
+
|
|
106
|
+
// The description is the only instruction a small model reliably reads, so it
|
|
107
|
+
// carries the whole protocol: call it first, one item per criterion, resend
|
|
108
|
+
// the entire list every time.
|
|
109
|
+
def('set_plan',
|
|
110
|
+
'Record the checklist for the current task and keep it updated. Call this first, with one '
|
|
111
|
+
+ 'item per acceptance criterion in the request. Call it again after each item is finished. '
|
|
112
|
+
+ 'The list you send replaces the stored one, so always send every item.',
|
|
113
|
+
{
|
|
114
|
+
items: {
|
|
115
|
+
type: 'array',
|
|
116
|
+
description: `The whole checklist, in order. At most ${MAX_ITEMS} items.`,
|
|
117
|
+
items: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
text: { type: 'string', description: 'The requirement, in the words of the request.' },
|
|
121
|
+
status: { type: 'string', enum: ['todo', 'doing', 'done'] },
|
|
122
|
+
},
|
|
123
|
+
required: ['text'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
['items']),
|
|
104
128
|
];
|
|
105
129
|
|
|
106
130
|
/** Tools that mutate state or run arbitrary code must be confirmed. */
|
|
@@ -135,18 +159,40 @@ export function describe(name, args) {
|
|
|
135
159
|
case 'list_dir': return `ls ${args.path ?? '.'}`;
|
|
136
160
|
case 'search': return `search /${args.pattern}/ in ${args.path ?? '.'}`;
|
|
137
161
|
case 'bash': return `bash: ${args.cmd}`;
|
|
162
|
+
case 'set_plan': return `plan: ${args.items?.length ?? 0} items`;
|
|
138
163
|
default: return `${name}(${JSON.stringify(args)})`;
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
|
|
142
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Strip the cwd marker off command output and report where we ended up.
|
|
169
|
+
*
|
|
170
|
+
* A command that walks out of the launch root does not move the session, but
|
|
171
|
+
* the caller still has to say where it ran: reporting `session.cwd` for a
|
|
172
|
+
* command that ran somewhere else handed the model a flat contradiction —
|
|
173
|
+
* "you are in the project" next to "this is not a git repository" — and it
|
|
174
|
+
* resolved it by going looking for the project elsewhere.
|
|
175
|
+
*
|
|
176
|
+
* `pwd` is null when the marker never printed, which is what `exec` does.
|
|
177
|
+
*/
|
|
143
178
|
function applyCwd(out, mark) {
|
|
144
179
|
const i = out.lastIndexOf(mark);
|
|
145
|
-
if (i === -1) return out;
|
|
180
|
+
if (i === -1) return { body: out, pwd: null, escaped: false };
|
|
181
|
+
const body = out.slice(0, i).replace(/\n$/, '');
|
|
146
182
|
const next = real(out.slice(i + mark.length).trim());
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
183
|
+
if (!next) return { body, pwd: null, escaped: false };
|
|
184
|
+
const escaped = relative(real(session.root), next).startsWith('..');
|
|
185
|
+
if (!escaped) session.cwd = next;
|
|
186
|
+
return { body, pwd: next, escaped };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The directory a command ran in, and a warning when that was outside the root. */
|
|
190
|
+
function whereLines(pwd, escaped) {
|
|
191
|
+
const ran = pwd ? `ran in: ${pwd}` : `ran in: ${session.cwd} (final directory unknown)`;
|
|
192
|
+
if (!escaped) return [ran];
|
|
193
|
+
return [ran, `note: this command left the launch root ${session.root} and the session `
|
|
194
|
+
+ 'directory is unchanged. Work inside the root; paths above it are outside this '
|
|
195
|
+
+ "agent's scope."];
|
|
150
196
|
}
|
|
151
197
|
|
|
152
198
|
const MARK = '__KRONK_CWD__';
|
|
@@ -214,10 +260,13 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
214
260
|
// Own process group: killing bash alone leaves its children running and
|
|
215
261
|
// holding the stdout pipe open, so `close` would not fire until they
|
|
216
262
|
// finished anyway — a 5s timeout that returned after 30s.
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
263
|
+
// Print the marker from an EXIT trap rather than appending it after the
|
|
264
|
+
// command. A command ending in `exit 1` never reaches an appended line, so
|
|
265
|
+
// the shell's final directory was unobservable for exactly the failures
|
|
266
|
+
// that most need reporting. The trap fires whatever route the shell takes
|
|
267
|
+
// out, and bash exits with the status in effect when it ran — so, unlike an
|
|
268
|
+
// appended `printf`, it cannot make a failure look successful.
|
|
269
|
+
const script = `__kronk_mark() { printf '\\n${MARK}%s' "$(pwd)"; }\ntrap __kronk_mark EXIT\n${cmd}`;
|
|
221
270
|
|
|
222
271
|
const backend = resolveSandbox();
|
|
223
272
|
if (backend === 'none' && (process.env.KRONK_SANDBOX ?? 'auto') === 'strict') {
|
|
@@ -273,13 +322,17 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
273
322
|
|
|
274
323
|
child.on('error', (e) => {
|
|
275
324
|
clearTimeout(timer);
|
|
325
|
+
// `cwd:`, not `ran in:` — this fires before any output exists, so nothing
|
|
326
|
+
// ever ran and there is no final directory to report. Naming one would be
|
|
327
|
+
// a worse lie than the stale cwd this file otherwise stopped printing.
|
|
276
328
|
resolve(`error: could not start command — ${e.message}\ncwd: ${session.cwd}`);
|
|
277
329
|
});
|
|
278
330
|
|
|
279
331
|
child.on('close', (code, signal) => {
|
|
280
332
|
clearTimeout(timer);
|
|
281
333
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
282
|
-
const body = applyCwd(out, MARK);
|
|
334
|
+
const { body, pwd, escaped } = applyCwd(out, MARK);
|
|
335
|
+
const where = whereLines(pwd, escaped);
|
|
283
336
|
const tail = [
|
|
284
337
|
body.trim() && `stdout:\n${body.trim()}`,
|
|
285
338
|
err.trim() && `stderr:\n${err.trim()}`,
|
|
@@ -289,19 +342,23 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
289
342
|
return resolve(clip([
|
|
290
343
|
`error: killed after ${secs}s (timeout ${Math.round(timeoutMs / 1000)}s).`,
|
|
291
344
|
'The command may simply be slow — re-run a narrower scope, or raise KRONK_TOOL_TIMEOUT.',
|
|
292
|
-
|
|
345
|
+
...where,
|
|
293
346
|
tail || '(no output before it was killed)',
|
|
294
347
|
].join('\n')));
|
|
295
348
|
}
|
|
296
349
|
if (code === 0) {
|
|
297
350
|
const okBody = clip(body + err);
|
|
298
|
-
|
|
351
|
+
const text = `${okBody.trim() || '(no output)'}${truncated ? '\n[earlier output dropped]' : ''}`;
|
|
352
|
+
// Prepended, and outside clip(): a command that succeeded on the way out
|
|
353
|
+
// of the root is exactly how a model talks itself into believing the
|
|
354
|
+
// project lives somewhere else, so the warning leads and cannot be elided.
|
|
355
|
+
return resolve(escaped ? `${where.join('\n')}\n${text}` : text);
|
|
299
356
|
}
|
|
300
357
|
return resolve(clip([
|
|
301
358
|
signal
|
|
302
359
|
? `error: killed by ${signal} after ${secs}s`
|
|
303
360
|
: `error: exit code ${code} after ${secs}s`,
|
|
304
|
-
|
|
361
|
+
...where,
|
|
305
362
|
tail || '(no output)',
|
|
306
363
|
truncated ? '[earlier output dropped]' : '',
|
|
307
364
|
].filter(Boolean).join('\n')));
|
|
@@ -348,6 +405,9 @@ export async function runTool(name, args, opts = {}) {
|
|
|
348
405
|
case 'bash':
|
|
349
406
|
return runBash(args.cmd, opts);
|
|
350
407
|
|
|
408
|
+
case 'set_plan':
|
|
409
|
+
return setPlan(args.items);
|
|
410
|
+
|
|
351
411
|
default:
|
|
352
412
|
return `error: unknown tool ${name}`;
|
|
353
413
|
}
|