@clutor/cli 0.0.0-stage → 0.1.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,2163 @@
1
+ import {
2
+ CONFIG_FILE,
3
+ ConfigError,
4
+ DEFAULT_PORT,
5
+ formatGates,
6
+ formatPlan,
7
+ formatSimulation,
8
+ loadConfig,
9
+ pct,
10
+ table
11
+ } from "./chunk-U5GHVJJO.js";
12
+
13
+ // src/args.ts
14
+ function parseArgs(argv, booleans = []) {
15
+ const flags = /* @__PURE__ */ new Map();
16
+ const positional = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === "--") {
20
+ positional.push(...argv.slice(i + 1));
21
+ break;
22
+ }
23
+ if (a.startsWith("--")) {
24
+ const eq = a.indexOf("=");
25
+ const name = eq === -1 ? a.slice(2) : a.slice(2, eq);
26
+ if (eq !== -1) flags.set(name, a.slice(eq + 1));
27
+ else if (booleans.includes(name) || i + 1 >= argv.length || argv[i + 1].startsWith("--")) flags.set(name, true);
28
+ else flags.set(name, argv[++i]);
29
+ } else positional.push(a);
30
+ }
31
+ return { positional, flags };
32
+ }
33
+ var flagString = (a, name) => {
34
+ const v = a.flags.get(name);
35
+ return typeof v === "string" ? v : void 0;
36
+ };
37
+ var flagBool = (a, name) => a.flags.has(name) && a.flags.get(name) !== "false";
38
+ function flagInt(a, name, min, max) {
39
+ const v = flagString(a, name);
40
+ if (v === void 0) return void 0;
41
+ const n = Number(v);
42
+ if (!Number.isInteger(n) || n < min || n > max) throw new UsageError(`--${name} must be an integer from ${min} to ${max}`);
43
+ return n;
44
+ }
45
+ var UsageError = class extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = "UsageError";
49
+ }
50
+ };
51
+
52
+ // src/decide.ts
53
+ import { createHash } from "crypto";
54
+ import { sealReceipt } from "@clutor/core";
55
+ import { appendReceipt, lastReceiptTag, loadOrCreateReceiptSigner } from "@clutor/core/node";
56
+ var DecideError = class extends Error {
57
+ constructor(status2, message) {
58
+ super(message);
59
+ this.status = status2;
60
+ }
61
+ status;
62
+ };
63
+ async function runDecide(e, prompt, choices, f = globalThis.fetch) {
64
+ if (!prompt || choices.length < 2 || choices.length > 26) throw new DecideError(400, "a prompt and 2 to 26 choices are required");
65
+ if (new Set(choices).size !== choices.length) throw new DecideError(400, "choices must be different from each other");
66
+ const letters = choices.map((_, i) => String.fromCharCode(65 + i));
67
+ const content = `${prompt}
68
+
69
+ Choose exactly one option and answer with its letter only.
70
+ ${choices.map((c, i) => `${letters[i]}) ${c}`).join("\n")}
71
+ Answer:`;
72
+ const t0 = Date.now();
73
+ const res = await f(`${e.baseUrl.replace(/\/$/, "")}/chat/completions`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json", ...e.apiKey ? { authorization: `Bearer ${e.apiKey}` } : {} },
76
+ body: JSON.stringify({ model: e.model, messages: [{ role: "user", content }], max_tokens: 1, temperature: 0, logprobs: true, top_logprobs: 20 })
77
+ });
78
+ if (!res.ok) throw new DecideError(502, `the model server answered ${res.status}: ${(await res.text().catch(() => "")).slice(0, 300)}`);
79
+ const body = await res.json();
80
+ const top = body.choices?.[0]?.logprobs?.content?.[0]?.top_logprobs;
81
+ if (!top) throw new DecideError(502, "the model server returned no log-probabilities; decide needs a server that supports logprobs (llama-server does)");
82
+ const mass = letters.map((l) => top.filter((t) => t.token.trim() === l).reduce((a, t) => a + Math.exp(t.logprob), 0));
83
+ const total = mass.reduce((a, b) => a + b, 0);
84
+ if (total === 0) throw new DecideError(502, "the model did not put any probability on the choice letters; try rephrasing the prompt");
85
+ const probs = mass.map((m) => m / total);
86
+ const best = probs.indexOf(Math.max(...probs));
87
+ return { choice: choices[best], probabilities: Object.fromEntries(choices.map((c, i) => [c, probs[i]])), probabilityMassOnChoices: total, latencyMs: Date.now() - t0 };
88
+ }
89
+ async function sealDecision(root, model, prompt, choices, r, startedAt) {
90
+ const signer = await loadOrCreateReceiptSigner(root);
91
+ const promptSha = createHash("sha256").update(prompt).digest("hex");
92
+ const receipt = await sealReceipt(signer, {
93
+ v: 2,
94
+ id: `dec_${Date.now().toString(36)}`,
95
+ task: `decide sha256:${promptSha} among [${choices.join("|")}] -> ${r.choice}`,
96
+ model,
97
+ startedAt,
98
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
99
+ status: "finished",
100
+ files: [],
101
+ commands: [],
102
+ steps: 1,
103
+ previous: await lastReceiptTag(root)
104
+ });
105
+ await appendReceipt(root, receipt);
106
+ return receipt;
107
+ }
108
+
109
+ // src/backend.ts
110
+ import { spawn } from "child_process";
111
+ import { accessSync, constants } from "fs";
112
+ import { homedir } from "os";
113
+ import { delimiter, join } from "path";
114
+ import { createHash as createHash2 } from "crypto";
115
+ import { TASK_MARKER } from "@clutor/core";
116
+ var exe = process.platform === "win32" ? "llama-server.exe" : "llama-server";
117
+ function findLlamaServer(env = process.env, extraDirs = []) {
118
+ const dirs = [...extraDirs, ...(env["PATH"] ?? "").split(delimiter), "/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".clutor", "bin")];
119
+ for (const d of dirs) {
120
+ if (!d) continue;
121
+ const p = join(d, exe);
122
+ try {
123
+ accessSync(p, constants.X_OK);
124
+ return p;
125
+ } catch {
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ function probeLlamaServer(binary, timeoutMs = 15e3) {
131
+ return new Promise((resolve3, reject) => {
132
+ const child = spawn(binary, ["--help"], { stdio: ["ignore", "pipe", "pipe"] });
133
+ let out = "";
134
+ const timer = setTimeout(() => {
135
+ child.kill("SIGKILL");
136
+ reject(new Error(`${binary} --help did not finish in ${timeoutMs / 1e3}s`));
137
+ }, timeoutMs);
138
+ child.stdout?.on("data", (b) => out += b.toString());
139
+ child.stderr?.on("data", (b) => out += b.toString());
140
+ child.on("error", (e) => {
141
+ clearTimeout(timer);
142
+ reject(e);
143
+ });
144
+ child.on("close", () => {
145
+ clearTimeout(timer);
146
+ resolve3({ flags: new Set(out.match(/--[a-z0-9][a-z0-9-]*/g) ?? []), flashAttnTakesValue: /--flash-attn[^\n]*\[on\|off\|auto\]|--flash-attn\s+\[?on/.test(out), helpText: out });
147
+ });
148
+ });
149
+ }
150
+ function buildLlamaArgs(i) {
151
+ const notes = [];
152
+ const args = ["-m", i.modelPath, "--host", "127.0.0.1", "--port", String(i.port), "-c", String(i.contextTokens)];
153
+ if (i.threads) args.push("-t", String(i.threads));
154
+ if (i.caps.flags.has("--jinja")) args.push("--jinja");
155
+ else notes.push('this llama-server has no --jinja, so native tool calling may not work; use agent.toolMode "json"');
156
+ if (i.kvBits < 16) {
157
+ const t = i.kvBits === 8 ? "q8_0" : "q4_0";
158
+ if (i.caps.flags.has("--cache-type-k") && i.caps.flags.has("--cache-type-v")) {
159
+ args.push("--cache-type-k", t, "--cache-type-v", t);
160
+ if (i.caps.flags.has("--flash-attn")) args.push(...i.caps.flashAttnTakesValue ? ["--flash-attn", "on"] : ["--flash-attn"]);
161
+ else notes.push("quantised KV cache needs flash attention, which this build lacks; the server may refuse to start");
162
+ } else notes.push("this llama-server cannot quantise the KV cache; the plan's KV size is too small");
163
+ }
164
+ if (i.plan?.strategy === "fully-resident" && i.caps.flags.has("--mlock")) args.push("--mlock");
165
+ if (i.plan && i.plan.strategy !== "fully-resident") notes.push("the model is larger than the memory budget, so it will be paged in from disk by the operating system (mmap): speed depends on your SSD and on how often the same experts are reused");
166
+ if (i.slotSavePath) {
167
+ if (i.caps.flags.has("--slot-save-path")) args.push("--slot-save-path", i.slotSavePath);
168
+ else notes.push("this llama-server has no --slot-save-path, so the project prefix cannot be saved between runs");
169
+ }
170
+ args.push(...i.extraArgs ?? []);
171
+ return { args, notes };
172
+ }
173
+ var BackendManager = class {
174
+ constructor(port, fetchFn = globalThis.fetch) {
175
+ this.port = port;
176
+ this.fetchFn = fetchFn;
177
+ }
178
+ port;
179
+ fetchFn;
180
+ state = "stopped";
181
+ error = null;
182
+ child = null;
183
+ log = [];
184
+ listeners = /* @__PURE__ */ new Set();
185
+ get baseUrl() {
186
+ return `http://127.0.0.1:${this.port}/v1`;
187
+ }
188
+ get recentLog() {
189
+ return this.log.slice(-200);
190
+ }
191
+ onState(f) {
192
+ this.listeners.add(f);
193
+ return () => this.listeners.delete(f);
194
+ }
195
+ set(s, err) {
196
+ this.state = s;
197
+ this.error = err ?? null;
198
+ for (const l of this.listeners) l(s);
199
+ }
200
+ async start(binary, args, opts = {}) {
201
+ if (this.child) throw new Error("the backend is already running");
202
+ this.log.length = 0;
203
+ this.set("starting");
204
+ const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"], env: opts.env ?? process.env });
205
+ this.child = child;
206
+ const take = (b) => {
207
+ for (const l of b.toString().split("\n")) if (l.trim()) {
208
+ this.log.push(l);
209
+ if (this.log.length > 1e3) this.log.shift();
210
+ }
211
+ };
212
+ child.stdout?.on("data", take);
213
+ child.stderr?.on("data", take);
214
+ child.on("error", (e) => {
215
+ this.child = null;
216
+ this.set("failed", `could not start ${binary}: ${e.message}`);
217
+ });
218
+ child.on("exit", (code2, sig) => {
219
+ this.child = null;
220
+ if (this.state !== "stopped") this.set("failed", `llama-server exited (${sig ?? `code ${code2}`}); last output: ${this.log.slice(-3).join(" | ")}`);
221
+ });
222
+ const deadline = Date.now() + (opts.readyTimeoutMs ?? 6e5);
223
+ while (Date.now() < deadline) {
224
+ if (this.state === "failed") throw new Error(this.error ?? "the backend failed to start");
225
+ try {
226
+ const r = await this.fetchFn(`http://127.0.0.1:${this.port}/health`);
227
+ if (r.ok) {
228
+ this.set("ready");
229
+ return;
230
+ }
231
+ } catch {
232
+ }
233
+ await new Promise((r) => setTimeout(r, 250));
234
+ }
235
+ await this.stop();
236
+ this.set("failed", "the model did not finish loading in time");
237
+ throw new Error(this.error ?? "timeout");
238
+ }
239
+ async stop() {
240
+ const c = this.child;
241
+ this.set("stopped");
242
+ if (!c) return;
243
+ await new Promise((resolve3) => {
244
+ const kill = setTimeout(() => c.kill("SIGKILL"), 5e3);
245
+ c.once("exit", () => {
246
+ clearTimeout(kill);
247
+ resolve3();
248
+ });
249
+ c.kill("SIGTERM");
250
+ });
251
+ this.child = null;
252
+ }
253
+ };
254
+ var SlotSnapshots = class {
255
+ constructor(baseUrl, slot = 0, fetchFn = globalThis.fetch) {
256
+ this.baseUrl = baseUrl;
257
+ this.slot = slot;
258
+ this.fetchFn = fetchFn;
259
+ }
260
+ baseUrl;
261
+ slot;
262
+ fetchFn;
263
+ stats = { restored: 0, saved: 0, restoreMisses: 0, failures: 0 };
264
+ url(action) {
265
+ return `${this.baseUrl.replace(/\/v1\/?$/, "")}/slots/${this.slot}?action=${action}`;
266
+ }
267
+ async post(action, filename) {
268
+ try {
269
+ return await this.fetchFn(this.url(action), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filename }), signal: AbortSignal.timeout(6e4) });
270
+ } catch {
271
+ this.stats.failures++;
272
+ return void 0;
273
+ }
274
+ }
275
+ async restore(name) {
276
+ const r = await this.post("restore", `${name}.bin`);
277
+ if (r?.ok) {
278
+ this.stats.restored++;
279
+ return true;
280
+ }
281
+ if (r) {
282
+ r.body?.cancel().catch(() => void 0);
283
+ this.stats.restoreMisses++;
284
+ }
285
+ return false;
286
+ }
287
+ async save(name) {
288
+ const r = await this.post("save", `${name}.bin`);
289
+ if (r?.ok) {
290
+ this.stats.saved++;
291
+ return true;
292
+ }
293
+ if (r) {
294
+ this.stats.failures++;
295
+ r.body?.cancel().catch(() => void 0);
296
+ }
297
+ return false;
298
+ }
299
+ };
300
+ function withPrefixSnapshots(inner, snaps) {
301
+ let done = false;
302
+ const wrapped = {
303
+ snapshots: snaps,
304
+ model: inner.model,
305
+ toolMode: inner.toolMode,
306
+ async chat(messages, opts) {
307
+ if (done) return inner.chat(messages, opts);
308
+ done = true;
309
+ const head = messages.map((m) => m.content ?? "").join("\n\n");
310
+ const cut = head.lastIndexOf(TASK_MARKER);
311
+ const prefix = cut >= 0 ? head.slice(0, cut) : head;
312
+ const key = `clt-${createHash2("sha256").update(inner.model).update("\0").update(prefix).digest("hex").slice(0, 32)}`;
313
+ const had = await snaps.restore(key);
314
+ const out = await inner.chat(messages, opts);
315
+ if (!had) await snaps.save(key);
316
+ return out;
317
+ }
318
+ };
319
+ return wrapped;
320
+ }
321
+
322
+ // src/models.ts
323
+ import { createWriteStream } from "fs";
324
+ import { mkdir, readdir, rename, rm, stat, statfs } from "fs/promises";
325
+ import { Readable } from "stream";
326
+ import { pipeline } from "stream/promises";
327
+ import { basename, extname, isAbsolute, join as join2, resolve, sep } from "path";
328
+ import { CATALOG, QUANTS, approximateLayout, buildModelLayout, parseGguf } from "@clutor/core";
329
+ import { fileReader } from "@clutor/core/node";
330
+ var ModelError = class extends Error {
331
+ constructor(message) {
332
+ super(message);
333
+ this.name = "ModelError";
334
+ }
335
+ };
336
+ async function resolveModel(ref, opts = {}) {
337
+ const [idPart, quantPart] = ref.split("@");
338
+ const spec = CATALOG.find((s) => s.id === idPart);
339
+ if (spec && !ref.toLowerCase().endsWith(".gguf")) {
340
+ const quant = quantPart ?? "Q4_K_M";
341
+ const bpw = QUANTS[quant];
342
+ if (bpw === void 0) throw new ModelError(`unknown quantisation "${quant}"; choose one of ${Object.keys(QUANTS).join(", ")}`);
343
+ return { id: `${spec.id}@${quant}`, name: spec.name, source: "catalog", layout: approximateLayout(spec, bpw), quant };
344
+ }
345
+ if (!ref.toLowerCase().endsWith(".gguf")) throw new ModelError(`unknown model "${ref}"; use one of ${CATALOG.map((s) => s.id).join(", ")}, or the path of a .gguf file`);
346
+ if (opts.allowFiles === false) throw new ModelError("reading a model file needs the access token");
347
+ const path = isAbsolute(ref) ? ref : resolve(opts.modelsDir ?? ".", ref);
348
+ try {
349
+ await stat(path);
350
+ } catch {
351
+ throw new ModelError(`${path} does not exist`);
352
+ }
353
+ const r = await fileReader(path);
354
+ try {
355
+ const gguf = await parseGguf(r);
356
+ const layout = buildModelLayout(gguf);
357
+ const dominant = Object.entries(layout.quantisations).sort((a, b) => b[1] - a[1])[0]?.[0];
358
+ return { id: basename(path), name: layout.name || basename(path), source: "file", path, layout, gguf, ...dominant ? { quant: dominant } : {} };
359
+ } finally {
360
+ await r.close();
361
+ }
362
+ }
363
+ async function listLocalModels(dir) {
364
+ const out = [];
365
+ const walk = async (d, depth) => {
366
+ let entries;
367
+ try {
368
+ entries = await readdir(d, { withFileTypes: true });
369
+ } catch {
370
+ return;
371
+ }
372
+ for (const e of entries) {
373
+ const p = join2(d, e.name);
374
+ if (e.isDirectory() && depth < 3) await walk(p, depth + 1);
375
+ else if (e.isFile() && extname(e.name).toLowerCase() === ".gguf") out.push({ file: p.slice(resolve(dir).length + 1).split(sep).join("/"), path: p, bytes: (await stat(p)).size });
376
+ }
377
+ };
378
+ await walk(resolve(dir), 0);
379
+ return out.sort((a, b) => a.file.localeCompare(b.file));
380
+ }
381
+ var GiB_ = 2 ** 30;
382
+ var DOWNLOADS = [
383
+ { id: "olmoe-1b-7b-q4_k_m", catalogId: "olmoe-1b-7b", repo: "bartowski/OLMoE-1B-7B-0924-Instruct-GGUF", file: "OLMoE-1B-7B-0924-Instruct-Q4_K_M.gguf", bytes: Math.round(3.92 * GiB_), note: "a real mixture-of-experts model (64 experts, 8 used): the one to test expert streaming with" },
384
+ { id: "qwen2.5-coder-7b-q4_k_m", catalogId: "qwen2.5-coder-7b", repo: "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF", file: "qwen2.5-coder-7b-instruct-q4_k_m.gguf", bytes: Math.round(4.36 * GiB_), note: "a dense coding model for the agent (not a mixture of experts)" }
385
+ ];
386
+ var downloadUrl = (d) => `https://huggingface.co/${d.repo}/resolve/main/${d.file}`;
387
+ var DownloadError = class extends Error {
388
+ constructor(message) {
389
+ super(message);
390
+ this.name = "DownloadError";
391
+ }
392
+ };
393
+ async function downloadFile(url, dest, opts = {}) {
394
+ const f = opts.fetch ?? globalThis.fetch;
395
+ await mkdir(resolve(dest, ".."), { recursive: true });
396
+ const res = await f(url, { redirect: "follow" });
397
+ if (!res.ok || !res.body) throw new DownloadError(`the server answered ${res.status} for ${url}`);
398
+ const total = Number(res.headers.get("content-length") ?? 0);
399
+ if (!total) throw new DownloadError("the server did not say how large the file is, so it cannot be checked");
400
+ if (opts.expectedBytes && Math.abs(total - opts.expectedBytes) / opts.expectedBytes > 0.05) throw new DownloadError(`expected about ${opts.expectedBytes} bytes but the server offers ${total}; not downloading`);
401
+ const fs = await statfs(resolve(dest, ".."));
402
+ const free = Number(fs.bavail) * Number(fs.bsize);
403
+ if (free < total * 1.1) throw new DownloadError(`not enough free disk: need ${(total / GiB_).toFixed(2)} GiB, have ${(free / GiB_).toFixed(2)} GiB`);
404
+ const part = `${dest}.part`;
405
+ let done = 0;
406
+ const body = Readable.fromWeb(res.body);
407
+ body.on("data", (c) => {
408
+ done += c.length;
409
+ opts.onProgress?.(done, total);
410
+ });
411
+ try {
412
+ await pipeline(body, createWriteStream(part));
413
+ if (done !== total) throw new DownloadError(`the download stopped at ${done} of ${total} bytes`);
414
+ await rename(part, dest);
415
+ } catch (e) {
416
+ await rm(part, { force: true });
417
+ throw e;
418
+ }
419
+ return total;
420
+ }
421
+
422
+ // src/sse.ts
423
+ async function* parseSse(body) {
424
+ const reader = body.getReader();
425
+ const dec = new TextDecoder();
426
+ let buf = "";
427
+ try {
428
+ for (; ; ) {
429
+ const { value, done } = await reader.read();
430
+ if (done) break;
431
+ buf += dec.decode(value, { stream: true });
432
+ let i;
433
+ while ((i = buf.search(/\r?\n\r?\n/)) >= 0) {
434
+ const block = buf.slice(0, i);
435
+ buf = buf.slice(i).replace(/^\r?\n\r?\n/, "");
436
+ const data = block.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
437
+ if (data) yield data;
438
+ }
439
+ }
440
+ const rest = buf.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
441
+ if (rest) yield rest;
442
+ } finally {
443
+ reader.releaseLock();
444
+ }
445
+ }
446
+
447
+ // src/ollama.ts
448
+ function toOpenAiBody(req, model, kind) {
449
+ const messages = kind === "generate" ? [...req.system ? [{ role: "system", content: req.system }] : [], { role: "user", content: req.prompt ?? "" }] : req.messages ?? [];
450
+ const o = req.options ?? {};
451
+ return {
452
+ model,
453
+ messages,
454
+ stream: req.stream !== false,
455
+ ...o.temperature !== void 0 ? { temperature: o.temperature } : {},
456
+ ...o.top_p !== void 0 ? { top_p: o.top_p } : {},
457
+ ...o.num_predict !== void 0 && o.num_predict > 0 ? { max_tokens: o.num_predict } : {},
458
+ ...o.stop ? { stop: o.stop } : {}
459
+ };
460
+ }
461
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
462
+ var piece = (kind, model, text, done, extra = {}) => kind === "chat" ? { model, created_at: now(), message: { role: "assistant", content: text }, done, ...extra } : { model, created_at: now(), response: text, done, ...extra };
463
+ function fromOpenAiReply(kind, model, body) {
464
+ const text = body.choices?.[0]?.message?.content ?? "";
465
+ return piece(kind, model, text, true, { done_reason: body.choices?.[0]?.finish_reason ?? "stop", prompt_eval_count: body.usage?.prompt_tokens ?? 0, eval_count: body.usage?.completion_tokens ?? 0 });
466
+ }
467
+ async function* streamToOllama(kind, model, body) {
468
+ let usage;
469
+ let reason = "stop";
470
+ for await (const data of parseSse(body)) {
471
+ if (data.trim() === "[DONE]") break;
472
+ let j;
473
+ try {
474
+ j = JSON.parse(data);
475
+ } catch {
476
+ continue;
477
+ }
478
+ if (j.usage) usage = j.usage;
479
+ const c = j.choices?.[0];
480
+ if (c?.finish_reason) reason = c.finish_reason;
481
+ const text = c?.delta?.content;
482
+ if (text) yield `${JSON.stringify(piece(kind, model, text, false))}
483
+ `;
484
+ }
485
+ yield `${JSON.stringify(piece(kind, model, "", true, { done_reason: reason, prompt_eval_count: usage?.prompt_tokens ?? 0, eval_count: usage?.completion_tokens ?? 0 }))}
486
+ `;
487
+ }
488
+
489
+ // src/agentllm.ts
490
+ import { mkdirSync } from "fs";
491
+ import { homedir as homedir2 } from "os";
492
+ import { join as join3 } from "path";
493
+ import { OpenAiCompatibleClient, toolCallJsonSchema } from "@clutor/core";
494
+ var slotDir = () => join3(homedir2(), ".clutor", "slots");
495
+ function ensureSlotDir() {
496
+ const dir = slotDir();
497
+ mkdirSync(dir, { recursive: true, mode: 448 });
498
+ return dir;
499
+ }
500
+ function agentLlm(config, ep, fetchFn) {
501
+ const json = config.agent.toolMode === "json";
502
+ const client = new OpenAiCompatibleClient({
503
+ baseUrl: ep.baseUrl,
504
+ model: ep.model,
505
+ ...ep.apiKey ? { apiKey: ep.apiKey } : {},
506
+ toolMode: config.agent.toolMode,
507
+ ...json && config.agent.constrainToolCalls ? { jsonSchema: toolCallJsonSchema() } : {},
508
+ ...ep.local ? { slot: 0 } : {},
509
+ ...fetchFn ? { fetch: fetchFn } : {}
510
+ });
511
+ if (ep.local && config.agent.prefixSnapshots) return withPrefixSnapshots(client, new SlotSnapshots(ep.baseUrl, 0, fetchFn));
512
+ return client;
513
+ }
514
+
515
+ // src/mcp.ts
516
+ import { CATALOG as CATALOG2, GiB, QUANTS as QUANTS2, ShadowWorkspace, buildRepoMap, fmtGiB, planMemory, TOOLS, executeTool, validateToolCall } from "@clutor/core";
517
+ import { NodeProject, hostInfo } from "@clutor/core/node";
518
+ var READ_TOOLS = ["list_files", "read_file", "search", "repo_map"];
519
+ function tools() {
520
+ const fromAgent = TOOLS.filter((t) => READ_TOOLS.includes(t.name)).map((t) => ({
521
+ name: t.name,
522
+ description: t.description,
523
+ inputSchema: {
524
+ type: "object",
525
+ additionalProperties: false,
526
+ properties: { dir: { type: "string", description: "Project folder (must be one Clutor has been given access to)." }, ...Object.fromEntries(Object.entries(t.fields).filter(([k]) => !(t.name === "list_files" && k === "dir") && !(t.name === "search" && k === "dir")).map(([k, f]) => [k, { type: f.type, description: f.description }])), ...t.name === "list_files" || t.name === "search" ? { subdir: { type: "string", description: "Only look under this folder." } } : {} },
527
+ required: ["dir", ...Object.entries(t.fields).filter(([, f]) => f.required).map(([k]) => k)]
528
+ }
529
+ }));
530
+ return [
531
+ ...fromAgent,
532
+ { name: "plan_memory", description: "Would a model fit in this much RAM, and how fast could it possibly run from disk? Models: " + CATALOG2.map((c) => c.id).join(", "), inputSchema: { type: "object", additionalProperties: false, properties: { model: { type: "string" }, ram_gib: { type: "number" }, quant: { type: "string", enum: Object.keys(QUANTS2) }, context_tokens: { type: "integer" } }, required: ["model", "ram_gib"] } }
533
+ ];
534
+ }
535
+ async function handleMcp(req, ctx) {
536
+ const id = req.id ?? null;
537
+ const ok = (result) => ({ status: 200, body: { jsonrpc: "2.0", id, result } });
538
+ const err = (code2, message) => ({ status: 200, body: { jsonrpc: "2.0", id, error: { code: code2, message } } });
539
+ if (req.jsonrpc !== "2.0" || typeof req.method !== "string") return err(-32600, "not a JSON-RPC 2.0 request");
540
+ switch (req.method) {
541
+ case "initialize":
542
+ return ok({ protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "clutor", version: "0.1.0" } });
543
+ case "notifications/initialized":
544
+ return { status: 202, body: "" };
545
+ case "ping":
546
+ return ok({});
547
+ case "tools/list":
548
+ return ok({ tools: tools() });
549
+ case "tools/call": {
550
+ const name = req.params?.["name"], args = req.params?.["arguments"] ?? {};
551
+ const text = (t, isError = false) => ok({ content: [{ type: "text", text: t }], isError });
552
+ if (name === "plan_memory") {
553
+ try {
554
+ const m = await resolveModel(`${String(args["model"])}${args["quant"] ? `@${String(args["quant"])}` : ""}`, { allowFiles: false });
555
+ const h = hostInfo();
556
+ const p = planMemory(m.layout, { hostRamBytes: Math.max(1, Number(args["ram_gib"])) * GiB, ...args["context_tokens"] ? { contextTokens: Number(args["context_tokens"]) } : {} });
557
+ return text([`${m.name} (${m.quant}) on ${fmtGiB(p.ceilingBytes)}: ${p.strategy}`, ...p.components.map((c) => `- ${c.label}: ${fmtGiB(c.bytes)}`), ...p.speed.map((s) => `at ${(s.hitRate * 100).toFixed(0)}% cache hits: at most ${s.maxTokensPerSec === Infinity ? "unlimited" : s.maxTokensPerSec.toFixed(2)} tokens/s (disk bound)`), ...p.warnings.map((w) => `! ${w}`), `(this machine has ${fmtGiB(h.totalRamBytes)})`].join("\n"));
558
+ } catch (e) {
559
+ return text(e instanceof Error ? e.message : String(e), true);
560
+ }
561
+ }
562
+ if (typeof name === "string" && READ_TOOLS.includes(name)) {
563
+ const dir = typeof args["dir"] === "string" ? await ctx.isRoot(args["dir"]) : null;
564
+ if (!dir) return text("that folder has not been opened in Clutor; open it in the app or run `clutor serve --dir <folder>`", true);
565
+ const { dir: _d, subdir, ...rest } = args;
566
+ void _d;
567
+ const call = name === "list_files" || name === "search" ? { ...rest, ...typeof subdir === "string" ? { dir: subdir } : {} } : rest;
568
+ const v = validateToolCall(name, call);
569
+ if (!v.ok) return text(v.error, true);
570
+ const r = await executeTool({ ws: new ShadowWorkspace(new NodeProject(dir)) }, v.call.name, v.call.args);
571
+ return text(r.output, !r.ok);
572
+ }
573
+ return err(-32602, `unknown tool ${JSON.stringify(name)}`);
574
+ }
575
+ default:
576
+ return err(-32601, `method not found: ${req.method}`);
577
+ }
578
+ }
579
+
580
+ // src/sessions.ts
581
+ import { randomBytes } from "crypto";
582
+ import { realpath, stat as stat2 } from "fs/promises";
583
+ import { join as join4 } from "path";
584
+ import { evaluateGates, runAgent } from "@clutor/core";
585
+ import { ConflictError, appendReceipt as appendReceipt2, applyChanges, lastReceiptTag as lastReceiptTag2, loadOrCreateReceiptSigner as loadOrCreateReceiptSigner2, rollbackCheckpoint, scratchRunner, verifyReceiptLog } from "@clutor/core/node";
586
+ var AgentSession = class {
587
+ constructor(dir, task, project, ws, cfg, hostRamBytes) {
588
+ this.dir = dir;
589
+ this.task = task;
590
+ this.project = project;
591
+ this.ws = ws;
592
+ this.cfg = cfg;
593
+ this.hostRamBytes = hostRamBytes;
594
+ }
595
+ dir;
596
+ task;
597
+ project;
598
+ ws;
599
+ cfg;
600
+ hostRamBytes;
601
+ id = `task_${randomBytes(6).toString("hex")}`;
602
+ events = [];
603
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
604
+ status = "running";
605
+ result;
606
+ checkpointId;
607
+ subs = /* @__PURE__ */ new Set();
608
+ pending = /* @__PURE__ */ new Map();
609
+ abort = new AbortController();
610
+ lastDiffs = [];
611
+ done = Promise.resolve();
612
+ push(e) {
613
+ this.events.push(e);
614
+ if (e.type === "diff") this.lastDiffs = e.diffs;
615
+ for (const s of this.subs) s(e);
616
+ }
617
+ /** Replays everything so far, then streams new events until the task ends. Returns a function that stops listening. */
618
+ subscribe(from, send, onEnd) {
619
+ for (const e of this.events.slice(from)) send(e);
620
+ if (this.status !== "running" && this.pending.size === 0) {
621
+ onEnd();
622
+ return () => void 0;
623
+ }
624
+ const f = (e) => {
625
+ if (e === null) onEnd();
626
+ else send(e);
627
+ };
628
+ this.subs.add(f);
629
+ return () => this.subs.delete(f);
630
+ }
631
+ start(llm) {
632
+ const root = this.dir;
633
+ this.done = (async () => {
634
+ const key = await loadOrCreateReceiptSigner2(root);
635
+ const previous = await lastReceiptTag2(root);
636
+ const result = await runAgent({
637
+ task: this.task,
638
+ llm,
639
+ ws: this.ws,
640
+ signal: this.abort.signal,
641
+ maxSteps: this.cfg.agent.maxSteps,
642
+ contextChars: Math.floor(this.cfg.memory.contextTokens * 2.5),
643
+ ...this.cfg.agent.verifyCommand ? { verifyCommand: this.cfg.agent.verifyCommand } : {},
644
+ runCommand: scratchRunner(this.project, this.ws, { timeoutMs: this.cfg.agent.commandTimeoutSeconds * 1e3 }),
645
+ approve: (info) => this.askApproval(info.command, info.network),
646
+ onEvent: (e) => {
647
+ if (e.type !== "done") this.push(e);
648
+ },
649
+ receipt: { signer: key, previous }
650
+ });
651
+ this.result = result;
652
+ this.status = result.status;
653
+ if (result.receipt) await appendReceipt2(root, result.receipt);
654
+ this.push({ type: "done", result });
655
+ for (const s of this.subs) s(null);
656
+ })().catch((e) => {
657
+ this.status = "error";
658
+ this.push({ type: "done", result: { status: "error", summary: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e), steps: 0, toolCalls: 0, invalidReplies: 0, violations: 0, diffs: [], commands: [], usage: { promptTokens: 0, completionTokens: 0 } } });
659
+ for (const s of this.subs) s(null);
660
+ });
661
+ }
662
+ wait() {
663
+ return this.done;
664
+ }
665
+ askApproval(command, network) {
666
+ if (this.cfg.agent.autoApproveProjectCommands) return Promise.resolve(true);
667
+ const requestId = `appr_${randomBytes(4).toString("hex")}`;
668
+ return new Promise((resolve3) => {
669
+ const timer = setTimeout(() => this.resolveApproval(requestId, false), 10 * 6e4);
670
+ this.pending.set(requestId, { command, network, resolve: resolve3, timer });
671
+ this.push({ type: "approval", requestId, command, network });
672
+ });
673
+ }
674
+ resolveApproval(requestId, allow) {
675
+ const p = this.pending.get(requestId);
676
+ if (!p) return false;
677
+ clearTimeout(p.timer);
678
+ this.pending.delete(requestId);
679
+ this.push({ type: "approval_resolved", requestId, allowed: allow });
680
+ p.resolve(allow);
681
+ return true;
682
+ }
683
+ stop() {
684
+ this.abort.abort();
685
+ for (const id of [...this.pending.keys()]) this.resolveApproval(id, false);
686
+ }
687
+ /** Writes the agent's changes to the project (after checking nothing changed under it), with a checkpoint to roll back to. */
688
+ async apply(force = false) {
689
+ if (this.status === "running") throw new Error("the task is still running");
690
+ const changes = await this.ws.changeSet();
691
+ if (changes.length === 0) throw new Error("there is nothing to apply");
692
+ const r = await applyChanges(this.project, changes, { force });
693
+ this.checkpointId = r.id;
694
+ this.push({ type: "applied", checkpointId: r.id, files: r.applied.length });
695
+ return { checkpointId: r.id, files: r.applied.length };
696
+ }
697
+ async rollback(force = false) {
698
+ if (!this.checkpointId) throw new Error("nothing has been applied by this task");
699
+ await rollbackCheckpoint(this.project, this.checkpointId, { force });
700
+ this.push({ type: "rolled_back", checkpointId: this.checkpointId });
701
+ delete this.checkpointId;
702
+ }
703
+ async info() {
704
+ const receipts2 = await verifyReceiptLog(this.dir);
705
+ const gates = evaluateGates({
706
+ hostRamBytes: this.hostRamBytes,
707
+ agent: this.result ? { toolCalls: this.result.toolCalls, invalidReplies: this.result.invalidReplies, violations: this.result.violations, commandApproval: this.cfg.agent.autoApproveProjectCommands ? "never-asked" : "project-code" } : { toolCalls: 0, invalidReplies: 0, violations: this.ws.violations, commandApproval: "project-code" },
708
+ receiptsOk: receipts2.ok ? { ok: true } : { ok: false, detail: `entry ${receipts2.index}: ${receipts2.reason}` }
709
+ });
710
+ return {
711
+ id: this.id,
712
+ dir: this.dir,
713
+ task: this.task,
714
+ status: this.status,
715
+ startedAt: this.startedAt,
716
+ diffs: this.lastDiffs,
717
+ pendingApprovals: [...this.pending].map(([requestId, p]) => ({ requestId, command: p.command, network: p.network })),
718
+ ...this.result ? { result: this.result } : {},
719
+ ...this.checkpointId ? { checkpointId: this.checkpointId } : {},
720
+ gates
721
+ };
722
+ }
723
+ };
724
+ async function normaliseRoot(dir) {
725
+ const real = await realpath(dir);
726
+ if (!(await stat2(real)).isDirectory()) throw new Error(`${dir} is not a directory`);
727
+ if (real === "/" || /^[A-Za-z]:\\?$/.test(real)) throw new Error("the root of a drive cannot be used as a project");
728
+ return real;
729
+ }
730
+ var isInside = (root, p) => p === root || p.startsWith(join4(root, "/"));
731
+
732
+ // src/server.ts
733
+ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
734
+ import { createServer as createHttpServer } from "http";
735
+ import { Readable as Readable2 } from "stream";
736
+ import {
737
+ BASELINE,
738
+ CATALOG as CATALOG3,
739
+ CLUTOR_DEFAULTS,
740
+ GgufError,
741
+ GiB as GiB2,
742
+ buildModelLayout as buildModelLayout2,
743
+ parseGguf as parseGguf2,
744
+ QUANTS as QUANTS3,
745
+ ShadowWorkspace as ShadowWorkspace2,
746
+ approximateLayout as approximateLayout2,
747
+ evaluateGates as evaluateGates2,
748
+ expertSizeBytes,
749
+ generateRoutingTrace,
750
+ planMemory as planMemory2,
751
+ simulateStreaming
752
+ } from "@clutor/core";
753
+ import { NodeProject as NodeProject2, hostInfo as hostInfo2, listCheckpoints, ConflictError as ConflictError2, fileReader as fileReader2, measureExpertReads, readReceipts, verifyReceiptLog as verifyReceiptLog2, writeSyntheticGguf } from "@clutor/core/node";
754
+ import { z } from "zod";
755
+ var HttpError = class extends Error {
756
+ constructor(status2, message, extra = {}) {
757
+ super(message);
758
+ this.status = status2;
759
+ this.extra = extra;
760
+ }
761
+ status;
762
+ extra;
763
+ };
764
+ var MAX_BODY = 8 * 1024 * 1024;
765
+ var APP_ORIGINS = ["app://clutor", "clutor://app"];
766
+ var LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
767
+ function createClutorServer(o) {
768
+ const cfg = o.config;
769
+ const token = o.token ?? randomBytes2(24).toString("hex");
770
+ const f = o.fetch ?? globalThis.fetch;
771
+ const roots = /* @__PURE__ */ new Set();
772
+ const sessions = /* @__PURE__ */ new Map();
773
+ const host = hostInfo2();
774
+ let boundPort = cfg.port;
775
+ const ready = (o.roots ?? []).map((r) => normaliseRoot(r).then((x) => roots.add(x)));
776
+ const send = (res, status2, body, headers = {}) => {
777
+ const text = typeof body === "string" ? body : JSON.stringify(body);
778
+ res.writeHead(status2, { "content-type": typeof body === "string" ? "text/plain; charset=utf-8" : "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers });
779
+ res.end(text);
780
+ };
781
+ const allowedOrigin = (origin) => APP_ORIGINS.includes(origin) || cfg.corsOrigins.includes(origin) || origin === `http://localhost:${boundPort}` || origin === `http://127.0.0.1:${boundPort}`;
782
+ const readBody = async (req) => {
783
+ const chunks = [];
784
+ let n = 0;
785
+ for await (const c of req) {
786
+ n += c.length;
787
+ if (n > MAX_BODY) throw new HttpError(413, "request body is too large");
788
+ chunks.push(c);
789
+ }
790
+ if (n === 0) return {};
791
+ try {
792
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
793
+ } catch {
794
+ throw new HttpError(400, "the body is not valid JSON");
795
+ }
796
+ };
797
+ const parse = (schema, v) => {
798
+ const r = schema.safeParse(v);
799
+ if (!r.success) throw new HttpError(400, r.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; "));
800
+ return r.data;
801
+ };
802
+ const authed = (req) => {
803
+ const h = req.headers["authorization"];
804
+ const t = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : req.headers["x-clutor-token"] ?? "";
805
+ const a = Buffer.from(t), b = Buffer.from(token);
806
+ return a.length === b.length && timingSafeEqual(a, b);
807
+ };
808
+ const needAuth = (req) => {
809
+ if (!authed(req)) throw new HttpError(401, "this needs the access token (Authorization: Bearer <token>)");
810
+ };
811
+ const llm = () => {
812
+ const b = cfg.backend;
813
+ if (b.kind === "openai") {
814
+ if (!b.baseUrl) throw new HttpError(409, "backend.baseUrl is not set");
815
+ return agentLlm(cfg, { baseUrl: b.baseUrl, model: b.model ?? "default", ...b.apiKeyEnv && process.env[b.apiKeyEnv] ? { apiKey: process.env[b.apiKeyEnv] } : {}, local: false }, f);
816
+ }
817
+ if (b.kind === "llama-server") {
818
+ if (o.backend?.state !== "ready") throw new HttpError(409, "the model is not loaded; start it first (POST /api/backend/start)");
819
+ return agentLlm(cfg, { baseUrl: o.backend.baseUrl, model: "local", local: true }, f);
820
+ }
821
+ throw new HttpError(409, 'no model backend is configured: set backend.kind to "openai" or "llama-server" in clutor.config.json');
822
+ };
823
+ const upstream = () => {
824
+ const b = cfg.backend;
825
+ if (b.kind === "openai" && b.baseUrl) return { url: b.baseUrl.replace(/\/$/, ""), headers: b.apiKeyEnv && process.env[b.apiKeyEnv] ? { authorization: `Bearer ${process.env[b.apiKeyEnv]}` } : {}, model: b.model ?? "default" };
826
+ if (b.kind === "llama-server" && o.backend?.state === "ready") return { url: o.backend.baseUrl, headers: {}, model: "local" };
827
+ throw new HttpError(503, "no model is available to answer: configure a backend and start it", { error: { message: "no model backend is ready", type: "unavailable" } });
828
+ };
829
+ const rootFor = async (dir) => {
830
+ if (typeof dir !== "string" || dir === "") throw new HttpError(400, "dir is required");
831
+ await Promise.all(ready);
832
+ let real;
833
+ try {
834
+ real = await normaliseRoot(dir);
835
+ } catch (e) {
836
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
837
+ }
838
+ for (const r of roots) if (isInside(r, real)) return real;
839
+ throw new HttpError(403, `${real} has not been opened in Clutor. Open it in the app, or start the server with --dir.`);
840
+ };
841
+ const planFor = (m, b) => planMemory2(m.layout, {
842
+ hostRamBytes: (b.hostRamGiB ?? host.totalRamBytes / GiB2) * GiB2,
843
+ ...b.ramCeilingGiB ?? cfg.memory.ramCeilingGiB ? { ramCeilingBytes: (b.ramCeilingGiB ?? cfg.memory.ramCeilingGiB) * GiB2 } : {},
844
+ ...cfg.memory.osHeadroomGiB !== void 0 ? { osHeadroomBytes: cfg.memory.osHeadroomGiB * GiB2 } : {},
845
+ contextTokens: b.contextTokens ?? cfg.memory.contextTokens,
846
+ kvBits: b.kvBits ?? cfg.memory.kvBits,
847
+ ...b.diskGBps ?? cfg.memory.diskGBps ? { diskBytesPerSec: (b.diskGBps ?? cfg.memory.diskGBps) * GiB2, diskMeasured: b.diskMeasured ?? cfg.memory.diskGBps !== void 0 } : {}
848
+ });
849
+ const DecideBody = z.object({
850
+ prompt: z.string().min(1).max(2e5),
851
+ choices: z.array(z.string().min(1).max(200)).min(2).max(26),
852
+ model: z.string().max(200).optional()
853
+ }).strict();
854
+ const PlanBody = z.object({
855
+ model: z.string().max(1e3),
856
+ hostRamGiB: z.number().min(1).max(4096).optional(),
857
+ ramCeilingGiB: z.number().min(1).max(4096).optional(),
858
+ contextTokens: z.number().int().min(256).max(2e6).optional(),
859
+ kvBits: z.union([z.literal(16), z.literal(8), z.literal(4)]).optional(),
860
+ diskGBps: z.number().min(0.01).max(100).optional(),
861
+ diskMeasured: z.boolean().optional()
862
+ }).strict();
863
+ async function route(req, res) {
864
+ const url = new URL(req.url ?? "/", "http://localhost");
865
+ const path = url.pathname;
866
+ const method = req.method ?? "GET";
867
+ if (!LOCAL_HOST.test(req.headers.host ?? "")) throw new HttpError(421, "this server only answers on localhost");
868
+ const origin = req.headers.origin;
869
+ const cors = {};
870
+ if (origin) {
871
+ if (!allowedOrigin(origin)) throw new HttpError(403, `the origin ${origin} is not allowed; add it to corsOrigins in clutor.config.json`);
872
+ Object.assign(cors, { "access-control-allow-origin": origin, vary: "Origin", "access-control-allow-headers": "authorization, content-type, x-clutor-token", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS" });
873
+ }
874
+ if (method === "OPTIONS") {
875
+ res.writeHead(204, cors);
876
+ return void res.end();
877
+ }
878
+ const reply = (status2, body) => send(res, status2, body, cors);
879
+ if (method === "GET" && path === "/api/health") return reply(200, { ok: true, version: o.version, backend: { kind: cfg.backend.kind, state: o.backend?.state ?? (cfg.backend.kind === "openai" ? "external" : "none") } });
880
+ if (method === "GET" && path === "/api/hardware") return reply(200, host);
881
+ if (method === "GET" && path === "/api/catalog") return reply(200, { models: CATALOG3.map((c) => ({ id: c.id, name: c.name, experts: c.experts, expertsUsed: c.expertsUsed, layers: c.layers, publishedParamsB: c.publishedParamsB, source: c.source })), quants: QUANTS3 });
882
+ if (method === "POST" && path === "/api/plan") {
883
+ const b = parse(PlanBody, await readBody(req));
884
+ const isFile = b.model.toLowerCase().endsWith(".gguf");
885
+ if (isFile) needAuth(req);
886
+ let m2;
887
+ try {
888
+ m2 = await resolveModel(b.model, { modelsDir: resolveModelsDir(), allowFiles: isFile });
889
+ } catch (e) {
890
+ throw new HttpError(e instanceof ModelError || e instanceof GgufError ? 400 : 500, e instanceof Error ? e.message : String(e));
891
+ }
892
+ const plan2 = planFor(m2, b);
893
+ const gates = evaluateGates2({ hostRamBytes: (b.hostRamGiB ?? host.totalRamBytes / GiB2) * GiB2, plan: plan2, cache: { policy: cfg.cache.policy }, smartRoute: { enabled: cfg.cache.smartRoute.enabled, maxDeltaP: cfg.cache.smartRoute.maxDeltaP }, ...m2.gguf ? { gguf: m2.gguf } : {} });
894
+ return reply(200, { model: { id: m2.id, name: m2.name, source: m2.source, quant: m2.quant, approximate: m2.layout.approximate, totalBytes: m2.layout.totalBytes, layers: m2.layout.layerCount, experts: m2.layout.expertCount, expertsUsed: m2.layout.expertsUsed }, plan: plan2, gates });
895
+ }
896
+ if (method === "POST" && path === "/api/simulate") {
897
+ const b = parse(z.object({ model: z.string().max(100), quant: z.string().max(20).optional(), cacheGiB: z.number().min(0).max(4096), tokens: z.number().int().min(20).max(1500).default(300), locality: z.number().min(0).max(0.99).default(0.9), diskGBps: z.number().min(0.01).max(100).default(3), computeMsPerLayer: z.number().min(0).max(1e3).default(0.5), seed: z.number().int().default(1), smartRoute: z.boolean().default(false) }).strict(), await readBody(req));
898
+ const spec = CATALOG3.find((c) => c.id === b.model);
899
+ if (!spec || spec.experts === 0) throw new HttpError(400, "pick a mixture-of-experts model from the catalog");
900
+ const bpw = QUANTS3[b.quant ?? "Q4_K_M"];
901
+ if (bpw === void 0) throw new HttpError(400, "unknown quantisation");
902
+ const layout = approximateLayout2(spec, bpw);
903
+ const moe = layout.layers.filter((l) => l.experts.length).map((l) => l.index);
904
+ const trace = generateRoutingTrace({ moeLayers: moe, experts: spec.experts, used: spec.expertsUsed, tokens: b.tokens, seed: b.seed, locality: b.locality });
905
+ const base = { cacheBytes: b.cacheGiB * GiB2, disk: { bytesPerSec: b.diskGBps * GiB2, requestLatencySec: 1e-4 }, computePerLayerSec: b.computeMsPerLayer / 1e3 };
906
+ const baseline = simulateStreaming(layout, trace, { ...BASELINE, ...base });
907
+ const clutor = simulateStreaming(layout, trace, { ...CLUTOR_DEFAULTS, smartRoute: { ...CLUTOR_DEFAULTS.smartRoute, enabled: b.smartRoute }, ...base });
908
+ return reply(200, { synthetic: true, note: "A synthetic routing trace: it shows how each policy behaves on a workload with this locality, not how any real model routes.", expertBytes: expertSizeBytes(layout), baseline, clutor });
909
+ }
910
+ if (method === "GET" && path === "/v1/models") {
911
+ const u = upstream();
912
+ return reply(200, { object: "list", data: [{ id: u.model, object: "model", created: 0, owned_by: "clutor" }] });
913
+ }
914
+ if (method === "POST" && path === "/v1/decide") {
915
+ needAuth(req);
916
+ const b = parse(DecideBody, await readBody(req));
917
+ const u = upstream();
918
+ const model = u.model === "local" ? "local" : b.model ?? u.model;
919
+ const authorization = u.headers["authorization"];
920
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
921
+ let result;
922
+ try {
923
+ result = await runDecide({ baseUrl: u.url, model, ...authorization ? { apiKey: authorization.replace(/^Bearer /, "") } : {} }, b.prompt, b.choices, f);
924
+ } catch (e) {
925
+ if (e instanceof DecideError) throw new HttpError(e.status, e.message);
926
+ throw e;
927
+ }
928
+ const root = roots.values().next().value;
929
+ const receipt = root ? await sealDecision(root, model, b.prompt, b.choices, result, startedAt) : null;
930
+ return reply(200, { choice: result.choice, probabilities: result.probabilities, probabilityMassOnChoices: result.probabilityMassOnChoices, latencyMs: result.latencyMs, receipt });
931
+ }
932
+ if (method === "POST" && path === "/v1/chat/completions") {
933
+ const u = upstream();
934
+ const body = await readBody(req);
935
+ const r = await f(`${u.url}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", ...u.headers }, body: JSON.stringify({ ...body, model: u.model === "local" ? body["model"] ?? "local" : u.model }) });
936
+ res.writeHead(r.status, { "content-type": r.headers.get("content-type") ?? "application/json", "cache-control": "no-store", ...cors });
937
+ if (!r.body) return void res.end();
938
+ const stream = Readable2.fromWeb(r.body);
939
+ req.on("close", () => stream.destroy());
940
+ stream.pipe(res);
941
+ return;
942
+ }
943
+ if (method === "GET" && path === "/api/tags") {
944
+ const u = (() => {
945
+ try {
946
+ return upstream();
947
+ } catch {
948
+ return null;
949
+ }
950
+ })();
951
+ const local = await listLocalModels(resolveModelsDir()).catch(() => []);
952
+ return reply(200, { models: [...u ? [{ name: u.model, model: u.model, modified_at: (/* @__PURE__ */ new Date()).toISOString(), size: 0, digest: "", details: { format: "gguf" } }] : [], ...local.map((m2) => ({ name: m2.file, model: m2.file, modified_at: (/* @__PURE__ */ new Date()).toISOString(), size: m2.bytes, digest: "", details: { format: "gguf" } }))] });
953
+ }
954
+ if (method === "POST" && (path === "/api/chat" || path === "/api/generate")) {
955
+ const kind = path === "/api/chat" ? "chat" : "generate";
956
+ const req0 = await readBody(req);
957
+ const u = upstream();
958
+ const model = req0.model ?? u.model;
959
+ const stream = req0.stream !== false;
960
+ const r = await f(`${u.url}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", ...u.headers }, body: JSON.stringify({ ...toOpenAiBody(req0, u.model === "local" ? model : u.model, kind), ...stream ? { stream_options: { include_usage: true } } : {} }) });
961
+ if (!r.ok) throw new HttpError(r.status === 404 ? 404 : 502, `the model server answered ${r.status}: ${(await r.text()).slice(0, 200)}`);
962
+ if (!stream) return reply(200, fromOpenAiReply(kind, model, await r.json()));
963
+ res.writeHead(200, { "content-type": "application/x-ndjson", "cache-control": "no-store", ...cors });
964
+ for await (const line of streamToOllama(kind, model, r.body)) {
965
+ if (!res.write(line)) await new Promise((x) => res.once("drain", x));
966
+ }
967
+ return void res.end();
968
+ }
969
+ if (path.startsWith("/api/")) needAuth(req);
970
+ if (method === "GET" && path === "/api/models") return reply(200, { local: await listLocalModels(resolveModelsDir()), catalog: CATALOG3.map((c) => c.id) });
971
+ if (method === "GET" && path === "/api/roots") return reply(200, { roots: [...roots] });
972
+ if (method === "POST" && path === "/api/roots") {
973
+ const b = parse(z.object({ dir: z.string().min(1).max(2e3) }).strict(), await readBody(req));
974
+ try {
975
+ return reply(200, { root: await addRoot(b.dir) });
976
+ } catch (e) {
977
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
978
+ }
979
+ }
980
+ if (method === "GET" && path === "/api/backend") return reply(200, { kind: cfg.backend.kind, state: o.backend?.state ?? "n/a", error: o.backend?.error ?? null, log: o.backend?.recentLog.slice(-40) ?? [] });
981
+ if (method === "POST" && path === "/api/backend/stop") {
982
+ await o.backend?.stop();
983
+ return reply(200, { state: o.backend?.state ?? "n/a" });
984
+ }
985
+ if (method === "POST" && path === "/api/backend/start") {
986
+ if (!o.startBackend || !o.backend) throw new HttpError(409, "this server was not set up to start a model: set backend.kind to llama-server and give it a model file");
987
+ if (o.backend.state === "ready" || o.backend.state === "starting") return reply(200, { state: o.backend.state });
988
+ void o.startBackend().catch((e) => o.log?.(`backend: ${e instanceof Error ? e.message : String(e)}`));
989
+ return reply(202, { state: "starting" });
990
+ }
991
+ if (method === "POST" && path === "/api/bench") {
992
+ const b = parse(z.object({ model: z.string().max(2e3).optional() }).strict(), await readBody(req));
993
+ let file = b.model;
994
+ let cleanup;
995
+ if (file) {
996
+ try {
997
+ file = (await resolveModel(file, { modelsDir: resolveModelsDir() })).path;
998
+ } catch (e) {
999
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1000
+ }
1001
+ } else {
1002
+ const { mkdtemp, rm: rm2 } = await import("fs/promises");
1003
+ const { tmpdir: tmpdir2 } = await import("os");
1004
+ const dir = await mkdtemp(`${tmpdir2()}/clutor-bench-`);
1005
+ cleanup = () => rm2(dir, { recursive: true, force: true });
1006
+ file = `${dir}/bench.gguf`;
1007
+ await writeSyntheticGguf(file, { arch: "qwen2moe", name: "bench-moe", layers: 8, embedding: 1024, heads: 16, kvHeads: 4, contextLength: 4096, expertCount: 32, expertsUsed: 4, expertFfn: 1024, denseFfn: 0, vocab: 4096, sharedExperts: 0 });
1008
+ }
1009
+ try {
1010
+ const r = await fileReader2(file);
1011
+ let layout;
1012
+ try {
1013
+ layout = buildModelLayout2(await parseGguf2(r));
1014
+ } finally {
1015
+ await r.close();
1016
+ }
1017
+ if (!layout.expertCount) throw new HttpError(400, "this model has no experts to read");
1018
+ const m2 = await measureExpertReads(file, layout, host.totalRamBytes, 96);
1019
+ return reply(200, { measurement: m2, synthetic: b.model === void 0 });
1020
+ } finally {
1021
+ await cleanup?.();
1022
+ }
1023
+ }
1024
+ if (method === "POST" && path === "/api/mcp") {
1025
+ const r = await handleMcp(await readBody(req), { isRoot: (d) => rootFor(d).catch(() => null) });
1026
+ return r.status === 202 ? (res.writeHead(202, cors), void res.end()) : reply(r.status, r.body);
1027
+ }
1028
+ if (method === "POST" && path === "/api/agent/run") {
1029
+ const b = parse(z.object({ dir: z.string().min(1), task: z.string().min(1).max(2e4) }).strict(), await readBody(req));
1030
+ const dir = await rootFor(b.dir);
1031
+ const client = llm();
1032
+ const project = new NodeProject2(dir);
1033
+ const s = new AgentSession(dir, b.task, project, new ShadowWorkspace2(project), cfg, host.totalRamBytes);
1034
+ sessions.set(s.id, s);
1035
+ if (sessions.size > 50) for (const [k, v] of sessions) {
1036
+ if (v.status !== "running") {
1037
+ sessions.delete(k);
1038
+ break;
1039
+ }
1040
+ }
1041
+ s.start(client);
1042
+ return reply(202, { id: s.id });
1043
+ }
1044
+ const m = /^\/api\/agent\/(task_[a-f0-9]+)(?:\/([a-z]+))?$/.exec(path);
1045
+ if (m) {
1046
+ const s = sessions.get(m[1]);
1047
+ if (!s) throw new HttpError(404, "no such task");
1048
+ const action = m[2];
1049
+ if (method === "GET" && !action) return reply(200, await s.info());
1050
+ if (method === "GET" && action === "events") {
1051
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", ...cors });
1052
+ const from = Number(url.searchParams.get("from") ?? 0) || 0;
1053
+ const stop = s.subscribe(from, (e) => res.write(`data: ${JSON.stringify(e)}
1054
+
1055
+ `), () => res.end());
1056
+ req.on("close", stop);
1057
+ return;
1058
+ }
1059
+ if (method === "POST" && action === "approve") {
1060
+ const b = parse(z.object({ requestId: z.string(), allow: z.boolean() }).strict(), await readBody(req));
1061
+ return s.resolveApproval(b.requestId, b.allow) ? reply(200, { ok: true }) : reply(404, { error: "no such approval request" });
1062
+ }
1063
+ if (method === "POST" && action === "stop") {
1064
+ s.stop();
1065
+ return reply(200, { ok: true });
1066
+ }
1067
+ if (method === "POST" && action === "apply") {
1068
+ const b = parse(z.object({ force: z.boolean().default(false) }).strict(), await readBody(req));
1069
+ try {
1070
+ return reply(200, await s.apply(b.force));
1071
+ } catch (e) {
1072
+ if (e instanceof ConflictError2) return reply(409, { error: e.message, conflicts: e.conflicts });
1073
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1074
+ }
1075
+ }
1076
+ if (method === "POST" && action === "rollback") {
1077
+ const b = parse(z.object({ force: z.boolean().default(false) }).strict(), await readBody(req));
1078
+ try {
1079
+ await s.rollback(b.force);
1080
+ return reply(200, { ok: true });
1081
+ } catch (e) {
1082
+ if (e instanceof ConflictError2) return reply(409, { error: e.message, conflicts: e.conflicts });
1083
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1084
+ }
1085
+ }
1086
+ }
1087
+ if (method === "GET" && path === "/api/receipts") {
1088
+ const dir = await rootFor(url.searchParams.get("dir"));
1089
+ return reply(200, { receipts: (await readReceipts(dir)).slice(-100), verification: await verifyReceiptLog2(dir), checkpoints: await listCheckpoints(new NodeProject2(dir)) });
1090
+ }
1091
+ throw new HttpError(404, "no such endpoint");
1092
+ }
1093
+ function resolveModelsDir() {
1094
+ return cfg.models.startsWith("/") || /^[A-Za-z]:/.test(cfg.models) ? cfg.models : `${o.root}/${cfg.models}`;
1095
+ }
1096
+ async function addRoot(dir) {
1097
+ const real = await normaliseRoot(dir);
1098
+ roots.add(real);
1099
+ return real;
1100
+ }
1101
+ const http = createHttpServer((req, res) => {
1102
+ route(req, res).catch((e) => {
1103
+ const status2 = e instanceof HttpError ? e.status : 500;
1104
+ if (status2 === 500) o.log?.(`error: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
1105
+ if (res.headersSent) return void res.destroy();
1106
+ send(res, status2, { error: e instanceof HttpError ? e.message : "internal error", ...e instanceof HttpError ? e.extra : {} });
1107
+ });
1108
+ });
1109
+ http.headersTimeout = 15e3;
1110
+ http.requestTimeout = 0;
1111
+ return {
1112
+ http,
1113
+ token,
1114
+ sessions,
1115
+ addRoot,
1116
+ listen: () => new Promise((resolve3, reject) => {
1117
+ http.once("error", reject);
1118
+ http.listen(cfg.port, cfg.host, () => {
1119
+ const a = http.address();
1120
+ boundPort = a.port;
1121
+ resolve3({ host: a.address, port: a.port });
1122
+ });
1123
+ }),
1124
+ close: async () => {
1125
+ for (const s of sessions.values()) s.stop();
1126
+ await new Promise((r) => {
1127
+ http.close(() => r());
1128
+ http.closeAllConnections();
1129
+ });
1130
+ await o.backend?.stop();
1131
+ }
1132
+ };
1133
+ }
1134
+
1135
+ // src/engine.ts
1136
+ import { spawn as spawn2 } from "child_process";
1137
+ import { accessSync as accessSync2, constants as constants2 } from "fs";
1138
+ import { homedir as homedir3 } from "os";
1139
+ import { delimiter as delimiter2, dirname, join as join5 } from "path";
1140
+ import { fileURLToPath } from "url";
1141
+ var EngineError = class extends Error {
1142
+ constructor(message) {
1143
+ super(message);
1144
+ this.name = "EngineError";
1145
+ }
1146
+ };
1147
+ var EXE = process.platform === "win32" ? "clutor-engine.exe" : "clutor-engine";
1148
+ var executable = (p) => {
1149
+ try {
1150
+ accessSync2(p, constants2.X_OK);
1151
+ return true;
1152
+ } catch {
1153
+ return false;
1154
+ }
1155
+ };
1156
+ function engineSourceDir() {
1157
+ return join5(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "c");
1158
+ }
1159
+ function findEngine(env = process.env) {
1160
+ const candidates = [env["CLUTOR_ENGINE"], join5(engineSourceDir(), "build", EXE), join5(homedir3(), ".clutor", "bin", EXE), ...(env["PATH"] ?? "").split(delimiter2).map((d) => join5(d, EXE))];
1161
+ return candidates.find((c) => !!c && executable(c));
1162
+ }
1163
+ function run(bin, args, opts = {}) {
1164
+ return new Promise((resolve3, reject) => {
1165
+ const child = spawn2(bin, args, { stdio: ["ignore", "pipe", "pipe"], ...opts.cwd ? { cwd: opts.cwd } : {} });
1166
+ let stdout = "", stderr = "";
1167
+ const timer = setTimeout(() => {
1168
+ child.kill("SIGKILL");
1169
+ reject(new EngineError(`${bin} did not finish within ${(opts.timeoutMs ?? 6e5) / 1e3} seconds`));
1170
+ }, opts.timeoutMs ?? 6e5);
1171
+ child.stdout.on("data", (b) => {
1172
+ stdout += b.toString();
1173
+ });
1174
+ child.stderr.on("data", (b) => {
1175
+ stderr += b.toString();
1176
+ });
1177
+ child.on("error", (e) => {
1178
+ clearTimeout(timer);
1179
+ reject(new EngineError(`could not start ${bin}: ${e.message}`));
1180
+ });
1181
+ child.on("close", (code2) => {
1182
+ clearTimeout(timer);
1183
+ resolve3({ code: code2 ?? 1, stdout, stderr });
1184
+ });
1185
+ });
1186
+ }
1187
+ async function buildEngine() {
1188
+ const dir = engineSourceDir();
1189
+ const r = await run("make", ["-C", dir], { timeoutMs: 3e5 }).catch((e) => {
1190
+ throw new EngineError(`could not run make: ${e instanceof Error ? e.message : String(e)}`);
1191
+ });
1192
+ if (r.code !== 0) throw new EngineError(`the engine did not compile:
1193
+ ${(r.stderr || r.stdout).split("\n").slice(-15).join("\n")}`);
1194
+ const bin = join5(dir, "build", EXE);
1195
+ if (!executable(bin)) throw new EngineError(`make finished but ${bin} is missing`);
1196
+ return bin;
1197
+ }
1198
+ function streamArgs(file, o) {
1199
+ const a = ["stream", file, "--json"];
1200
+ const add = (k, v) => {
1201
+ if (v !== void 0) a.push(k, String(v));
1202
+ };
1203
+ add("--tokens", o.tokens);
1204
+ add("--warmup", o.warmup);
1205
+ add("--cache-mb", o.cacheMb);
1206
+ add("--stage", o.stage);
1207
+ add("--policy", o.policy);
1208
+ add("--smart", o.smart);
1209
+ if (o.prefetch !== void 0) add("--prefetch", o.prefetch ? "on" : "off");
1210
+ add("--io-threads", o.ioThreads);
1211
+ add("--seed", o.seed);
1212
+ add("--locality", o.locality);
1213
+ add("--predict-corr", o.predictCorr);
1214
+ add("--compute-us", o.computeUs);
1215
+ add("--window-pct", o.windowPct);
1216
+ add("--dense", o.dense);
1217
+ add("--compute", o.compute);
1218
+ add("--window", o.window);
1219
+ if (o.shift) add("--shift", `${o.shift.at},${o.shift.len}`);
1220
+ if (o.noOsCache) a.push("--no-os-cache");
1221
+ return a;
1222
+ }
1223
+ async function engineInfo(bin, file) {
1224
+ const r = await run(bin, ["info", file, "--json"], { timeoutMs: 6e4 });
1225
+ if (r.code !== 0) throw new EngineError(r.stderr.trim() || `engine exited with ${r.code}`);
1226
+ return JSON.parse(r.stdout);
1227
+ }
1228
+ async function engineStream(bin, file, o = {}, timeoutMs = 9e5) {
1229
+ const r = await run(bin, streamArgs(file, o), { timeoutMs });
1230
+ if (r.code !== 0) throw new EngineError(r.stderr.trim() || `engine exited with ${r.code}`);
1231
+ try {
1232
+ return JSON.parse(r.stdout);
1233
+ } catch {
1234
+ throw new EngineError("the engine printed something that is not a report");
1235
+ }
1236
+ }
1237
+
1238
+ // src/cli.ts
1239
+ import { existsSync, mkdirSync as mkdirSync2, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
1240
+ import { tmpdir } from "os";
1241
+ import { dirname as dirname2, join as join6, resolve as resolve2 } from "path";
1242
+ import { fileURLToPath as fileURLToPath2 } from "url";
1243
+ import {
1244
+ BASELINE as BASELINE2,
1245
+ CATALOG as CATALOG4,
1246
+ CLUTOR_DEFAULTS as CLUTOR_DEFAULTS2,
1247
+ GiB as GiB3,
1248
+ QUANTS as QUANTS4,
1249
+ ShadowWorkspace as ShadowWorkspace3,
1250
+ OpenAiCompatibleClient as OpenAiCompatibleClient2,
1251
+ buildModelLayout as buildModelLayout3,
1252
+ evaluateGates as evaluateGates3,
1253
+ expertSizeBytes as expertSizeBytes2,
1254
+ fmtGiB as fmtGiB2,
1255
+ generateRoutingTrace as generateRoutingTrace2,
1256
+ parseGguf as parseGguf3,
1257
+ planMemory as planMemory3,
1258
+ runAgent as runAgent2,
1259
+ simulateStreaming as simulateStreaming2,
1260
+ validateBounds,
1261
+ approximateLayout as approximateLayout3,
1262
+ fmtBytes,
1263
+ keyThumbprint,
1264
+ sealReceipt as sealReceipt2,
1265
+ verifyReceiptChain
1266
+ } from "@clutor/core";
1267
+ import { NodeProject as NodeProject3, applyChanges as applyChanges2, diskModelFrom, fileReader as fileReader3, hostInfo as hostInfo3, lastReceiptTag as lastReceiptTag3, listCheckpoints as listCheckpoints2, loadOrCreateReceiptSigner as loadOrCreateReceiptSigner3, measureExpertReads as measureExpertReads2, appendReceipt as appendReceipt3, readReceipts as readReceipts2, rollbackCheckpoint as rollbackCheckpoint2, scratchRunner as scratchRunner2, verifyReceiptLog as verifyReceiptLog3, writeSyntheticGguf as writeSyntheticGguf2, ConflictError as ConflictError3 } from "@clutor/core/node";
1268
+ var version = () => {
1269
+ try {
1270
+ const here = dirname2(fileURLToPath2(import.meta.url));
1271
+ return JSON.parse(readFileSync(join6(here, "..", "package.json"), "utf8")).version;
1272
+ } catch {
1273
+ return "0.0.0";
1274
+ }
1275
+ };
1276
+ var HELP = `clutor: run very large models from disk on small machines, and a coding agent that works on your project
1277
+
1278
+ Usage: clutor <command> [options]
1279
+ clutor In a terminal: an interactive session. Type a task, or /help for commands.
1280
+
1281
+ init [dir] Create clutor.config.json and a models folder
1282
+ models [dir] List the model catalog and the .gguf files in your models folder
1283
+ pull [id] [--yes] List the small test models you can download, or download one into your models folder
1284
+ inspect <file.gguf> Read a model file's header: architecture, layers, experts, sizes
1285
+ plan <model> [--ram GiB] [--ctx N] [--kv 16|8|4] [--disk GiB/s] [--quant Q4_K_M] [--json]
1286
+ Will it fit? Where does the memory go? How fast could it possibly run?
1287
+ simulate <model> [--cache GiB] [--tokens N] [--locality 0.9] [--disk GiB/s] [--compute-ms N] [--smart-route] [--json]
1288
+ Replay a routing trace through the expert cache: baseline LRU against clutor
1289
+ bench [--file model.gguf] [--engine] [--cache-mb N] [--tokens N] [--compute kernel|checksum]
1290
+ Measure real disk reads on this machine; with the native engine, also compare cache and prefetch strategies
1291
+ stream <model.gguf> [--tokens N] [--cache-mb N] [--policy lru|wtinylfu|adaptive] [--smart J,M,TAU|off] [--prefetch on|off] [--dense auto|resident|ring] [--compute kernel|checksum] [--json]
1292
+ Stream a model's experts from disk through the native engine (synthetic routing; measured I/O and cache)
1293
+ engine build|where Compile the native engine from c/ (needs a C compiler), or show which one is used
1294
+ code "<task>" [--dir folder] [--apply] [--yes] [--verify "npm test"] [--max-steps N]
1295
+ Run the coding agent on a folder; review the diff before anything is written
1296
+ serve [dir] [--port ${DEFAULT_PORT}] [--folder dir]... Start the local server: OpenAI and Ollama compatible API, agent, MCP
1297
+ backend [--model file.gguf] Find llama-server, see which flags it supports and how clutor would start it
1298
+ status [dir] One screen: machine, model backend, memory plan, receipts
1299
+ chat [--prompt "<text>"] [--system "<text>"] Talk to the configured model in the terminal (one-shot with --prompt, else a REPL)
1300
+ decide --prompt "<text>" --choices "a,b,c" [--json] One forward pass, no chat loop: probability of each choice's first token
1301
+ receipts [dir] Verify the agent's receipt log and list checkpoints (also: ledger verify|list)
1302
+ receipt verify <clt_token|file> [dir] [--key-id id|--pubkey file] Check a receipt log offline; receipt key shows your key id
1303
+ rollback <checkpoint> [--dir folder] [--force] Undo an applied task
1304
+ doctor [dir] Check this machine and the configuration
1305
+
1306
+ Models are catalog ids (${CATALOG4.map((c) => c.id).join(", ")}) or the path of a .gguf file.
1307
+ Common options: --config <file> --json -h, --help -v, --version
1308
+ `;
1309
+ var BOOLEANS = ["json", "help", "version", "apply", "yes", "force", "smart-route", "no-start", "engine", "no-os-cache"];
1310
+ function cfgFor(args, io, dirArg) {
1311
+ return loadConfig(dirArg ? resolve2(io.cwd, dirArg) : io.cwd, flagString(args, "config"));
1312
+ }
1313
+ async function main(argv, io, session) {
1314
+ const args = parseArgs(argv, BOOLEANS);
1315
+ const [command, ...rest] = args.positional;
1316
+ const json = flagBool(args, "json");
1317
+ try {
1318
+ if (flagBool(args, "version") || command === "version" || argv.includes("-v")) return io.out(version()), 0;
1319
+ if (!command && !flagBool(args, "help") && !argv.includes("-h") && io.prompt && !session) return await (await import("./repl-OVODHQI5.js")).runRepl(io, (a, s) => main(a, io, s), (cfg, what) => engineSlashCommand(io, cfg, what));
1320
+ if (!command || flagBool(args, "help") || command === "help" || argv.includes("-h")) return io.out(HELP), 0;
1321
+ switch (command) {
1322
+ case "init":
1323
+ return await init(io, rest[0]);
1324
+ case "models":
1325
+ return await models(io, args, rest[0], json);
1326
+ case "stream":
1327
+ return await streamCmd(io, args, rest[0], json);
1328
+ case "engine":
1329
+ return await engineCmd(io, rest[0]);
1330
+ case "pull":
1331
+ return await pull(io, args, rest[0]);
1332
+ case "inspect":
1333
+ return await inspect(io, rest[0], json);
1334
+ case "plan":
1335
+ return await plan(io, args, rest[0], json);
1336
+ case "simulate":
1337
+ return await simulate(io, args, rest[0], json);
1338
+ case "bench":
1339
+ return await bench(io, args, json);
1340
+ case "code":
1341
+ return await code(io, args, rest.join(" "), session);
1342
+ case "serve":
1343
+ return await serve(io, args, rest[0]);
1344
+ case "backend":
1345
+ return await backendCmd(io, args);
1346
+ case "receipts":
1347
+ return await receipts(io, args, rest[0], json);
1348
+ case "ledger":
1349
+ return await receipts(io, args, rest[1], json);
1350
+ case "receipt":
1351
+ return await receiptVerify(io, args, rest[0], rest[1], rest[2]);
1352
+ case "status":
1353
+ return await status(io, args, rest[0], json);
1354
+ case "chat":
1355
+ return await chat(io, args);
1356
+ case "decide":
1357
+ return await decide(io, args, json);
1358
+ case "rollback":
1359
+ return await rollback(io, args, rest[0]);
1360
+ case "doctor":
1361
+ return await doctor(io, args, rest[0], json);
1362
+ default:
1363
+ throw new UsageError(`unknown command "${command}"; run clutor --help`);
1364
+ }
1365
+ } catch (e) {
1366
+ if (e instanceof UsageError || e instanceof ConfigError) {
1367
+ io.err(e.message);
1368
+ return 2;
1369
+ }
1370
+ io.err(e instanceof Error ? e.message : String(e));
1371
+ return 1;
1372
+ }
1373
+ }
1374
+ function init(io, dirArg) {
1375
+ const dir = resolve2(io.cwd, dirArg ?? ".");
1376
+ mkdirSync2(join6(dir, "models"), { recursive: true });
1377
+ const path = join6(dir, CONFIG_FILE);
1378
+ if (existsSync(path)) {
1379
+ io.err(`${path} already exists; leaving it alone`);
1380
+ return 1;
1381
+ }
1382
+ writeFileSync(path, `${JSON.stringify({ port: DEFAULT_PORT, models: "models", backend: { kind: "none" }, memory: { contextTokens: 8192, kvBits: 16 }, cache: { policy: "adaptive" }, agent: { toolMode: "json", maxSteps: 30 } }, null, 2)}
1383
+ `);
1384
+ const h = hostInfo3();
1385
+ io.out(`Created ${path} and ${join6(dir, "models")}.`);
1386
+ io.out(`This machine: ${h.platformName} ${h.arch}, ${h.cpuModel}, ${fmtGiB2(h.totalRamBytes)} RAM.`);
1387
+ io.out("");
1388
+ io.out("Next: put a .gguf file in the models folder, then");
1389
+ io.out(" clutor plan <file.gguf> to see whether and how it fits");
1390
+ io.out(" clutor backend --model <file> to see how llama-server would be started for it");
1391
+ return 0;
1392
+ }
1393
+ async function models(io, args, dirArg, json) {
1394
+ const { config, root } = cfgFor(args, io, dirArg);
1395
+ const local = await listLocalModels(resolve2(root, config.models));
1396
+ if (json) {
1397
+ io.out(JSON.stringify({ local, catalog: CATALOG4.map((c) => c.id) }, null, 2));
1398
+ return 0;
1399
+ }
1400
+ io.out("Catalog (architectures you can plan for before downloading anything):");
1401
+ io.out(table(CATALOG4.map((c) => [c.id, c.name, `${c.publishedParamsB}B`, c.experts ? `${c.experts} experts, ${c.expertsUsed} used` : "dense"])));
1402
+ io.out("");
1403
+ io.out(local.length ? `In ${resolve2(root, config.models)}:
1404
+ ${table(local.map((m) => [m.file, fmtBytes(m.bytes)]))}` : `No .gguf files in ${resolve2(root, config.models)}.`);
1405
+ return 0;
1406
+ }
1407
+ async function inspect(io, file, json) {
1408
+ if (!file) throw new UsageError("usage: clutor inspect <file.gguf>");
1409
+ const path = resolve2(io.cwd, file);
1410
+ const r = await fileReader3(path);
1411
+ try {
1412
+ const g = await parseGguf3(r);
1413
+ const l = buildModelLayout3(g);
1414
+ validateBounds(g);
1415
+ if (json) {
1416
+ io.out(JSON.stringify({ version: g.version, tensors: g.tensorCount, arch: l.arch, name: l.name, layers: l.layerCount, experts: l.expertCount, expertsUsed: l.expertsUsed, bytes: l.totalBytes, quantisations: l.quantisations, residentBytes: l.residentBytes, fileBytes: g.fileSize }, null, 2));
1417
+ return 0;
1418
+ }
1419
+ io.out(`${l.name} (${l.arch}), GGUF v${g.version}, ${g.tensorCount} tensors, ${fmtBytes(g.fileSize)}`);
1420
+ io.out(`${l.layerCount} layers${l.expertCount ? `, ${l.expertCount} experts per layer (${l.expertsUsed} used per token${l.sharedExperts ? `, ${l.sharedExperts} shared` : ""})` : ", dense"}`);
1421
+ io.out(`Always needed in RAM: ${fmtBytes(l.residentBytes)} Per-layer dense weights: ${fmtBytes(l.layers.reduce((s, x) => s + x.denseBytes, 0))} Experts: ${fmtBytes(l.layers.reduce((s, x) => s + x.experts.length * x.expertBytes, 0))}`);
1422
+ io.out(`Quantisations: ${Object.entries(l.quantisations).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${fmtBytes(v)}`).join(", ")}`);
1423
+ io.out(`KV cache: ${l.kv.kvHeads} KV heads x ${l.kv.headDim} dims, up to ${l.kv.maxContext.toLocaleString("en-US")} tokens`);
1424
+ if (l.unsizedTensors) io.out(`! ${l.unsizedTensors} tensors use a quantisation this reader cannot size.`);
1425
+ io.out("All tensors lie inside the file.");
1426
+ return 0;
1427
+ } finally {
1428
+ await r.close();
1429
+ }
1430
+ }
1431
+ function numFlag(args, name, min, max) {
1432
+ const v = flagString(args, name);
1433
+ if (v === void 0) return void 0;
1434
+ const n = Number(v);
1435
+ if (!Number.isFinite(n) || n < min || n > max) throw new UsageError(`--${name} must be a number from ${min} to ${max}`);
1436
+ return n;
1437
+ }
1438
+ async function plan(io, args, ref, json) {
1439
+ if (!ref) throw new UsageError("usage: clutor plan <model> [--ram GiB]");
1440
+ const { config, root } = cfgFor(args, io);
1441
+ const quant = flagString(args, "quant");
1442
+ const m = await resolveModel(ref.toLowerCase().endsWith(".gguf") ? resolve2(io.cwd, ref) : quant ? `${ref}@${quant}` : ref, { modelsDir: resolve2(root, config.models) });
1443
+ const h = hostInfo3();
1444
+ const ram = numFlag(args, "ram", 1, 4096) ?? config.memory.ramCeilingGiB;
1445
+ const disk = numFlag(args, "disk", 0.01, 100) ?? config.memory.diskGBps;
1446
+ const kv = flagInt(args, "kv", 4, 16);
1447
+ if (kv !== void 0 && ![4, 8, 16].includes(kv)) throw new UsageError("--kv must be 16, 8 or 4");
1448
+ const ramFlag = numFlag(args, "ram", 1, 4096);
1449
+ const hostRam = ramFlag !== void 0 ? ramFlag * GiB3 : h.totalRamBytes;
1450
+ const p = planMemory3(m.layout, {
1451
+ hostRamBytes: hostRam,
1452
+ ...ramFlag === void 0 && ram ? { ramCeilingBytes: ram * GiB3 } : {},
1453
+ ...config.memory.osHeadroomGiB !== void 0 ? { osHeadroomBytes: config.memory.osHeadroomGiB * GiB3 } : {},
1454
+ contextTokens: flagInt(args, "ctx", 256, 2e6) ?? config.memory.contextTokens,
1455
+ kvBits: kv ?? config.memory.kvBits,
1456
+ ...disk ? { diskBytesPerSec: disk * GiB3, diskMeasured: flagString(args, "disk") !== void 0 || config.memory.diskGBps !== void 0 } : {}
1457
+ });
1458
+ const gates = evaluateGates3({ hostRamBytes: hostRam, plan: p, cache: { policy: config.cache.policy }, smartRoute: { enabled: config.cache.smartRoute.enabled, maxDeltaP: config.cache.smartRoute.maxDeltaP }, ...m.gguf ? { gguf: m.gguf } : {} });
1459
+ if (json) {
1460
+ io.out(JSON.stringify({ model: { id: m.id, name: m.name, approximate: m.layout.approximate }, plan: p, gates }, null, 2));
1461
+ return p.fits ? 0 : 1;
1462
+ }
1463
+ io.out(formatPlan(m.name, m.quant, p));
1464
+ io.out("");
1465
+ io.out(formatGates(gates));
1466
+ return p.fits ? 0 : 1;
1467
+ }
1468
+ async function simulate(io, args, id, json) {
1469
+ if (!id) throw new UsageError("usage: clutor simulate <model> --cache <GiB>");
1470
+ const spec = CATALOG4.find((c2) => c2.id === id);
1471
+ if (!spec || spec.experts === 0) throw new UsageError(`pick a mixture-of-experts model: ${CATALOG4.filter((c2) => c2.experts).map((c2) => c2.id).join(", ")}`);
1472
+ const quant = flagString(args, "quant") ?? "Q4_K_M";
1473
+ const bpw = QUANTS4[quant];
1474
+ if (bpw === void 0) throw new UsageError(`unknown quantisation ${quant}`);
1475
+ const layout = approximateLayout3(spec, bpw);
1476
+ const cacheGiB = numFlag(args, "cache", 0, 4096) ?? 4;
1477
+ const tokens = flagInt(args, "tokens", 20, 3e3) ?? 300;
1478
+ const moe = layout.layers.filter((l) => l.experts.length).map((l) => l.index);
1479
+ const trace = generateRoutingTrace2({ moeLayers: moe, experts: spec.experts, used: spec.expertsUsed, tokens, seed: flagInt(args, "seed", 0, 2 ** 31) ?? 1, locality: numFlag(args, "locality", 0, 0.99) ?? 0.9 });
1480
+ const base = { cacheBytes: cacheGiB * GiB3, disk: { bytesPerSec: (numFlag(args, "disk", 0.01, 100) ?? 3) * GiB3, requestLatencySec: 1e-4 }, computePerLayerSec: (numFlag(args, "compute-ms", 0, 1e3) ?? 0.5) / 1e3 };
1481
+ const b = simulateStreaming2(layout, trace, { ...BASELINE2, ...base });
1482
+ const c = simulateStreaming2(layout, trace, { ...CLUTOR_DEFAULTS2, smartRoute: { ...CLUTOR_DEFAULTS2.smartRoute, enabled: flagBool(args, "smart-route") }, ...base });
1483
+ if (json) {
1484
+ io.out(JSON.stringify({ synthetic: true, baseline: b, clutor: c }, null, 2));
1485
+ return 0;
1486
+ }
1487
+ io.out(`${spec.name} (${quant}), ${cacheGiB} GiB expert cache, ${tokens} tokens, expert size ${(expertSizeBytes2(layout) / 2 ** 20).toFixed(1)} MiB`);
1488
+ io.out(formatSimulation(b, c));
1489
+ io.out("");
1490
+ io.out("This replays a SYNTHETIC routing trace (skewed expert popularity, neighbouring tokens that pick similar experts). It shows how each policy");
1491
+ io.out("behaves on a workload like that; it is not a measurement of any real model, and speed is modelled from the disk speed you give it.");
1492
+ return 0;
1493
+ }
1494
+ async function bench(io, args, json) {
1495
+ const h = hostInfo3();
1496
+ const file = flagString(args, "file");
1497
+ let path;
1498
+ let cleanup;
1499
+ if (file) path = resolve2(io.cwd, file);
1500
+ else {
1501
+ const dir = mkdtempSync(join6(tmpdir(), "clutor-bench-"));
1502
+ cleanup = () => rmSync(dir, { recursive: true, force: true });
1503
+ path = join6(dir, "bench.gguf");
1504
+ io.err("No --file given: writing a test model of about 1 GiB to the temp folder (it will be deleted).");
1505
+ await writeSyntheticGguf2(path, { arch: "qwen2moe", name: "bench-moe", layers: 8, embedding: 1024, heads: 16, kvHeads: 4, contextLength: 4096, expertCount: 32, expertsUsed: 4, expertFfn: 1024, denseFfn: 0, vocab: 4096, sharedExperts: 0 });
1506
+ }
1507
+ try {
1508
+ const r = await fileReader3(path);
1509
+ const layout = buildModelLayout3(await parseGguf3(r));
1510
+ await r.close();
1511
+ if (!layout.expertCount) throw new UsageError("this model has no experts; bench measures expert-sized reads");
1512
+ const m = await measureExpertReads2(path, layout, h.totalRamBytes, 96);
1513
+ const dm = diskModelFrom(m, expertSizeBytes2(layout), layout.layers.find((l) => l.experts.length)?.experts[0]?.length ?? 1);
1514
+ const bin = findEngine();
1515
+ if (flagBool(args, "engine") && !bin) throw new UsageError("the native engine is not built: run `clutor engine build` (needs a C compiler)");
1516
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1517
+ const cmp = bin ? await compareStrategies(bin, path, flagInt(args, "cache-mb", 1, 1e6) ?? Math.max(64, Math.floor(h.totalRamBytes / 2 ** 20 / 16)), flagInt(args, "tokens", 10, 1e5) ?? 300, flagString(args, "dense"), computeFlag(args)) : void 0;
1518
+ if (json) {
1519
+ io.out(JSON.stringify({ measurement: m, diskModel: dm, ...cmp ? { engine: cmp } : {} }, null, 2));
1520
+ return 0;
1521
+ }
1522
+ io.out(`${m.samples} random expert reads from ${path} (${fmtBytes(m.fileBytes)}):`);
1523
+ io.out(table([["throughput", `${(m.bytesPerSec / 2 ** 30).toFixed(2)} GiB/s`], ["mean per expert", `${(m.meanReadSec * 1e3).toFixed(2)} ms`], ["95th percentile", `${(m.p95ReadSec * 1e3).toFixed(2)} ms`], ["modelled request overhead", `${(dm.requestLatencySec * 1e6).toFixed(0)} us`]]));
1524
+ io.out("");
1525
+ io.out(m.likelyCold ? "The file is larger than 1.5x your RAM, so most reads came from the SSD." : `! The file (${fmtBytes(m.fileBytes)}) is not much bigger than your RAM (${fmtGiB2(h.totalRamBytes)}), so the operating system's cache probably served many of these reads. Treat this as an upper bound; run it on your real model file for a true figure.`);
1526
+ io.out(`Use it: clutor plan <model> --disk ${(m.bytesPerSec / 2 ** 30).toFixed(1)}`);
1527
+ if (cmp) io.out(`
1528
+ ${formatComparison(cmp)}`);
1529
+ if (cmp && flagBool(args, "receipt")) {
1530
+ const dir = resolve2(io.cwd, flagString(args, "dir") ?? ".");
1531
+ const signer = await loadOrCreateReceiptSigner3(dir);
1532
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1533
+ const brief = cmp.map((x) => `${x.name}: hit ${(x.report.totals.hitRate * 100).toFixed(1)}%, ${x.report.totals.missesPerToken.toFixed(1)} misses/token, ${x.report.totals.tokensPerSecond.toFixed(2)} passes/s`).join(" | ");
1534
+ const first = cmp[0].report;
1535
+ const rc = await sealReceipt2(signer, { v: 2, id: `bench_${Date.now().toString(36)}`, task: `bench ${path.split("/").pop()} (${first.model.layers} layers x ${first.model.experts} experts, top-${first.model.expertsUsed}, ${first.config.cacheSlots} cached, ${first.config.tokens} tokens, synthetic routing, peak RSS ${(Math.max(...cmp.map((x) => x.report.rss.peakBytes)) / 2 ** 20).toFixed(0)} MiB) -> ${brief}`, model: first.model.name, startedAt, finishedAt, status: "finished", files: [], commands: [], steps: cmp.length, previous: await lastReceiptTag3(dir) });
1536
+ await appendReceipt3(dir, rc);
1537
+ io.out(`
1538
+ Signed receipt ${rc.tag}
1539
+ Verify it with: clutor receipt verify ${rc.tag} ${dir}`);
1540
+ }
1541
+ if (!cmp) io.out("\nThe native engine is not built, so cache and prefetch strategies were not compared. Build it with: clutor engine build");
1542
+ return 0;
1543
+ } finally {
1544
+ cleanup?.();
1545
+ }
1546
+ }
1547
+ async function startBackendIfConfigured(config, io, existing) {
1548
+ if (config.backend.kind !== "llama-server") return void 0;
1549
+ const ls = config.backend.llamaServer;
1550
+ if (!ls.model) {
1551
+ io.err("backend.llamaServer.model is not set, so no model is loaded.");
1552
+ return void 0;
1553
+ }
1554
+ const binary = ls.binary ?? findLlamaServer();
1555
+ if (!binary) throw new ConfigError("llama-server was not found. Install llama.cpp (for example `brew install llama.cpp`) or set backend.llamaServer.binary.");
1556
+ const caps = await probeLlamaServer(binary);
1557
+ const r = await fileReader3(ls.model);
1558
+ let layout;
1559
+ try {
1560
+ layout = buildModelLayout3(await parseGguf3(r));
1561
+ } finally {
1562
+ await r.close();
1563
+ }
1564
+ const h = hostInfo3();
1565
+ const p = planMemory3(layout, { hostRamBytes: h.totalRamBytes, contextTokens: config.memory.contextTokens, kvBits: config.memory.kvBits });
1566
+ const { args, notes } = buildLlamaArgs({ modelPath: ls.model, port: ls.port, contextTokens: config.memory.contextTokens, kvBits: config.memory.kvBits, ...ls.threads ? { threads: ls.threads } : {}, plan: p, caps, extraArgs: ls.extraArgs, ...config.agent.prefixSnapshots ? { slotSavePath: ensureSlotDir() } : {} });
1567
+ for (const n of notes) io.err(`note: ${n}`);
1568
+ io.err(`Loading ${ls.model} (this can take minutes for a large model)...`);
1569
+ const b = existing ?? new BackendManager(ls.port);
1570
+ await b.start(binary, args);
1571
+ io.err("Model ready.");
1572
+ return b;
1573
+ }
1574
+ async function serve(io, args, dirArg) {
1575
+ const { config, root } = cfgFor(args, io, dirArg);
1576
+ const port = flagInt(args, "port", 0, 65535);
1577
+ const folders = [.../* @__PURE__ */ new Set([root, ...flagString(args, "folder") ? [resolve2(io.cwd, flagString(args, "folder"))] : []])];
1578
+ const backend = config.backend.kind === "llama-server" ? new BackendManager(config.backend.llamaServer.port) : void 0;
1579
+ if (backend && !flagBool(args, "no-start")) await startBackendIfConfigured(config, io, backend);
1580
+ const server = createClutorServer({
1581
+ root,
1582
+ config: { ...config, ...port !== void 0 ? { port } : {} },
1583
+ version: version(),
1584
+ ...backend ? { backend, startBackend: async () => void await startBackendIfConfigured(config, { ...io, out: () => void 0 }, backend) } : {},
1585
+ roots: folders,
1586
+ log: (l) => io.err(l)
1587
+ });
1588
+ const a = await server.listen();
1589
+ const base = `http://${a.host === "::" ? "localhost" : a.host}:${a.port}`;
1590
+ io.out(`Clutor ${version()} listening on ${base}`);
1591
+ io.out(` OpenAI API ${base}/v1 Ollama API ${base}`);
1592
+ io.out(` Agent, MCP ${base}/api/agent/run ${base}/api/mcp (need the token below)`);
1593
+ io.out(` Token ${server.token}`);
1594
+ io.out(` Folders ${folders.join(", ")}`);
1595
+ io.out(` Backend ${config.backend.kind}${backend?.state === "ready" ? " (loaded)" : ""}`);
1596
+ await new Promise((res) => {
1597
+ const stop = () => res();
1598
+ process.once("SIGINT", stop);
1599
+ process.once("SIGTERM", stop);
1600
+ });
1601
+ await server.close();
1602
+ return 0;
1603
+ }
1604
+ async function backendCmd(io, args) {
1605
+ const { config } = cfgFor(args, io);
1606
+ const binary = config.backend.llamaServer.binary ?? findLlamaServer();
1607
+ if (!binary) {
1608
+ io.err("llama-server was not found on PATH. Install llama.cpp (for example `brew install llama.cpp`), or set backend.llamaServer.binary.");
1609
+ return 1;
1610
+ }
1611
+ const caps = await probeLlamaServer(binary);
1612
+ io.out(`llama-server: ${binary}`);
1613
+ io.out(`Supports: ${["--jinja", "--cache-type-k", "--flash-attn", "--mlock", "--no-mmap"].map((f) => `${f} ${caps.flags.has(f) ? "yes" : "no"}`).join(", ")}`);
1614
+ const model = flagString(args, "model") ?? config.backend.llamaServer.model;
1615
+ if (!model) {
1616
+ io.out("Pass --model <file.gguf> to see the command clutor would run.");
1617
+ return 0;
1618
+ }
1619
+ const r = await fileReader3(resolve2(io.cwd, model));
1620
+ let layout;
1621
+ try {
1622
+ layout = buildModelLayout3(await parseGguf3(r));
1623
+ } finally {
1624
+ await r.close();
1625
+ }
1626
+ const p = planMemory3(layout, { hostRamBytes: hostInfo3().totalRamBytes, contextTokens: config.memory.contextTokens, kvBits: config.memory.kvBits });
1627
+ const { args: a, notes } = buildLlamaArgs({ modelPath: resolve2(io.cwd, model), port: config.backend.llamaServer.port, contextTokens: config.memory.contextTokens, kvBits: config.memory.kvBits, plan: p, caps, extraArgs: config.backend.llamaServer.extraArgs });
1628
+ io.out(`
1629
+ ${binary} ${a.map((x) => /\s/.test(x) ? JSON.stringify(x) : x).join(" ")}`);
1630
+ for (const n of notes) io.out(`note: ${n}`);
1631
+ return 0;
1632
+ }
1633
+ function contextCharsFor(contextTokens) {
1634
+ return Math.floor(contextTokens * 2.5);
1635
+ }
1636
+ async function code(io, args, task, session) {
1637
+ if (!task.trim()) throw new UsageError('usage: clutor code "<what to do>" [--dir folder]');
1638
+ const { config, root } = cfgFor(args, io);
1639
+ const dir = resolve2(io.cwd, flagString(args, "dir") ?? ".");
1640
+ if (!existsSync(dir)) throw new UsageError(`${dir} does not exist`);
1641
+ const backend = session ? session.backend ??= await startBackendIfConfigured(config, io) : await startBackendIfConfigured(config, io);
1642
+ try {
1643
+ const llm = llmFrom(config, backend);
1644
+ const project = new NodeProject3(dir);
1645
+ const ws = new ShadowWorkspace3(project);
1646
+ const auto = flagBool(args, "yes") || config.agent.autoApproveProjectCommands || session?.auto === true;
1647
+ if (auto) io.err("note: commands that run project code will run without asking (--yes). Only do this for projects you trust.");
1648
+ const key = await loadOrCreateReceiptSigner3(dir);
1649
+ const verifyFlag = flagString(args, "verify") ?? session?.verify;
1650
+ const verify = verifyFlag ?? config.agent.verifyCommand;
1651
+ const show = (e) => {
1652
+ if (e.type === "thought") io.out(` ${e.text.split("\n")[0]?.slice(0, 200)}`);
1653
+ else if (e.type === "tool_call") io.out(`> ${e.name} ${JSON.stringify(e.args).slice(0, 160)}`);
1654
+ else if (e.type === "tool_result") io.out(` ${e.ok ? "ok" : "failed"}: ${e.output.split("\n")[0]?.slice(0, 200)}`);
1655
+ else if (e.type === "invalid") io.out(` (invalid reply: ${e.error})`);
1656
+ else if (e.type === "verify") io.out(` verify ${!e.ran ? "NOT RUN" : e.ok ? "passed" : "FAILED"}${e.attempt > 0 ? ` (after ${e.attempt} retr${e.attempt === 1 ? "y" : "ies"})` : ""}: ${e.command}${!e.ran ? ` (${e.output.split("\n")[0]})` : ""}`);
1657
+ };
1658
+ const result = await runAgent2({
1659
+ task,
1660
+ llm,
1661
+ ws,
1662
+ maxSteps: flagInt(args, "max-steps", 1, 200) ?? config.agent.maxSteps,
1663
+ contextChars: contextCharsFor(config.memory.contextTokens),
1664
+ ...verify ? { verifyCommand: verify } : {},
1665
+ ...session?.notes.length ? { systemExtra: `Earlier in this session (for context only):
1666
+ ${session.notes.slice(-6).map((n) => `- ${n}`).join("\n")}` } : {},
1667
+ onEvent: show,
1668
+ runCommand: scratchRunner2(project, ws, { timeoutMs: config.agent.commandTimeoutSeconds * 1e3 }),
1669
+ approve: async ({ command, network }) => {
1670
+ if (auto) return true;
1671
+ if (verifyFlag !== void 0 && command === verifyFlag) return true;
1672
+ if (!io.prompt) return false;
1673
+ const a = await io.prompt(`Run \`${command}\`${network ? " (uses the network)" : ""} in a scratch copy of the project? [y/N] `);
1674
+ return /^y(es)?$/i.test(a.trim());
1675
+ },
1676
+ receipt: { signer: key, previous: await lastReceiptTag3(dir) }
1677
+ });
1678
+ if (result.receipt) await appendReceipt3(dir, result.receipt);
1679
+ if (session) {
1680
+ session.usage.promptTokens += result.usage.promptTokens;
1681
+ session.usage.completionTokens += result.usage.completionTokens;
1682
+ session.notes.push(`"${task.slice(0, 120)}" -> ${result.status}: ${result.summary.slice(0, 200)}`);
1683
+ }
1684
+ io.out("");
1685
+ io.out(`${result.status}: ${result.summary}`);
1686
+ for (const d of result.diffs) io.out(`
1687
+ ${d.patch || `${d.status} ${d.path}`}`);
1688
+ io.out(`
1689
+ ${result.diffs.length} file${result.diffs.length === 1 ? "" : "s"} changed, ${result.steps} steps, ${result.invalidReplies} invalid replies, ${result.violations} blocked attempts.`);
1690
+ if (result.verification) io.out(`Final check \`${result.verification.command}\`: ${!result.verification.ran ? "NOT RUN" : result.verification.ok ? "passed" : "FAILED"}${result.verification.retries ? ` (${result.verification.retries} self-heal ${result.verification.retries === 1 ? "turn" : "turns"})` : ""}`);
1691
+ if (result.diffs.length === 0) return result.status === "finished" ? 0 : 1;
1692
+ if (verifyFlag !== void 0 && !(result.verification?.ok === true)) {
1693
+ io.out(`Nothing was written: the check \`${verifyFlag}\` ${result.verification && !result.verification.ran ? "was not run" : "did not pass"}. The diff above is what the agent proposed.`);
1694
+ return 1;
1695
+ }
1696
+ let apply = flagBool(args, "apply");
1697
+ if (!apply && io.prompt) apply = /^y(es)?$/i.test((await io.prompt("Apply these changes to the folder? [y/N] ")).trim());
1698
+ if (!apply) {
1699
+ io.out("Nothing was written. Run again with --apply to write the changes.");
1700
+ return result.status === "finished" ? 0 : 1;
1701
+ }
1702
+ try {
1703
+ const r = await applyChanges2(project, await ws.changeSet());
1704
+ if (session) session.lastCheckpoint = { id: r.id, dir };
1705
+ io.out(`Applied ${r.applied.length} files. Undo with: ${session ? "/undo" : `clutor rollback ${r.id} --dir ${dir}`}`);
1706
+ } catch (e) {
1707
+ if (e instanceof ConflictError3) {
1708
+ io.err(`${e.message}
1709
+ Nothing was written.`);
1710
+ return 1;
1711
+ }
1712
+ throw e;
1713
+ }
1714
+ return result.status === "finished" ? 0 : 1;
1715
+ } finally {
1716
+ if (!session) await backend?.stop();
1717
+ void root;
1718
+ }
1719
+ }
1720
+ async function receipts(io, args, dirArg, json) {
1721
+ const dir = resolve2(io.cwd, dirArg ?? flagString(args, "dir") ?? ".");
1722
+ const v = await verifyReceiptLog3(dir);
1723
+ const all = await readReceipts2(dir);
1724
+ const cps = await listCheckpoints2(new NodeProject3(dir));
1725
+ if (json) {
1726
+ io.out(JSON.stringify({ verification: v, receipts: all, checkpoints: cps }, null, 2));
1727
+ return v.ok ? 0 : 1;
1728
+ }
1729
+ io.out(v.ok ? `Receipt chain intact: ${v.count} receipt${v.count === 1 ? "" : "s"}, hash chain and ECDSA signatures verified${v.keyIds.length ? ` (key id ${v.keyIds.join(", ")})` : ""}.` : `Receipt chain BROKEN at entry ${v.index}: ${v.reason}`);
1730
+ if (all.length) io.out(table(all.slice(-15).map((r) => [r.finishedAt.slice(0, 19), r.status, `${r.files.length} files`, r.task.slice(0, 50)]), ["finished", "status", "changed", "task"]));
1731
+ if (cps.length) io.out(`
1732
+ Checkpoints:
1733
+ ${table(cps.slice(0, 10).map((c) => [c.id, c.createdAt.slice(0, 19), `${c.files} files`]))}`);
1734
+ return v.ok ? 0 : 1;
1735
+ }
1736
+ async function rollback(io, args, id) {
1737
+ if (!id) throw new UsageError("usage: clutor rollback <checkpoint id> [--dir folder]");
1738
+ const project = new NodeProject3(resolve2(io.cwd, flagString(args, "dir") ?? "."));
1739
+ try {
1740
+ const r = await rollbackCheckpoint2(project, id, { force: flagBool(args, "force") });
1741
+ io.out(`Restored ${r.length} files.`);
1742
+ return 0;
1743
+ } catch (e) {
1744
+ if (e instanceof ConflictError3) {
1745
+ io.err(`${e.message}
1746
+ Use --force to restore anyway.`);
1747
+ return 1;
1748
+ }
1749
+ throw e;
1750
+ }
1751
+ }
1752
+ async function doctor(io, args, dirArg, json) {
1753
+ const h = hostInfo3();
1754
+ const checks = [];
1755
+ const add = (level, text) => void checks.push({ level, text });
1756
+ add(Number(h.nodeVersion.split(".")[0]) >= 20 ? "ok" : "fail", `Node.js ${h.nodeVersion}`);
1757
+ add("ok", `${h.platformName} ${h.arch}, ${h.cpuModel}, ${h.logicalCores} cores, ${fmtGiB2(h.totalRamBytes)} RAM`);
1758
+ if (h.totalRamBytes < 8 * GiB3) add("warn", "Under 8 GiB of RAM: only small models will run comfortably, and large mixture-of-experts models will be very slow.");
1759
+ let cfg;
1760
+ let root = resolve2(io.cwd, dirArg ?? ".");
1761
+ try {
1762
+ const l = cfgFor(args, io, dirArg);
1763
+ cfg = l.config;
1764
+ root = l.root;
1765
+ add(l.path ? "ok" : "info", l.path ? `Configuration ${l.path}` : `No ${CONFIG_FILE}: using defaults (run clutor init)`);
1766
+ } catch (e) {
1767
+ add("fail", e instanceof Error ? e.message : String(e));
1768
+ }
1769
+ const ls = findLlamaServer(process.env, cfg?.backend.llamaServer.binary ? [dirname2(cfg.backend.llamaServer.binary)] : []);
1770
+ add(ls ? "ok" : "warn", ls ? `llama-server at ${ls}` : "llama-server not found (install llama.cpp to run models; the planner, simulator and agent tools still work)");
1771
+ const eng = findEngine();
1772
+ add(eng ? "ok" : "info", eng ? `Native engine at ${eng}` : "Native engine not built (clutor engine build): cache and prefetch strategies cannot be measured without it");
1773
+ if (cfg) {
1774
+ const local = await listLocalModels(resolve2(root, cfg.models));
1775
+ add(local.length ? "ok" : "info", `${local.length} .gguf file${local.length === 1 ? "" : "s"} in ${resolve2(root, cfg.models)}`);
1776
+ if (cfg.backend.kind === "none") add("info", "No model backend configured: chat and the agent need one (backend.kind in the configuration).");
1777
+ if (cfg.backend.kind === "openai" && !cfg.backend.baseUrl) add("fail", "backend.kind is openai but backend.baseUrl is missing");
1778
+ if (cfg.backend.kind === "llama-server" && !cfg.backend.llamaServer.model) add("fail", "backend.kind is llama-server but backend.llamaServer.model is missing");
1779
+ if (cfg.agent.autoApproveProjectCommands) add("warn", "agent.autoApproveProjectCommands is on: commands that run project code run without asking.");
1780
+ if (cfg.host !== "127.0.0.1" && cfg.host !== "localhost") add("warn", `The server listens on ${cfg.host}: anyone who can reach it and learns the token can have the agent edit files.`);
1781
+ }
1782
+ if (json) {
1783
+ io.out(JSON.stringify(checks, null, 2));
1784
+ return checks.some((c) => c.level === "fail") ? 1 : 0;
1785
+ }
1786
+ const tag = { ok: "ok ", info: "info", warn: "warn", fail: "FAIL" };
1787
+ for (const c of checks) (c.level === "fail" ? io.err : io.out)(`${tag[c.level]} ${c.text}`);
1788
+ return checks.some((c) => c.level === "fail") ? 1 : 0;
1789
+ }
1790
+ function modelEndpoint(config, backend) {
1791
+ const b = config.backend;
1792
+ if (b.kind === "openai" && b.baseUrl) return { baseUrl: b.baseUrl, model: b.model ?? "default", ...b.apiKeyEnv && process.env[b.apiKeyEnv] ? { apiKey: process.env[b.apiKeyEnv] } : {} };
1793
+ if (backend) return { baseUrl: backend.baseUrl, model: "local" };
1794
+ throw new ConfigError('no model is configured. In clutor.config.json set backend.kind to "openai" (with baseUrl) or "llama-server" (with llamaServer.model).');
1795
+ }
1796
+ function llmFrom(config, backend) {
1797
+ const e = modelEndpoint(config, backend);
1798
+ return agentLlm(config, { ...e, local: config.backend.kind === "llama-server" });
1799
+ }
1800
+ async function chat(io, args) {
1801
+ const { config } = cfgFor(args, io);
1802
+ const backend = await startBackendIfConfigured(config, io);
1803
+ try {
1804
+ const llm = new OpenAiCompatibleClient2({ ...modelEndpoint(config, backend), toolMode: "json" });
1805
+ const history = [];
1806
+ const system = flagString(args, "system");
1807
+ if (system) history.push({ role: "system", content: system });
1808
+ const turn = async (text) => {
1809
+ history.push({ role: "user", content: text });
1810
+ const t0 = Date.now();
1811
+ const r = await llm.chat(history);
1812
+ const reply = r.content ?? "";
1813
+ history.push({ role: "assistant", content: reply });
1814
+ io.out(reply);
1815
+ const secs = (Date.now() - t0) / 1e3;
1816
+ const gen = r.usage?.completionTokens;
1817
+ io.err(`[${secs.toFixed(1)} s${gen ? `, ${gen} tokens, ${(gen / secs).toFixed(2)} tok/s measured end to end` : ""}]`);
1818
+ };
1819
+ const one = flagString(args, "prompt");
1820
+ if (one !== void 0) {
1821
+ await turn(one);
1822
+ return 0;
1823
+ }
1824
+ if (!io.prompt) throw new UsageError('chat needs a terminal, or --prompt "<text>"');
1825
+ io.out("clutor chat. /help lists the commands, /exit leaves.");
1826
+ for (; ; ) {
1827
+ const line = (await io.prompt("clt> ")).trim();
1828
+ if (!line) continue;
1829
+ if (line === "/exit" || line === "/quit") return 0;
1830
+ if (line === "/layers" || line === "/cache") {
1831
+ try {
1832
+ await engineSlashCommand(io, config, line.slice(1));
1833
+ } catch (e) {
1834
+ io.err(e instanceof Error ? e.message : String(e));
1835
+ }
1836
+ continue;
1837
+ }
1838
+ if (line === "/help") {
1839
+ io.out("/layers where the model file's bytes are, per layer\n/cache a streaming pass over the model file: cache, prefetch and disk waits (native engine)\n/reset clear the conversation\n/exit leave");
1840
+ continue;
1841
+ }
1842
+ if (line === "/reset") {
1843
+ history.length = system ? 1 : 0;
1844
+ io.out("(conversation cleared)");
1845
+ continue;
1846
+ }
1847
+ try {
1848
+ await turn(line);
1849
+ } catch (e) {
1850
+ io.err(e instanceof Error ? e.message : String(e));
1851
+ }
1852
+ }
1853
+ } finally {
1854
+ await backend?.stop();
1855
+ }
1856
+ }
1857
+ async function decide(io, args, json) {
1858
+ const prompt = flagString(args, "prompt");
1859
+ const choices = (flagString(args, "choices") ?? "").split(",").map((c) => c.trim()).filter(Boolean);
1860
+ if (!prompt || choices.length < 2 || choices.length > 26) throw new UsageError('usage: clutor decide --prompt "<text>" --choices "approve,reject,escalate" (2 to 26 choices)');
1861
+ const { config } = cfgFor(args, io);
1862
+ const backend = await startBackendIfConfigured(config, io);
1863
+ try {
1864
+ const out = await runDecide(modelEndpoint(config, backend), prompt, choices);
1865
+ if (json) io.out(JSON.stringify(out, null, 2));
1866
+ else {
1867
+ io.out(`Decision: ${out.choice}`);
1868
+ choices.forEach((c) => io.out(` ${c.padEnd(Math.max(...choices.map((x) => x.length)))} ${"#".repeat(Math.round(out.probabilities[c] * 30)).padEnd(30)} ${pct(out.probabilities[c])}`));
1869
+ io.out(`(the model put ${pct(out.probabilityMassOnChoices)} of its first-token probability on these letters; not a calibrated confidence; ${out.latencyMs} ms)`);
1870
+ }
1871
+ return 0;
1872
+ } finally {
1873
+ await backend?.stop();
1874
+ }
1875
+ }
1876
+ async function status(io, args, dirArg, json) {
1877
+ const h = hostInfo3();
1878
+ const { config, root, path } = cfgFor(args, io, dirArg);
1879
+ const local = await listLocalModels(resolve2(root, config.models));
1880
+ const ls = findLlamaServer(process.env, config.backend.llamaServer.binary ? [dirname2(config.backend.llamaServer.binary)] : []);
1881
+ const v = await verifyReceiptLog3(root);
1882
+ const s = { host: `${h.platformName} ${h.arch}, ${h.cpuModel}, ${h.logicalCores} cores`, ramBytes: h.totalRamBytes, configuration: path ?? null, backend: config.backend.kind, llamaServer: ls ?? null, models: local.length, receipts: v };
1883
+ if (json) {
1884
+ io.out(JSON.stringify(s, null, 2));
1885
+ return 0;
1886
+ }
1887
+ io.out(`Host ${s.host}, ${fmtGiB2(h.totalRamBytes)} RAM`);
1888
+ io.out(`Config ${path ?? "none (defaults)"}`);
1889
+ io.out(`Backend ${config.backend.kind}${ls ? `, llama-server at ${ls}` : ", llama-server not found"}`);
1890
+ io.out(`Models ${local.length} .gguf file${local.length === 1 ? "" : "s"} in ${resolve2(root, config.models)}`);
1891
+ io.out(`Receipts ${v.ok ? `intact (${v.count})` : `BROKEN at entry ${v.index}`}`);
1892
+ return 0;
1893
+ }
1894
+ async function receiptVerify(io, args, sub, target, dirArg) {
1895
+ if (sub === "key") {
1896
+ const dir = resolve2(io.cwd, target ?? flagString(args, "dir") ?? ".");
1897
+ const signer = await loadOrCreateReceiptSigner3(dir);
1898
+ io.out(`Key id ${await keyThumbprint(signer.publicJwk)}`);
1899
+ io.out(`Public key (share this; keep ${join6(".clutor", "keys")} private):
1900
+ ${JSON.stringify(signer.publicJwk, null, 2)}`);
1901
+ return 0;
1902
+ }
1903
+ if (sub !== "verify" || !target) throw new UsageError("usage: clutor receipt verify <clt_token | receipts.jsonl> [dir] [--key-id <id> | --pubkey <file.json>] or clutor receipt key [dir]");
1904
+ let trusted;
1905
+ const keyId = flagString(args, "key-id");
1906
+ const pubkey = flagString(args, "pubkey");
1907
+ if (keyId) trusted = [keyId];
1908
+ if (pubkey) trusted = [...trusted ?? [], await keyThumbprint(JSON.parse(readFileSync(resolve2(io.cwd, pubkey), "utf8")))];
1909
+ const isToken = target.startsWith("clt_");
1910
+ let all;
1911
+ if (isToken) all = await readReceipts2(resolve2(io.cwd, dirArg ?? flagString(args, "dir") ?? "."));
1912
+ else all = readFileSync(resolve2(io.cwd, target), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1913
+ const v = await verifyReceiptChain(all, trusted ? { trustedKeyIds: trusted } : {});
1914
+ if (!v.ok) {
1915
+ io.err(`INVALID at entry ${v.index}: ${v.reason}`);
1916
+ return 1;
1917
+ }
1918
+ const found = isToken ? all.find((r) => r.tag === target) : void 0;
1919
+ if (isToken && !found) {
1920
+ io.err("That receipt is not in this log.");
1921
+ return 1;
1922
+ }
1923
+ io.out(isToken && found ? `Valid: ${found.status} "${found.task.slice(0, 60)}" at ${found.finishedAt}, ${found.files.length} files, signed by ${found.keyId}.` : `Valid: ${all.length} receipt${all.length === 1 ? "" : "s"}, chain and ECDSA signatures check out.`);
1924
+ if (isToken && found) {
1925
+ const { tag, ...body } = found;
1926
+ io.out(`Decoded payload (this is exactly what the signature covers):
1927
+ ${JSON.stringify(body, null, 2)}
1928
+ Signature tag: ${tag}`);
1929
+ }
1930
+ io.out(`Signed by key${v.keyIds.length === 1 ? "" : "s"}: ${v.keyIds.join(", ") || "none"}`);
1931
+ io.out(trusted ? "The signing key is one you trust." : "No --key-id or --pubkey given: this shows the log is consistent and unedited, not whose key signed it.");
1932
+ return 0;
1933
+ }
1934
+ async function pull(io, args, id) {
1935
+ const { config, root } = cfgFor(args, io);
1936
+ const dir = resolve2(root, config.models);
1937
+ if (!id) {
1938
+ io.out(table(DOWNLOADS.map((d2) => [d2.id, fmtBytes(d2.bytes), d2.repo, d2.note]), ["id", "size", "from", "what it is"]));
1939
+ io.out("\nDownload one with: clutor pull <id>");
1940
+ return 0;
1941
+ }
1942
+ const d = DOWNLOADS.find((x) => x.id === id);
1943
+ if (!d) throw new UsageError(`unknown download "${id}"; run clutor pull to see the list`);
1944
+ const dest = join6(dir, d.file);
1945
+ if (existsSync(dest)) {
1946
+ io.out(`${dest} is already there.`);
1947
+ return 0;
1948
+ }
1949
+ if (!flagBool(args, "yes")) {
1950
+ if (!io.prompt) throw new UsageError(`this downloads ${fmtBytes(d.bytes)} from huggingface.co; run again with --yes to confirm`);
1951
+ if (!/^y(es)?$/i.test((await io.prompt(`Download ${d.file} (${fmtBytes(d.bytes)}) from huggingface.co to ${dir}? [y/N] `)).trim())) {
1952
+ io.out("Nothing downloaded.");
1953
+ return 0;
1954
+ }
1955
+ }
1956
+ let last = 0;
1957
+ const bytes = await downloadFile(downloadUrl(d), dest, { expectedBytes: d.bytes, onProgress: (done, total) => {
1958
+ const p = Math.floor(done / total * 10);
1959
+ if (p > last) {
1960
+ last = p;
1961
+ io.err(` ${p * 10}%`);
1962
+ }
1963
+ } });
1964
+ io.out(`Saved ${dest} (${fmtBytes(bytes)}).`);
1965
+ return 0;
1966
+ }
1967
+ function shiftFlag(args) {
1968
+ const v = flagString(args, "shift");
1969
+ if (v === void 0) return {};
1970
+ const m = /^(\d+),(\d+)$/.exec(v);
1971
+ if (!m || Number(m[2]) < 1) throw new UsageError("--shift wants AT,LEN, for example 80,10: measured tokens AT..AT+LEN-1 are uniform scan noise");
1972
+ return { shift: { at: Number(m[1]), len: Number(m[2]) } };
1973
+ }
1974
+ function computeFlag(args) {
1975
+ const c = flagString(args, "compute");
1976
+ if (c !== void 0 && c !== "kernel" && c !== "checksum") throw new UsageError("--compute must be kernel or checksum");
1977
+ return c;
1978
+ }
1979
+ async function compareStrategies(bin, file, cacheMb, tokens, dense, compute) {
1980
+ const common = { dense: dense ?? "auto", ...compute ? { compute } : {}, tokens, warmup: Math.min(50, Math.floor(tokens / 5)), cacheMb, seed: 1, noOsCache: true };
1981
+ const runs = [
1982
+ ["LRU, no prefetch", { ...common, policy: "lru", prefetch: false, ioThreads: 1 }],
1983
+ ["adaptive cache + prefetch", { ...common, policy: "adaptive", prefetch: true, ioThreads: 2 }],
1984
+ ["+ smart-route (2,12,0.015)", { ...common, policy: "adaptive", prefetch: true, ioThreads: 2, smart: "2,12,0.015" }]
1985
+ ];
1986
+ const out = [];
1987
+ for (const [name, o] of runs) out.push({ name, report: await engineStream(bin, file, o) });
1988
+ return out;
1989
+ }
1990
+ function formatComparison(c) {
1991
+ const r0 = c[0]?.report;
1992
+ if (!r0) return "";
1993
+ const gb = (x) => `${x.totals.gbPerToken.toFixed(3)}`;
1994
+ const rows = [
1995
+ ["hit rate", ...c.map((x) => pct(x.report.totals.hitRate))],
1996
+ ["misses per token", ...c.map((x) => x.report.totals.missesPerToken.toFixed(1))],
1997
+ ["GB read per token", ...c.map((x) => gb(x.report))],
1998
+ ["time waiting for disk (s)", ...c.map((x) => x.report.totals.ioWaitSeconds.toFixed(2))],
1999
+ ["wall time (s)", ...c.map((x) => x.report.totals.wallSeconds.toFixed(2))],
2000
+ ["passes per second", ...c.map((x) => x.report.totals.tokensPerSecond.toFixed(1))],
2001
+ ["scan-pollution rejects (admission filter)", ...c.map((x) => String(x.report.totals.admitRejects))],
2002
+ ["prefetch issued / late / unused", ...c.map((x) => `${x.report.totals.prefetchIssued} / ${x.report.totals.prefetchLate} / ${x.report.totals.prefetchWasted}`)],
2003
+ ["prefetch hit / late (hits served by prefetch)", ...c.map((x) => `${x.report.totals.stageHits} / ${x.report.totals.prefetchLate}`)],
2004
+ ["lookahead overlap (read time hidden)", ...c.map((x) => pct(x.report.totals.overlap))],
2005
+ ["disk wait / compute time (s)", ...c.map((x) => `${x.report.totals.ioWaitSeconds.toFixed(2)} / ${x.report.totals.computeSeconds.toFixed(2)}`)],
2006
+ ["substitutions per token", ...c.map((x) => (x.report.totals.substituted / Math.max(1, x.report.config.tokens)).toFixed(2))],
2007
+ ["peak RSS of the engine (MiB, ru_maxrss)", ...c.map((x) => (x.report.rss.peakBytes / 2 ** 20).toFixed(0))],
2008
+ ["substitutions (largest probability moved)", ...c.map((x) => `${x.report.totals.substituted} (${x.report.totals.maxDeltaP.toFixed(4)})`)]
2009
+ ];
2010
+ return [
2011
+ `MEASURED by the native engine on this machine: ${r0.model.name}, ${r0.config.cacheSlots} experts cached (${(r0.config.cacheSlots * r0.model.expertBytes / 2 ** 20).toFixed(0)} MiB), ${r0.config.tokens} tokens after ${r0.config.warmup} warm-up; dense weights ${r0.config.denseMode === "resident" ? "resident" : "re-read every token"}.`,
2012
+ `Disk reads, waits and cache behaviour are real. The routing is SYNTHETIC (a skewed random router with drifting topics), so hit rates here are not what a real model would see. ${r0.config.computeMode === "kernel" ? `The compute is a real Q4_0 x Q8_0 kernel (${r0.config.computeKernel}) run over every loaded byte, read as Q4_0 whatever its true type: genuine CPU work, not this model's maths.` : "The compute is a checksum, so this run measures I/O alone."}`,
2013
+ table(rows, ["", ...c.map((x) => x.name)])
2014
+ ].join("\n");
2015
+ }
2016
+ async function streamCmd(io, args, file, json) {
2017
+ if (!file) throw new UsageError("usage: clutor stream <model.gguf> [--tokens N] [--cache-mb N] [--policy lru|wtinylfu|adaptive] [--smart J,M,TAU|off] [--prefetch on|off]");
2018
+ const bin = findEngine();
2019
+ if (!bin) throw new UsageError("the native engine is not built: run `clutor engine build` (needs a C compiler)");
2020
+ const num = (n, lo, hi) => flagInt(args, n, lo, hi);
2021
+ const pol = flagString(args, "policy");
2022
+ if (pol && !["lru", "wtinylfu", "adaptive"].includes(pol)) throw new UsageError("--policy must be lru, wtinylfu or adaptive");
2023
+ const dn = flagString(args, "dense");
2024
+ if (dn && !["auto", "resident", "ring"].includes(dn)) throw new UsageError("--dense must be auto, resident or ring");
2025
+ const pf = flagString(args, "prefetch");
2026
+ if (pf && !["on", "off"].includes(pf)) throw new UsageError("--prefetch must be on or off");
2027
+ const o = {
2028
+ ...num("tokens", 1, 1e6) !== void 0 ? { tokens: num("tokens", 1, 1e6) } : {},
2029
+ ...num("warmup", 0, 1e6) !== void 0 ? { warmup: num("warmup", 0, 1e6) } : {},
2030
+ ...num("cache-mb", 1, 1e7) !== void 0 ? { cacheMb: num("cache-mb", 1, 1e7) } : {},
2031
+ ...num("io-threads", 1, 16) !== void 0 ? { ioThreads: num("io-threads", 1, 16) } : {},
2032
+ ...num("seed", 0, 2 ** 31) !== void 0 ? { seed: num("seed", 0, 2 ** 31) } : {},
2033
+ ...pol ? { policy: pol } : {},
2034
+ ...flagString(args, "smart") ? { smart: flagString(args, "smart") } : {},
2035
+ ...pf ? { prefetch: pf === "on" } : {},
2036
+ ...dn ? { dense: dn } : {},
2037
+ ...computeFlag(args) ? { compute: computeFlag(args) } : {},
2038
+ ...flagBool(args, "no-os-cache") ? { noOsCache: true } : {},
2039
+ ...num("window", 1, 1e5) !== void 0 ? { window: num("window", 1, 1e5) } : {},
2040
+ ...shiftFlag(args)
2041
+ };
2042
+ const path = resolve2(io.cwd, file);
2043
+ if (!existsSync(path)) throw new UsageError(`${path} does not exist`);
2044
+ const r = await engineStream(bin, path, o);
2045
+ if (json) {
2046
+ io.out(JSON.stringify(r, null, 2));
2047
+ return 0;
2048
+ }
2049
+ io.out(describeStream(r));
2050
+ return 0;
2051
+ }
2052
+ function describeStream(r) {
2053
+ const t = r.totals;
2054
+ const lines = [
2055
+ `${r.model.name} (${r.model.arch}): ${r.model.layers} layers, ${r.model.experts} experts (${r.model.expertsUsed} used), ${(r.model.expertBytes / 2 ** 20).toFixed(2)} MiB each`,
2056
+ `policy ${r.config.policy}, ${r.config.cacheSlots} cached + ${r.config.stageSlots} staged experts, smart-route ${r.config.smartRoute ? `on (J=${r.config.J}, M=${r.config.M}, tau=${r.config.tau})` : "off"}, prefetch ${r.config.prefetch ? "on" : "off"}, dense weights ${r.config.denseMode === "resident" ? `resident (${(r.config.densePreloadBytes / 2 ** 20).toFixed(0)} MiB read once)` : "re-read every token"}`,
2057
+ `MEASURED ${((t.demandBytes + t.prefetchBytes + t.denseBytes) / 1e9).toFixed(2)} GB read; waited ${t.ioWaitSeconds.toFixed(2)} s of ${t.wallSeconds.toFixed(2)} s; ${pct(t.overlap)} of read time overlapped with other work`,
2058
+ `MEASURED cache ${pct(t.hitRate)} hits (${t.stageHits} served by prefetch), ${t.missesPerToken.toFixed(1)} misses per token, ${t.admitRejects} admission rejects`,
2059
+ `MEASURED prefetch ${t.prefetchIssued} issued, ${t.prefetchLate} late, ${t.prefetchWasted} never used`
2060
+ ];
2061
+ if (r.config.smartRoute) lines.push(`MEASURED smart-route ${t.substituted} substitutions; the largest probability moved was ${t.maxDeltaP.toFixed(4)} (limit ${r.config.tau})`);
2062
+ lines.push(`MEASURED peak resident memory of the engine process (ru_maxrss): ${(r.rss.peakBytes / 2 ** 20).toFixed(1)} MiB; COMPUTED from the buffers and tables it allocates: ${(r.rss.predictedBytes / 2 ** 20).toFixed(1)} MiB (${(r.rss.startBytes / 2 ** 20).toFixed(1)} MiB of that was there before the run)`);
2063
+ if (r.windows.length) {
2064
+ const note = r.scenario.shiftLen > 0 ? `; tokens ${r.scenario.shiftAt + 1}-${r.scenario.shiftAt + r.scenario.shiftLen} are uniform scan noise, the rest one tight topic` : "";
2065
+ lines.push(`MEASURED hit rate and peak RSS per window of tokens${note}:
2066
+ ${table(r.windows.map((w, i) => [`${i ? r.windows[i - 1].tokEnd + 1 : 1}-${w.tokEnd}`, pct(w.accesses ? w.hits / w.accesses : 0), `${(w.peakRssBytes / 2 ** 20).toFixed(0)} MiB`]), ["tokens", "hit rate", "peak RSS"])}`);
2067
+ }
2068
+ lines.push(`${t.tokensPerSecond.toFixed(1)} passes per second over SYNTHETIC routing; the compute is ${r.config.computeMode === "kernel" ? `a Q4_0 x Q8_0 kernel (${r.config.computeKernel}) over the loaded bytes (${t.computeSeconds.toFixed(2)} s in total), not this model's maths` : "a checksum"}: this is not model tokens per second.`);
2069
+ return lines.join("\n");
2070
+ }
2071
+ async function engineCmd(io, sub) {
2072
+ if (sub === "where") {
2073
+ const bin = findEngine();
2074
+ io.out(bin ?? "not built (run: clutor engine build)");
2075
+ return bin ? 0 : 1;
2076
+ }
2077
+ if (sub === "build") {
2078
+ io.err("Compiling the native engine...");
2079
+ try {
2080
+ io.out(`Built ${await buildEngine()}`);
2081
+ return 0;
2082
+ } catch (e) {
2083
+ if (e instanceof EngineError) {
2084
+ io.err(e.message);
2085
+ return 1;
2086
+ }
2087
+ throw e;
2088
+ }
2089
+ }
2090
+ throw new UsageError("usage: clutor engine build | where");
2091
+ }
2092
+ async function engineSlashCommand(io, config, what) {
2093
+ const file = config.backend.llamaServer.model;
2094
+ const bin = findEngine();
2095
+ if (!file) {
2096
+ io.out("No model file is configured (backend.llamaServer.model), so there is nothing to describe.");
2097
+ return;
2098
+ }
2099
+ if (!bin) {
2100
+ io.out("The native engine is not built: run `clutor engine build`.");
2101
+ return;
2102
+ }
2103
+ if (what === "layers") {
2104
+ const info = await engineInfo(bin, file);
2105
+ io.out(`${info.name} (${info.arch}): ${info.layers} layers, ${info.experts} experts (${info.expertsUsed} used)`);
2106
+ const rows = info.layerTable.map((l, i) => [String(i), `${(l.denseBytes / 2 ** 20).toFixed(1)} MiB`, `${(l.expertBytes / 2 ** 20).toFixed(2)} MiB`, `${(l.routerBytes / 2 ** 10).toFixed(0)} KiB`]);
2107
+ io.out(table(rows.length > 24 ? [...rows.slice(0, 12), ["...", "", "", ""], ...rows.slice(-6)] : rows, ["layer", "dense (read every token)", "one expert", "router (resident)"]));
2108
+ return;
2109
+ }
2110
+ io.out("Running a short streaming pass over this model's file (synthetic routing; the chat itself is served by llama-server, which reports no cache data)...");
2111
+ const r = await engineStream(bin, file, { tokens: 30, warmup: 5, cacheMb: 256, policy: "adaptive", prefetch: true, ioThreads: 2, noOsCache: true });
2112
+ io.out(describeStream(r));
2113
+ const worst = [...r.layers].filter((l) => l.accesses > 0).sort((a, b) => b.ioWaitSeconds - a.ioWaitSeconds).slice(0, 5);
2114
+ io.out(`Layers that waited longest: ${worst.map((l) => `${l.layer} (${l.ioWaitSeconds.toFixed(2)} s, ${pct(l.hits / l.accesses)} hits)`).join(", ")}`);
2115
+ }
2116
+
2117
+ export {
2118
+ parseArgs,
2119
+ flagString,
2120
+ flagBool,
2121
+ flagInt,
2122
+ UsageError,
2123
+ DecideError,
2124
+ runDecide,
2125
+ sealDecision,
2126
+ findLlamaServer,
2127
+ probeLlamaServer,
2128
+ buildLlamaArgs,
2129
+ BackendManager,
2130
+ SlotSnapshots,
2131
+ withPrefixSnapshots,
2132
+ ModelError,
2133
+ resolveModel,
2134
+ listLocalModels,
2135
+ DOWNLOADS,
2136
+ downloadUrl,
2137
+ DownloadError,
2138
+ downloadFile,
2139
+ parseSse,
2140
+ toOpenAiBody,
2141
+ fromOpenAiReply,
2142
+ streamToOllama,
2143
+ slotDir,
2144
+ ensureSlotDir,
2145
+ agentLlm,
2146
+ handleMcp,
2147
+ ConflictError,
2148
+ AgentSession,
2149
+ normaliseRoot,
2150
+ isInside,
2151
+ createClutorServer,
2152
+ EngineError,
2153
+ engineSourceDir,
2154
+ findEngine,
2155
+ run,
2156
+ buildEngine,
2157
+ engineInfo,
2158
+ engineStream,
2159
+ version,
2160
+ main,
2161
+ startBackendIfConfigured
2162
+ };
2163
+ //# sourceMappingURL=chunk-6WAEQCCP.js.map