@otto-code/brain 0.7.5

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.
Files changed (150) hide show
  1. package/bin/otto-brain +2 -0
  2. package/dist/bench/context-corpus.d.ts +58 -0
  3. package/dist/bench/context-corpus.js +210 -0
  4. package/dist/bench/corpus.d.ts +31 -0
  5. package/dist/bench/corpus.js +379 -0
  6. package/dist/bench/curated-repos.d.ts +35 -0
  7. package/dist/bench/curated-repos.js +41 -0
  8. package/dist/bench/health.d.ts +50 -0
  9. package/dist/bench/health.js +112 -0
  10. package/dist/bench/index.d.ts +103 -0
  11. package/dist/bench/index.js +209 -0
  12. package/dist/bench/mine.d.ts +32 -0
  13. package/dist/bench/mine.js +101 -0
  14. package/dist/bench/repo-task.d.ts +93 -0
  15. package/dist/bench/repo-task.js +380 -0
  16. package/dist/bench/repo.d.ts +125 -0
  17. package/dist/bench/repo.js +195 -0
  18. package/dist/bench/rescore.d.ts +41 -0
  19. package/dist/bench/rescore.js +127 -0
  20. package/dist/bench/tasks.d.ts +124 -0
  21. package/dist/bench/tasks.js +1249 -0
  22. package/dist/bench/verify.d.ts +142 -0
  23. package/dist/bench/verify.js +350 -0
  24. package/dist/cli.d.ts +13 -0
  25. package/dist/cli.js +72 -0
  26. package/dist/commands/bench.d.ts +20 -0
  27. package/dist/commands/bench.js +222 -0
  28. package/dist/commands/calibrate.d.ts +19 -0
  29. package/dist/commands/calibrate.js +75 -0
  30. package/dist/commands/catalog.d.ts +32 -0
  31. package/dist/commands/catalog.js +48 -0
  32. package/dist/commands/config.d.ts +13 -0
  33. package/dist/commands/config.js +90 -0
  34. package/dist/commands/lifecycle.d.ts +29 -0
  35. package/dist/commands/lifecycle.js +194 -0
  36. package/dist/commands/pull.d.ts +22 -0
  37. package/dist/commands/pull.js +135 -0
  38. package/dist/commands/report.d.ts +10 -0
  39. package/dist/commands/report.js +34 -0
  40. package/dist/commands/rescore.d.ts +11 -0
  41. package/dist/commands/rescore.js +48 -0
  42. package/dist/commands/runtime.d.ts +21 -0
  43. package/dist/commands/runtime.js +62 -0
  44. package/dist/commands/scan.d.ts +24 -0
  45. package/dist/commands/scan.js +55 -0
  46. package/dist/commands/search.d.ts +31 -0
  47. package/dist/commands/search.js +135 -0
  48. package/dist/commands/share.d.ts +26 -0
  49. package/dist/commands/share.js +148 -0
  50. package/dist/commands/sweep.d.ts +21 -0
  51. package/dist/commands/sweep.js +69 -0
  52. package/dist/commands/ui.d.ts +9 -0
  53. package/dist/commands/ui.js +28 -0
  54. package/dist/config/env.d.ts +10 -0
  55. package/dist/config/env.js +48 -0
  56. package/dist/config/index.d.ts +9 -0
  57. package/dist/config/index.js +9 -0
  58. package/dist/config/otto-home.d.ts +2 -0
  59. package/dist/config/otto-home.js +24 -0
  60. package/dist/config/paths.d.ts +20 -0
  61. package/dist/config/paths.js +33 -0
  62. package/dist/config/private-files.d.ts +3 -0
  63. package/dist/config/private-files.js +32 -0
  64. package/dist/config/profiles.d.ts +34 -0
  65. package/dist/config/profiles.js +116 -0
  66. package/dist/config/schema.d.ts +1035 -0
  67. package/dist/config/schema.js +191 -0
  68. package/dist/config/store.d.ts +11 -0
  69. package/dist/config/store.js +73 -0
  70. package/dist/gguf.d.ts +61 -0
  71. package/dist/gguf.js +231 -0
  72. package/dist/gpu.d.ts +12 -0
  73. package/dist/gpu.js +49 -0
  74. package/dist/index.d.ts +14 -0
  75. package/dist/index.js +13 -0
  76. package/dist/main.d.ts +2 -0
  77. package/dist/main.js +5 -0
  78. package/dist/models/dirs.d.ts +9 -0
  79. package/dist/models/dirs.js +21 -0
  80. package/dist/models/download.d.ts +31 -0
  81. package/dist/models/download.js +105 -0
  82. package/dist/models/enrich.d.ts +36 -0
  83. package/dist/models/enrich.js +68 -0
  84. package/dist/models/hf.d.ts +62 -0
  85. package/dist/models/hf.js +114 -0
  86. package/dist/models/index.d.ts +15 -0
  87. package/dist/models/index.js +49 -0
  88. package/dist/models/manage.d.ts +27 -0
  89. package/dist/models/manage.js +112 -0
  90. package/dist/models/pick.d.ts +3 -0
  91. package/dist/models/pick.js +36 -0
  92. package/dist/models/scan.d.ts +13 -0
  93. package/dist/models/scan.js +185 -0
  94. package/dist/ops/archive.d.ts +34 -0
  95. package/dist/ops/archive.js +105 -0
  96. package/dist/ops/calibrate.d.ts +49 -0
  97. package/dist/ops/calibrate.js +74 -0
  98. package/dist/ops/report.d.ts +26 -0
  99. package/dist/ops/report.js +532 -0
  100. package/dist/ops/results.d.ts +207 -0
  101. package/dist/ops/results.js +250 -0
  102. package/dist/ops/sweep.d.ts +77 -0
  103. package/dist/ops/sweep.js +126 -0
  104. package/dist/output/index.d.ts +5 -0
  105. package/dist/output/index.js +5 -0
  106. package/dist/output/render.d.ts +4 -0
  107. package/dist/output/render.js +96 -0
  108. package/dist/output/types.d.ts +52 -0
  109. package/dist/output/types.js +9 -0
  110. package/dist/output/with-output.d.ts +3 -0
  111. package/dist/output/with-output.js +42 -0
  112. package/dist/run.d.ts +5 -0
  113. package/dist/run.js +19 -0
  114. package/dist/runtime/args.d.ts +21 -0
  115. package/dist/runtime/args.js +71 -0
  116. package/dist/runtime/index.d.ts +13 -0
  117. package/dist/runtime/index.js +41 -0
  118. package/dist/runtime/lmstudio.d.ts +10 -0
  119. package/dist/runtime/lmstudio.js +88 -0
  120. package/dist/runtime/managed.d.ts +26 -0
  121. package/dist/runtime/managed.js +148 -0
  122. package/dist/service/model-selector.d.ts +36 -0
  123. package/dist/service/model-selector.js +123 -0
  124. package/dist/service/pid-lock.d.ts +18 -0
  125. package/dist/service/pid-lock.js +50 -0
  126. package/dist/service/router.d.ts +144 -0
  127. package/dist/service/router.js +746 -0
  128. package/dist/service/scheduler.d.ts +74 -0
  129. package/dist/service/scheduler.js +110 -0
  130. package/dist/service/serve.d.ts +41 -0
  131. package/dist/service/serve.js +278 -0
  132. package/dist/service/supervisor.d.ts +67 -0
  133. package/dist/service/supervisor.js +222 -0
  134. package/dist/service/tailscale.d.ts +19 -0
  135. package/dist/service/tailscale.js +62 -0
  136. package/dist/service/tls.d.ts +77 -0
  137. package/dist/service/tls.js +192 -0
  138. package/dist/sysmon.d.ts +50 -0
  139. package/dist/sysmon.js +103 -0
  140. package/dist/tui/app.d.ts +208 -0
  141. package/dist/tui/app.js +1801 -0
  142. package/dist/tui/screen.d.ts +72 -0
  143. package/dist/tui/screen.js +176 -0
  144. package/dist/types.d.ts +76 -0
  145. package/dist/types.js +7 -0
  146. package/dist/version.d.ts +2 -0
  147. package/dist/version.js +8 -0
  148. package/dist/vram.d.ts +67 -0
  149. package/dist/vram.js +132 -0
  150. package/package.json +58 -0
@@ -0,0 +1,1801 @@
1
+ import { Screen, box, meter, style, pad, truncate, width, onKeys } from "./screen.js";
2
+ import { scanModels, managedModelsDir, diskUsage, totalModelBytes, planDelete, deleteModelFiles, listRepoQuants, searchModels, downloadRepoFiles, resolveHfToken, } from "../models/index.js";
3
+ import * as profiles from "../config/profiles.js";
4
+ import { loadBrainConfig, loadProfilesStore, saveProfilesStore } from "../config/index.js";
5
+ import * as vram from "../vram.js";
6
+ import * as gpu from "../gpu.js";
7
+ import { calibrate } from "../ops/calibrate.js";
8
+ import { sweep } from "../ops/sweep.js";
9
+ import * as results from "../ops/results.js";
10
+ import * as archive from "../ops/archive.js";
11
+ import { Supervisor } from "../service/supervisor.js";
12
+ import { createRouter, Telemetry } from "../service/router.js";
13
+ import * as sysmon from "../sysmon.js";
14
+ import http from "node:http";
15
+ // The config panel holds short fields, so keep it compact and give the rest of
16
+ // the width to the model list (long model names need the room).
17
+ const CONFIG_MIN_WIDTH = 28;
18
+ const CONFIG_MAX_WIDTH = 46;
19
+ const MODEL_MIN_WIDTH = 24;
20
+ const CACHE_CYCLE = ["q4_0", "q5_1", "q8_0", "f16"];
21
+ const REASONING_CYCLE = [0, 512, 1024, 1536, 3072, -1];
22
+ // Capability nudges for the model-list ordering only - NOT the displayed score.
23
+ // Small and bounded so they reorder near-ties: a vision/thinking model edges
24
+ // ahead of a comparable one, but never beats a materially higher benchmark score
25
+ // (a 70% vision model stays below a 99% one). MTP is excluded on purpose - it is
26
+ // a performance potential, not a capability, and shows no benefit on local
27
+ // hardware; draft models likewise do not help, so nothing boosts for them.
28
+ const VISION_RANK_BONUS = 0.03;
29
+ const THINKING_RANK_BONUS = 0.02;
30
+ /** Editable configuration fields, in display order. */
31
+ export const FIELDS = [
32
+ {
33
+ key: "contextSize",
34
+ label: "Context",
35
+ kind: "number",
36
+ format: (p, ctx) => {
37
+ const native = ctx.model?.metadata?.contextLength;
38
+ return `${p.contextSize.toLocaleString()}${native ? style.grey + " / " + native.toLocaleString() + style.reset : ""}`;
39
+ },
40
+ step: 8192,
41
+ min: 1024,
42
+ max: (ctx) => ctx.model?.metadata?.contextLength || 1000000,
43
+ },
44
+ {
45
+ key: "cacheTypeK",
46
+ label: "KV cache K",
47
+ kind: "cycle",
48
+ values: CACHE_CYCLE,
49
+ format: (p) => p.cacheTypeK,
50
+ },
51
+ {
52
+ key: "cacheTypeV",
53
+ label: "KV cache V",
54
+ kind: "cycle",
55
+ values: CACHE_CYCLE,
56
+ format: (p) => p.cacheTypeV,
57
+ },
58
+ {
59
+ key: "flashAttention",
60
+ label: "Flash attention",
61
+ kind: "toggle",
62
+ format: (p) => p.flashAttention
63
+ ? `${style.brightGreen}on${style.reset}`
64
+ : `${style.yellow}off${style.reset}`,
65
+ note: (p) => !p.flashAttention && p.cacheTypeV !== "f16"
66
+ ? "quantised V cache requires flash attention"
67
+ : null,
68
+ },
69
+ {
70
+ key: "vision",
71
+ label: "Vision",
72
+ kind: "toggle",
73
+ format: (p, ctx) => {
74
+ if (!ctx.model?.mmprojPath)
75
+ return `${style.grey}no projector${style.reset}`;
76
+ return p.vision ? `${style.brightGreen}on${style.reset}` : "off";
77
+ },
78
+ enabled: (ctx) => Boolean(ctx.model?.mmprojPath),
79
+ },
80
+ {
81
+ key: "reasoningBudget",
82
+ label: "Reasoning budget",
83
+ kind: "cycle",
84
+ values: REASONING_CYCLE,
85
+ format: (p) => {
86
+ if (p.reasoningBudget === -1)
87
+ return `${style.red}unrestricted${style.reset}`;
88
+ if (p.reasoningBudget === 0)
89
+ return `${style.cyan}thinking off${style.reset}`;
90
+ return `${p.reasoningBudget} tokens`;
91
+ },
92
+ note: (p) => p.reasoningBudget === -1 ? "unrestricted budget can consume every token on thinking" : null,
93
+ },
94
+ {
95
+ key: "gpuLayers",
96
+ label: "GPU layers",
97
+ kind: "number",
98
+ step: 1,
99
+ min: 0,
100
+ max: () => 999,
101
+ format: (p) => p.gpuLayers >= 999 ? `all ${style.grey}(999)${style.reset}` : String(p.gpuLayers),
102
+ },
103
+ {
104
+ key: "parallelSlots",
105
+ label: "Parallel slots",
106
+ kind: "number",
107
+ step: 1,
108
+ min: 1,
109
+ max: () => 16,
110
+ format: (p) => String(p.parallelSlots),
111
+ note: (p) => p.parallelSlots > 1 ? `${p.parallelSlots} concurrent requests, sharing one KV pool` : null,
112
+ },
113
+ ];
114
+ export class App {
115
+ constructor({ runtime, listenPort, listenHost }) {
116
+ this.filterMode = false;
117
+ this.confirming = null;
118
+ this.picker = null;
119
+ this.search = null;
120
+ this.disk = null;
121
+ this.runtime = runtime;
122
+ this.listenPort = listenPort;
123
+ this.listenHost = listenHost;
124
+ this.screen = new Screen();
125
+ this.store = loadProfilesStore();
126
+ this.catalog = [];
127
+ this.filter = "";
128
+ this.selected = 0;
129
+ this.scrollTop = 0;
130
+ this.focus = "models";
131
+ this.fieldIndex = 0;
132
+ this.editing = null;
133
+ this.busy = false;
134
+ this.status = { text: "loading model catalog…", tone: "info" };
135
+ this.gpuInfo = null;
136
+ this.profile = null;
137
+ this.sweepResult = null;
138
+ this.cpuSampler = sysmon.createCpuSampler();
139
+ this.sys = null;
140
+ this.viewMode = "standard"; // standard | logs | help | bench
141
+ this.benchRunning = false; // a suite is executing right now
142
+ this.benchProgress = []; // live per-task lines during a run
143
+ this.benchResults = []; // cached leaderboard (results.latestPerConfig, ranked)
144
+ this.benchModelId = null; // model id of the most recent / active run
145
+ this.rankings = new Map(); // model id/name -> averaged benchmark rank + score
146
+ this.rankedModels = []; // ranked list (mean of runs), best first | help
147
+ this.telemetry = new Telemetry();
148
+ this.supervisor = new Supervisor({ runtime });
149
+ this.routerServer = null;
150
+ this.supervisor.on("state", () => this.draw());
151
+ this.supervisor.on("log", () => {
152
+ if (this.viewMode === "logs")
153
+ this.draw();
154
+ });
155
+ this.supervisor.on("ready", (payload) => {
156
+ this.setStatus(`loaded in ${payload.loadSeconds.toFixed(1)}s, using ${vram.formatGiB(payload.deltaBytes)} VRAM`, "good");
157
+ });
158
+ this.supervisor.on("crashed", (why) => this.setStatus(`server crashed: ${why}`, "bad"));
159
+ }
160
+ // ---------------------------------------------------------------- lifecycle
161
+ async run() {
162
+ this.screen.enter();
163
+ this.detach = onKeys((key) => this.onKey(key));
164
+ process.stdout.on("resize", () => {
165
+ this.screen.invalidate();
166
+ this.draw();
167
+ });
168
+ this.gpuInfo = await gpu.query();
169
+ this.reload();
170
+ await this.startRouter();
171
+ this.draw();
172
+ this.ticker = setInterval(async () => {
173
+ // One combined reading; only poll /slots while a server is actually up.
174
+ this.sys = await sysmon.sample(this.cpuSampler, this.supervisor.state === "ready"
175
+ ? { host: this.supervisor.host, port: this.supervisor.internalPort }
176
+ : {});
177
+ if (this.sys.gpu)
178
+ this.gpuInfo = this.sys.gpu;
179
+ this.draw();
180
+ }, 2000);
181
+ await new Promise((resolve) => {
182
+ this.done = () => resolve();
183
+ });
184
+ }
185
+ async shutdown() {
186
+ clearInterval(this.ticker);
187
+ this.detach?.();
188
+ this.screen.leave();
189
+ await this.supervisor.stop();
190
+ const server = this.routerServer;
191
+ if (server)
192
+ await new Promise((r) => {
193
+ server.close(() => r());
194
+ });
195
+ this.done?.();
196
+ }
197
+ /** Fit a model to the live VRAM budget and (re)start the server on it. */
198
+ async loadModelFitted(target) {
199
+ const info = this.gpuInfo || (await gpu.query());
200
+ let profile = profiles.forModel(this.store, target);
201
+ if (info) {
202
+ const fit = vram.fitToBudget({
203
+ model: target,
204
+ profile,
205
+ calibration: profiles.getCalibration(this.store, target, profile),
206
+ totalVramBytes: info.totalBytes,
207
+ });
208
+ if (!fit.adjusted && !fit.budget.fits)
209
+ throw new Error(fit.reason ?? undefined);
210
+ profile = fit.profile;
211
+ }
212
+ this.setStatus(`switching to ${target.displayName}…`, "info");
213
+ await this.supervisor.start(target, profile);
214
+ }
215
+ async startRouter() {
216
+ const handler = createRouter({
217
+ supervisor: this.supervisor,
218
+ telemetry: this.telemetry,
219
+ logger: { warn: (m) => this.setStatus(m, "warn") },
220
+ getCatalog: () => this.catalog,
221
+ loadModel: (m) => this.loadModelFitted(m),
222
+ });
223
+ const server = http.createServer(handler);
224
+ this.routerServer = server;
225
+ server.keepAliveTimeout = 75000;
226
+ server.requestTimeout = 0;
227
+ await new Promise((resolve, reject) => {
228
+ server.once("error", reject);
229
+ server.listen(this.listenPort, this.listenHost, () => resolve());
230
+ }).catch((error) => {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ this.setStatus(`could not bind ${this.listenHost}:${this.listenPort} - ${message}`, "bad");
233
+ this.routerServer = null;
234
+ });
235
+ }
236
+ // -------------------------------------------------------------------- state
237
+ reload() {
238
+ const config = loadBrainConfig();
239
+ this.catalog = scanModels(config);
240
+ this.loadRankings();
241
+ this.selected = Math.min(this.selected, Math.max(0, this.visible.length - 1));
242
+ this.syncProfile();
243
+ void this.refreshDisk();
244
+ this.setStatus(`${this.catalog.length} models found`, "info");
245
+ }
246
+ /** Mean-of-runs rank + score per model, for ordering and badging the list. */
247
+ loadRankings() {
248
+ let ranked = [];
249
+ try {
250
+ ranked = results.rankModels();
251
+ }
252
+ catch {
253
+ /* no results yet */
254
+ }
255
+ const map = new Map();
256
+ for (const m of ranked) {
257
+ if (m.id)
258
+ map.set(m.id, m);
259
+ map.set(m.displayName, m);
260
+ }
261
+ this.rankings = map;
262
+ this.rankedModels = ranked;
263
+ }
264
+ rankOf(model) {
265
+ if (!model)
266
+ return null;
267
+ return ((model.id && this.rankings.get(model.id)) || this.rankings.get(model.displayName) || null);
268
+ }
269
+ /** Small, bounded ordering nudge for more-capable models (vision, thinking). */
270
+ capabilityBonus(model) {
271
+ return ((model.mmprojPath ? VISION_RANK_BONUS : 0) +
272
+ (model.metadata?.reasoning || model.thinking ? THINKING_RANK_BONUS : 0));
273
+ }
274
+ get visible() {
275
+ let list = this.catalog;
276
+ if (this.filter) {
277
+ const needle = this.filter.toLowerCase();
278
+ list = list.filter((m) => m.displayName.toLowerCase().includes(needle));
279
+ }
280
+ // Order: best known benchmark first (nudged up slightly for vision/thinking),
281
+ // then any not-yet-benchmarked models by capability and name.
282
+ return [...list].sort((a, b) => {
283
+ const ra = this.rankOf(a);
284
+ const rb = this.rankOf(b);
285
+ if (ra && rb) {
286
+ return rb.overall + this.capabilityBonus(b) - (ra.overall + this.capabilityBonus(a));
287
+ }
288
+ if (ra)
289
+ return -1;
290
+ if (rb)
291
+ return 1;
292
+ const bonus = this.capabilityBonus(b) - this.capabilityBonus(a);
293
+ return bonus !== 0 ? bonus : a.displayName.localeCompare(b.displayName);
294
+ });
295
+ }
296
+ get model() {
297
+ return this.visible[this.selected] || null;
298
+ }
299
+ syncProfile() {
300
+ this.profile = this.model ? profiles.forModel(this.store, this.model) : null;
301
+ this.sweepResult = null;
302
+ }
303
+ get calibration() {
304
+ const model = this.model;
305
+ const profile = this.profile;
306
+ if (!model || !profile)
307
+ return null;
308
+ return profiles.getCalibration(this.store, model, profile);
309
+ }
310
+ get budget() {
311
+ const model = this.model;
312
+ const profile = this.profile;
313
+ const info = this.gpuInfo;
314
+ if (!model || !profile || !info)
315
+ return null;
316
+ return vram.budget({
317
+ model,
318
+ profile,
319
+ calibration: this.calibration,
320
+ totalVramBytes: info.totalBytes,
321
+ });
322
+ }
323
+ persist() {
324
+ const model = this.model;
325
+ const profile = this.profile;
326
+ if (model && profile) {
327
+ profiles.put(this.store, model, profile);
328
+ this.store.lastModelId = model.id;
329
+ saveProfilesStore(this.store);
330
+ }
331
+ }
332
+ setStatus(text, tone = "info") {
333
+ this.status = { text, tone };
334
+ this.draw();
335
+ }
336
+ // -------------------------------------------------------------------- input
337
+ onKey(key) {
338
+ if (this.editing)
339
+ return this.onEditKey(key);
340
+ if (this.filterMode)
341
+ return this.onFilterKey(key);
342
+ if (this.confirming)
343
+ return this.onConfirmKey(key);
344
+ if (this.picker)
345
+ return this.onPickerKey(key);
346
+ if (this.search)
347
+ return this.onSearchKey(key);
348
+ // Esc always returns to the standard view from logs / help / bench.
349
+ if (key === "escape" && this.viewMode !== "standard") {
350
+ this.viewMode = "standard";
351
+ this.draw();
352
+ return;
353
+ }
354
+ // Benchmark mode is a full second workspace with its own keys.
355
+ if (this.viewMode === "bench")
356
+ return this.onBenchKey(key);
357
+ switch (key) {
358
+ case "ctrl-c":
359
+ case "q":
360
+ this.shutdown();
361
+ return;
362
+ case "tab":
363
+ case "shifttab":
364
+ this.focus = this.focus === "models" ? "config" : "models";
365
+ break;
366
+ case "up":
367
+ this.move(-1);
368
+ break;
369
+ case "down":
370
+ this.move(1);
371
+ break;
372
+ case "pageup":
373
+ this.move(-8);
374
+ break;
375
+ case "pagedown":
376
+ this.move(8);
377
+ break;
378
+ case "left":
379
+ this.adjust(-1);
380
+ break;
381
+ case "right":
382
+ this.adjust(1);
383
+ break;
384
+ case "-":
385
+ case "_":
386
+ this.adjust(-1);
387
+ break;
388
+ case "+":
389
+ case "=":
390
+ this.adjust(1);
391
+ break;
392
+ case "enter":
393
+ case "space":
394
+ if (this.focus === "config")
395
+ this.activateField();
396
+ else
397
+ this.focus = "config";
398
+ break;
399
+ case "/":
400
+ this.filterMode = true;
401
+ break;
402
+ case "s":
403
+ this.startModel();
404
+ break;
405
+ case "x":
406
+ this.stopModel();
407
+ break;
408
+ case "c":
409
+ this.runCalibration();
410
+ break;
411
+ case "w":
412
+ this.runSweep();
413
+ break;
414
+ case "m":
415
+ this.applyMaxContext();
416
+ break;
417
+ case "g":
418
+ void this.beginDownload();
419
+ break;
420
+ case "f":
421
+ this.beginSearch();
422
+ break;
423
+ case "D":
424
+ this.beginDelete();
425
+ break;
426
+ case "r":
427
+ this.reload();
428
+ break;
429
+ case "b":
430
+ this.enterBench();
431
+ break;
432
+ case "l":
433
+ this.viewMode = this.viewMode === "logs" ? "standard" : "logs";
434
+ break;
435
+ case "?":
436
+ this.viewMode = this.viewMode === "help" ? "standard" : "help";
437
+ break;
438
+ default:
439
+ return;
440
+ }
441
+ this.draw();
442
+ }
443
+ onFilterKey(key) {
444
+ if (key === "enter" || key === "escape") {
445
+ this.filterMode = false;
446
+ if (key === "escape")
447
+ this.filter = "";
448
+ }
449
+ else if (key === "backspace") {
450
+ this.filter = this.filter.slice(0, -1);
451
+ }
452
+ else if (key === "space") {
453
+ // decodeKey names the space bar 'space'; a model name can contain one.
454
+ this.filter += " ";
455
+ }
456
+ else if (key.length === 1 && key >= " ") {
457
+ this.filter += key;
458
+ }
459
+ this.selected = 0;
460
+ this.scrollTop = 0;
461
+ this.syncProfile();
462
+ this.draw();
463
+ }
464
+ onEditKey(key) {
465
+ const active = this.editing;
466
+ if (!active)
467
+ return;
468
+ const { field } = active;
469
+ if (key === "escape") {
470
+ this.editing = null;
471
+ }
472
+ else if (key === "enter") {
473
+ const value = Number.parseInt(active.buffer, 10);
474
+ if (Number.isFinite(value)) {
475
+ const rawMax = field.max;
476
+ const max = typeof rawMax === "function"
477
+ ? rawMax({ model: this.model })
478
+ : (rawMax ?? Number.MAX_SAFE_INTEGER);
479
+ this.writeField(field.key, Math.max(field.min ?? 0, Math.min(max, value)));
480
+ this.persist();
481
+ }
482
+ this.editing = null;
483
+ }
484
+ else if (key === "backspace") {
485
+ active.buffer = active.buffer.slice(0, -1);
486
+ }
487
+ else if (/^[0-9-]$/.test(key)) {
488
+ active.buffer += key;
489
+ }
490
+ this.draw();
491
+ }
492
+ move(delta) {
493
+ if (this.focus === "models") {
494
+ const list = this.visible;
495
+ if (!list.length)
496
+ return;
497
+ this.selected = Math.max(0, Math.min(list.length - 1, this.selected + delta));
498
+ this.syncProfile();
499
+ }
500
+ else {
501
+ const usable = this.usableFields();
502
+ if (!usable.length)
503
+ return;
504
+ const current = usable.indexOf(this.fieldIndex);
505
+ const next = Math.max(0, Math.min(usable.length - 1, (current < 0 ? 0 : current) + delta));
506
+ this.fieldIndex = usable[next];
507
+ }
508
+ }
509
+ usableFields() {
510
+ return FIELDS.map((f, i) => ({ f, i }))
511
+ .filter(({ f }) => !f.enabled || f.enabled({ model: this.model }))
512
+ .map(({ i }) => i);
513
+ }
514
+ /** Read a profile field by key, without narrowing to a single value type. */
515
+ readField(key) {
516
+ return this.profile ? this.profile[key] : undefined;
517
+ }
518
+ /** Write a profile field by key; a no-op when no model is selected. */
519
+ writeField(key, value) {
520
+ if (!this.profile)
521
+ return;
522
+ this.profile[key] = value;
523
+ }
524
+ adjust(direction) {
525
+ if (this.focus !== "config" || !this.profile)
526
+ return;
527
+ const field = FIELDS[this.fieldIndex];
528
+ if (!field)
529
+ return;
530
+ if (field.kind === "toggle") {
531
+ this.writeField(field.key, !this.readField(field.key));
532
+ }
533
+ else if (field.kind === "cycle") {
534
+ const values = field.values ?? [];
535
+ const at = values.indexOf(this.readField(field.key));
536
+ const next = (at < 0 ? 0 : at + direction + values.length) % values.length;
537
+ this.writeField(field.key, values[next]);
538
+ }
539
+ else {
540
+ const rawMax = field.max;
541
+ const max = typeof rawMax === "function"
542
+ ? rawMax({ model: this.model })
543
+ : (rawMax ?? Number.MAX_SAFE_INTEGER);
544
+ const value = this.readField(field.key) + direction * (field.step ?? 0);
545
+ this.writeField(field.key, Math.max(field.min ?? 0, Math.min(max, value)));
546
+ }
547
+ this.persist();
548
+ // Changing the KV cache types invalidates any prior calibration (bytes/token
549
+ // is keyed by them). Nudge a recalibrate, but only if there is a stored
550
+ // measurement to invalidate - no point prompting a model never calibrated.
551
+ if ((field.key === "cacheTypeK" || field.key === "cacheTypeV") &&
552
+ this.model &&
553
+ this.profile &&
554
+ profiles.hasStaleCalibration(this.store, this.model, this.profile)) {
555
+ this.setStatus("cache types changed - press c to recalibrate for an accurate budget", "warn");
556
+ }
557
+ }
558
+ activateField() {
559
+ const field = FIELDS[this.fieldIndex];
560
+ if (!field || !this.profile)
561
+ return;
562
+ if (field.kind === "number") {
563
+ this.editing = { field, buffer: "" };
564
+ }
565
+ else {
566
+ this.adjust(1);
567
+ }
568
+ }
569
+ applyMaxContext() {
570
+ const model = this.model;
571
+ const profile = this.profile;
572
+ const info = this.gpuInfo;
573
+ if (!model || !profile || !info)
574
+ return;
575
+ const max = vram.maxContextThatFits({
576
+ model,
577
+ profile,
578
+ calibration: this.calibration,
579
+ totalVramBytes: info.totalBytes,
580
+ });
581
+ if (!max) {
582
+ this.setStatus("cannot determine a fitting context - run calibration (c)", "warn");
583
+ return;
584
+ }
585
+ profile.contextSize = max;
586
+ this.persist();
587
+ const how = this.calibration ? "measured" : "theoretical (calibrate for accuracy)";
588
+ this.setStatus(`context set to ${max.toLocaleString()} from ${how} budget`, "good");
589
+ }
590
+ // ------------------------------------------------------------------ actions
591
+ // -------------------------------------------------------- model management
592
+ /** The Hugging Face repo for a model: catalog match, else the id's first two
593
+ * path segments (`<publisher>/<repo>/<file>`). */
594
+ repoOf(model) {
595
+ if (model.catalogHfRepo)
596
+ return model.catalogHfRepo;
597
+ const segments = model.id.split("/");
598
+ return segments.length >= 3 ? segments.slice(0, 2).join("/") : null;
599
+ }
600
+ async refreshDisk() {
601
+ const usage = await diskUsage(managedModelsDir(loadBrainConfig()));
602
+ if (usage) {
603
+ this.disk = usage;
604
+ this.draw();
605
+ }
606
+ }
607
+ beginDelete() {
608
+ const model = this.model;
609
+ if (!model)
610
+ return;
611
+ if (this.supervisor.model?.id === model.id && this.supervisor.state === "ready") {
612
+ this.setStatus("stop the model before deleting it (x)", "warn");
613
+ return;
614
+ }
615
+ const plan = planDelete(model);
616
+ this.confirming = { kind: "delete", model };
617
+ this.setStatus(`delete ${model.displayName} — frees ${vram.formatGiB(plan.bytes)}` +
618
+ `${plan.includesProjector ? " (incl. projector)" : ""}? y / n`, "warn");
619
+ this.draw();
620
+ }
621
+ onConfirmKey(key) {
622
+ const confirming = this.confirming;
623
+ if (!confirming)
624
+ return;
625
+ if (key === "y") {
626
+ this.confirming = null;
627
+ void this.guard("delete", async () => {
628
+ const plan = deleteModelFiles(confirming.model);
629
+ this.reload();
630
+ void this.refreshDisk();
631
+ this.setStatus(`deleted ${confirming.model.displayName} — freed ${vram.formatGiB(plan.bytes)}`, "good");
632
+ });
633
+ }
634
+ else if (key === "n" || key === "escape") {
635
+ this.confirming = null;
636
+ this.setStatus("delete cancelled", "info");
637
+ this.draw();
638
+ }
639
+ }
640
+ async beginDownload() {
641
+ const model = this.model;
642
+ if (!model)
643
+ return;
644
+ const repo = this.repoOf(model);
645
+ if (!repo) {
646
+ this.setStatus("cannot determine the Hugging Face repo for this model", "warn");
647
+ return;
648
+ }
649
+ await this.openPicker(repo, model);
650
+ }
651
+ /** Open the quant picker for a repo (an installed model, or one from search). */
652
+ async openPicker(repo, model) {
653
+ this.search = null;
654
+ this.picker = { model, repo, loading: true, options: [], mmproj: null, index: 0 };
655
+ this.draw();
656
+ await this.guard("list quants", async () => {
657
+ const token = resolveHfToken(loadBrainConfig());
658
+ const { quants, mmproj } = await listRepoQuants(repo, token);
659
+ const installed = new Set(this.catalog
660
+ .filter((m) => this.repoOf(m) === repo && m.quant)
661
+ .map((m) => m.quant.toUpperCase()));
662
+ this.picker = {
663
+ model,
664
+ repo,
665
+ loading: false,
666
+ options: quants.map((q) => ({ ...q, installed: installed.has(q.quant.toUpperCase()) })),
667
+ mmproj,
668
+ index: 0,
669
+ };
670
+ this.draw();
671
+ }).catch(() => {
672
+ this.picker = null;
673
+ this.draw();
674
+ });
675
+ }
676
+ // ----------------------------------------------------------- HF model search
677
+ /** A direct owner/repo the query names, so a known repo can be opened without
678
+ * a round trip. Null unless the query looks like a repo path. */
679
+ directRepo(query) {
680
+ const q = query.trim();
681
+ return /^[\w.-]+\/[\w.-]+$/.test(q) ? q : null;
682
+ }
683
+ beginSearch() {
684
+ this.search = { query: "", input: true, loading: false, results: [], index: 0 };
685
+ this.draw();
686
+ }
687
+ onSearchKey(key) {
688
+ const search = this.search;
689
+ if (!search)
690
+ return;
691
+ if (search.input) {
692
+ if (key === "escape") {
693
+ this.search = null;
694
+ }
695
+ else if (key === "enter") {
696
+ void this.runSearch();
697
+ }
698
+ else if (key === "backspace") {
699
+ search.query = search.query.slice(0, -1);
700
+ }
701
+ else if (key === "space") {
702
+ search.query += " ";
703
+ }
704
+ else if (key.length === 1 && key >= " ") {
705
+ search.query += key;
706
+ }
707
+ this.draw();
708
+ return;
709
+ }
710
+ // Browsing results. Index 0 is the "open this repo directly" row when the
711
+ // query looks like owner/repo.
712
+ const direct = this.directRepo(search.query);
713
+ const total = (direct ? 1 : 0) + search.results.length;
714
+ if (key === "escape") {
715
+ search.input = true;
716
+ }
717
+ else if (key === "up") {
718
+ search.index = Math.max(0, search.index - 1);
719
+ }
720
+ else if (key === "down") {
721
+ search.index = Math.min(Math.max(0, total - 1), search.index + 1);
722
+ }
723
+ else if (key === "enter" && total > 0) {
724
+ const repo = direct && search.index === 0
725
+ ? direct
726
+ : search.results[search.index - (direct ? 1 : 0)]?.repo;
727
+ if (repo)
728
+ void this.openPicker(repo, null);
729
+ return;
730
+ }
731
+ this.draw();
732
+ }
733
+ async runSearch() {
734
+ const search = this.search;
735
+ if (!search || !search.query.trim())
736
+ return;
737
+ search.loading = true;
738
+ search.input = false;
739
+ this.draw();
740
+ await this.guard("search", async () => {
741
+ const token = resolveHfToken(loadBrainConfig());
742
+ const results = await searchModels(search.query.trim(), { limit: 30, token });
743
+ if (this.search) {
744
+ this.search.results = results;
745
+ this.search.loading = false;
746
+ this.search.index = 0;
747
+ this.draw();
748
+ }
749
+ }).catch(() => {
750
+ if (this.search) {
751
+ this.search.loading = false;
752
+ this.draw();
753
+ }
754
+ });
755
+ }
756
+ drawSearch(cols) {
757
+ const search = this.search;
758
+ if (!search)
759
+ return;
760
+ const inner = Math.min(cols - 4, 78);
761
+ const rows = [];
762
+ if (search.input) {
763
+ rows.push(`${style.grey}query${style.reset} ${search.query}${style.brightCyan}▏${style.reset}`);
764
+ rows.push("");
765
+ rows.push(`${style.grey}Type a model name, or an exact owner/repo to open it directly.${style.reset}`);
766
+ }
767
+ else if (search.loading) {
768
+ rows.push(`${style.grey}searching Hugging Face…${style.reset}`);
769
+ }
770
+ else {
771
+ const direct = this.directRepo(search.query);
772
+ let i = 0;
773
+ const line = (selected, text) => selected
774
+ ? `${style.inverse}${pad(text.replace(/\x1b\[[0-9;]*m/g, ""), inner)}${style.reset}`
775
+ : text;
776
+ if (direct) {
777
+ rows.push(line(search.index === 0, `▶ open ${direct} directly`));
778
+ i = 1;
779
+ }
780
+ if (!search.results.length && !direct) {
781
+ rows.push(`${style.yellow}no GGUF models matched${style.reset}`);
782
+ }
783
+ // Repos we already have on disk, so a search hit can be flagged.
784
+ const installedRepos = new Set(this.catalog
785
+ .map((x) => this.repoOf(x)?.toLowerCase())
786
+ .filter((v) => Boolean(v)));
787
+ for (let r = 0; r < search.results.length; r += 1) {
788
+ const m = search.results[r];
789
+ const dl = m.downloads >= 1000 ? `${Math.round(m.downloads / 1000)}k` : String(m.downloads);
790
+ const have = installedRepos.has(m.repo.toLowerCase())
791
+ ? ` ${style.brightGreen}have${style.reset}`
792
+ : "";
793
+ const text = `${pad(truncate(m.repo, inner - 24), inner - 24)} ` +
794
+ `${style.grey}${dl.padStart(6)} dl ${String(m.likes).padStart(4)}♥${m.gated ? " gated" : ""}${style.reset}${have}`;
795
+ rows.push(line(search.index === i, text));
796
+ i += 1;
797
+ }
798
+ }
799
+ const footer = search.input
800
+ ? `${style.grey}enter search · esc cancel${style.reset}`
801
+ : `${style.grey}↑↓ select · enter view quants · esc edit query${style.reset}`;
802
+ const lines = [this.header(cols), ""];
803
+ lines.push(...box({
804
+ title: "Search Hugging Face for models",
805
+ lines: rows,
806
+ innerWidth: inner,
807
+ footer,
808
+ accent: style.brightCyan,
809
+ }));
810
+ this.renderFitted(lines);
811
+ }
812
+ onPickerKey(key) {
813
+ const picker = this.picker;
814
+ if (!picker)
815
+ return;
816
+ if (key === "escape" || key === "q") {
817
+ this.picker = null;
818
+ this.draw();
819
+ return;
820
+ }
821
+ if (picker.loading || !picker.options.length)
822
+ return;
823
+ if (key === "up") {
824
+ picker.index = Math.max(0, picker.index - 1);
825
+ }
826
+ else if (key === "down") {
827
+ picker.index = Math.min(picker.options.length - 1, picker.index + 1);
828
+ }
829
+ else if (key === "enter") {
830
+ const choice = picker.options[picker.index];
831
+ this.picker = null;
832
+ void this.guard("download", () => this.downloadQuant(picker.repo, choice, picker.mmproj));
833
+ return;
834
+ }
835
+ this.draw();
836
+ }
837
+ async downloadQuant(repo, choice, mmproj) {
838
+ if (choice.installed) {
839
+ this.setStatus(`${choice.quant} is already installed`, "info");
840
+ return;
841
+ }
842
+ const token = resolveHfToken(loadBrainConfig());
843
+ const files = [...choice.files, ...(mmproj ? mmproj.files : [])];
844
+ const total = choice.sizeBytes + (mmproj?.sizeBytes ?? 0);
845
+ let lastPct = -1;
846
+ this.setStatus(`downloading ${choice.quant} (${vram.formatGiB(total)})…`, "info");
847
+ await downloadRepoFiles({
848
+ repo,
849
+ files,
850
+ destRoot: managedModelsDir(loadBrainConfig()),
851
+ token,
852
+ onProgress: (p) => {
853
+ const pct = total ? Math.floor((p.receivedBytes / total) * 100) : 0;
854
+ if (pct !== lastPct) {
855
+ lastPct = pct;
856
+ this.setStatus(`downloading ${choice.quant} ${pct}%`, "info");
857
+ }
858
+ },
859
+ });
860
+ this.reload();
861
+ void this.refreshDisk();
862
+ this.setStatus(`downloaded ${choice.quant}`, "good");
863
+ }
864
+ drawPicker(cols) {
865
+ const picker = this.picker;
866
+ if (!picker)
867
+ return;
868
+ const inner = Math.min(cols - 4, 72);
869
+ const rows = [];
870
+ if (picker.loading) {
871
+ rows.push(`${style.grey}fetching available quantizations…${style.reset}`);
872
+ }
873
+ else if (!picker.options.length) {
874
+ rows.push(`${style.yellow}no GGUF quantizations found in this repo${style.reset}`);
875
+ }
876
+ else {
877
+ // Reserve the same 1.5G VRAM the budget does, as a rough "will it fit" hint.
878
+ const vramBudget = this.gpuInfo ? this.gpuInfo.totalBytes - 1.5 * vram.GIB : null;
879
+ for (let i = 0; i < picker.options.length; i += 1) {
880
+ const q = picker.options[i];
881
+ const marker = i === picker.index ? "▶" : " ";
882
+ const state = q.installed
883
+ ? `${style.brightGreen}installed${style.reset}`
884
+ : vramBudget !== null && q.sizeBytes > vramBudget
885
+ ? `${style.yellow}heavy for VRAM${style.reset}`
886
+ : `${style.grey}available${style.reset}`;
887
+ const plain = `${marker} ${pad(q.quant, 12)} ${vram.formatGiB(q.sizeBytes).padStart(8)}`;
888
+ rows.push(i === picker.index
889
+ ? `${style.inverse}${pad(plain, inner)}${style.reset}`
890
+ : `${plain} ${state}`);
891
+ }
892
+ }
893
+ const lines = [this.header(cols), ""];
894
+ lines.push(...box({
895
+ title: `Download quant — ${truncate(picker.repo, Math.max(8, inner - 18))}`,
896
+ lines: rows,
897
+ innerWidth: inner,
898
+ footer: picker.loading
899
+ ? `${style.grey}esc cancel${style.reset}`
900
+ : `${style.grey}↑↓ select · enter download · esc cancel${style.reset}`,
901
+ accent: style.brightCyan,
902
+ }));
903
+ this.renderFitted(lines);
904
+ }
905
+ async guard(label, fn) {
906
+ if (this.busy) {
907
+ this.setStatus("another operation is already running", "warn");
908
+ return;
909
+ }
910
+ this.busy = true;
911
+ try {
912
+ await fn();
913
+ }
914
+ catch (error) {
915
+ const message = error instanceof Error ? error.message : String(error);
916
+ this.setStatus(`${label} failed: ${message}`, "bad");
917
+ }
918
+ finally {
919
+ this.busy = false;
920
+ this.draw();
921
+ }
922
+ }
923
+ startModel() {
924
+ const model = this.model;
925
+ if (!model)
926
+ return;
927
+ const profile = this.profile;
928
+ if (!profile)
929
+ return;
930
+ const budget = this.budget;
931
+ if (budget && !budget.fits) {
932
+ this.setStatus(`refusing to load: needs ${vram.formatGiB(budget.totalBytes)} but only ${vram.formatGiB(budget.usableBytes)} usable - press m to fit`, "bad");
933
+ return;
934
+ }
935
+ if (!profile.flashAttention && profile.cacheTypeV !== "f16") {
936
+ this.setStatus("quantised V cache needs flash attention on", "bad");
937
+ return;
938
+ }
939
+ return this.guard("start", async () => {
940
+ this.setStatus(`loading ${model.displayName}…`, "info");
941
+ await this.supervisor.start(model, profile);
942
+ });
943
+ }
944
+ stopModel() {
945
+ return this.guard("stop", async () => {
946
+ await this.supervisor.stop();
947
+ this.setStatus("server stopped", "info");
948
+ });
949
+ }
950
+ runCalibration() {
951
+ const model = this.model;
952
+ const profile = this.profile;
953
+ if (!model || !profile)
954
+ return;
955
+ return this.guard("calibration", async () => {
956
+ await this.supervisor.stop();
957
+ const measurement = await calibrate({
958
+ runtime: this.runtime,
959
+ model,
960
+ profile,
961
+ onProgress: (p) => {
962
+ if (p.phase === "loading")
963
+ this.setStatus(`calibrating: loading at ${p.contextSize.toLocaleString()} ctx…`, "info");
964
+ if (p.phase === "measured")
965
+ this.setStatus(`calibrating: ${p.contextSize.toLocaleString()} ctx used ${vram.formatGiB(p.deltaBytes ?? 0)}`, "info");
966
+ },
967
+ });
968
+ profiles.putCalibration(this.store, model, profile, measurement);
969
+ saveProfilesStore(this.store);
970
+ const ratio = measurement.theoreticalRatio;
971
+ const measuredMsg = `measured ${(measurement.kvBytesPerToken / 1024).toFixed(1)} KB/token` +
972
+ (ratio ? ` (formula overestimated ${ratio.toFixed(1)}x)` : "");
973
+ // Auto-apply the fresh budget. A measured KV cost is almost always far
974
+ // below the theoretical estimate, so the context that was safe before is
975
+ // now needlessly low and leaves VRAM idle - raise it to the measured max.
976
+ const info = this.gpuInfo;
977
+ const before = profile.contextSize;
978
+ let appliedTo = null;
979
+ if (info) {
980
+ const max = vram.maxContextThatFits({
981
+ model,
982
+ profile,
983
+ calibration: this.calibration,
984
+ totalVramBytes: info.totalBytes,
985
+ });
986
+ if (max && max !== before) {
987
+ profile.contextSize = max;
988
+ this.persist();
989
+ appliedTo = max;
990
+ }
991
+ }
992
+ this.setStatus(appliedTo !== null
993
+ ? `${measuredMsg} - context ${before.toLocaleString()} -> ${appliedTo.toLocaleString()}`
994
+ : measuredMsg, "good");
995
+ });
996
+ }
997
+ runSweep() {
998
+ const model = this.model;
999
+ const profile = this.profile;
1000
+ if (!model || !profile)
1001
+ return;
1002
+ return this.guard("sweep", async () => {
1003
+ await this.supervisor.stop();
1004
+ const result = await sweep({
1005
+ runtime: this.runtime,
1006
+ model,
1007
+ profile,
1008
+ onProgress: (p) => {
1009
+ if (p.phase === "loading")
1010
+ this.setStatus(`sweep: loading with budget ${p.budget}…`, "info");
1011
+ if (p.phase === "generating")
1012
+ this.setStatus(`sweep: generating with budget ${p.budget}…`, "info");
1013
+ if (p.phase === "done") {
1014
+ this.setStatus(`sweep: budget ${p.budget} -> ${((p.contentChars ?? 0) / 1024).toFixed(1)}KB content, ${p.filesDelivered}/4 files, ${(p.elapsedSeconds ?? 0).toFixed(0)}s`, "info");
1015
+ }
1016
+ },
1017
+ });
1018
+ this.sweepResult = result;
1019
+ if (result.recommended !== null) {
1020
+ profile.reasoningBudget = result.recommended;
1021
+ this.persist();
1022
+ this.setStatus(`sweep complete - reasoning budget set to ${result.recommended}`, "good");
1023
+ }
1024
+ else {
1025
+ this.setStatus("sweep produced no usable result", "warn");
1026
+ }
1027
+ });
1028
+ }
1029
+ // --------------------------------------------------------------- benchmarks
1030
+ enterBench() {
1031
+ this.viewMode = "bench";
1032
+ this.focus = "models";
1033
+ this.loadBenchResults();
1034
+ this.loadRankings();
1035
+ }
1036
+ loadBenchResults() {
1037
+ try {
1038
+ this.benchResults = results.latestPerConfig();
1039
+ }
1040
+ catch {
1041
+ this.benchResults = [];
1042
+ }
1043
+ }
1044
+ onBenchKey(key) {
1045
+ switch (key) {
1046
+ case "ctrl-c":
1047
+ case "q":
1048
+ this.shutdown();
1049
+ return;
1050
+ case "b":
1051
+ this.viewMode = "standard";
1052
+ break;
1053
+ case "up":
1054
+ this.move(-1);
1055
+ break;
1056
+ case "down":
1057
+ this.move(1);
1058
+ break;
1059
+ case "pageup":
1060
+ this.move(-8);
1061
+ break;
1062
+ case "pagedown":
1063
+ this.move(8);
1064
+ break;
1065
+ case "r":
1066
+ case "enter":
1067
+ this.runBenchmarkOnSelected();
1068
+ break;
1069
+ case "x":
1070
+ this.stopModel();
1071
+ break;
1072
+ default:
1073
+ return;
1074
+ }
1075
+ this.draw();
1076
+ }
1077
+ /** Load the selected model if needed, run the suite, and store the result. */
1078
+ runBenchmarkOnSelected() {
1079
+ const model = this.model;
1080
+ if (!model || this.benchRunning)
1081
+ return;
1082
+ return this.guard("benchmark", async () => {
1083
+ this.benchRunning = true;
1084
+ this.benchModelId = model.id;
1085
+ this.benchProgress = [`preparing ${model.displayName}…`];
1086
+ this.draw();
1087
+ try {
1088
+ if (this.supervisor.model?.id !== model.id || this.supervisor.state !== "ready") {
1089
+ this.benchProgress.push("loading model…");
1090
+ this.draw();
1091
+ await this.loadModelFitted(model);
1092
+ }
1093
+ // The benchmark suite has no TypeScript port yet, so it is loaded lazily
1094
+ // and described by the local Bench/Health interfaces declared above.
1095
+ const benchModule = await import("../bench/index.js");
1096
+ const healthModule = await import("../bench/health.js");
1097
+ const bench = benchModule;
1098
+ const health = healthModule;
1099
+ const profile = this.supervisor.profile;
1100
+ const archiveId = archive.runId(model);
1101
+ const healthSampler = health.start();
1102
+ // stop() is idempotent (clearInterval); guarantee the 1s nvidia-smi
1103
+ // sampler never outlives the run even if runSuite throws — otherwise a
1104
+ // failed bench leaks a recurring subprocess.
1105
+ let sampled = false;
1106
+ try {
1107
+ const report = await bench.runSuite({
1108
+ host: this.supervisor.host,
1109
+ port: this.supervisor.internalPort,
1110
+ concurrency: Math.max(1, profile?.parallelSlots || 3),
1111
+ reasoningBudget: profile?.reasoningBudget ?? null,
1112
+ contextWindow: profile?.contextSize ?? null,
1113
+ archiveId,
1114
+ onProgress: (p) => {
1115
+ if (p.phase === "start")
1116
+ this.benchProgress.push(`${p.title}…`);
1117
+ else if (p.phase === "done") {
1118
+ this.benchProgress[this.benchProgress.length - 1] =
1119
+ `${p.title}: ${(p.score * 100).toFixed(0)}% ${p.summary}`;
1120
+ }
1121
+ else if (p.phase === "failed")
1122
+ this.benchProgress.push(`${p.title}: failed — ${p.summary}`);
1123
+ this.draw();
1124
+ },
1125
+ });
1126
+ report.system = healthSampler.stop();
1127
+ sampled = true;
1128
+ report.vramBytes = this.supervisor.vramAtReadyBytes;
1129
+ report.loadSeconds = this.supervisor.loadSeconds;
1130
+ results.save({
1131
+ model,
1132
+ profile,
1133
+ report,
1134
+ gpu: this.gpuInfo,
1135
+ runtime: `${this.runtime.label} v${this.runtime.version}`,
1136
+ system: report.system,
1137
+ archiveId,
1138
+ });
1139
+ this.loadBenchResults();
1140
+ this.loadRankings();
1141
+ this.benchProgress.push(`done — overall ${(report.overall * 100).toFixed(0)}% (${report.grade})`);
1142
+ this.setStatus(`benchmark complete: ${model.displayName} ${(report.overall * 100).toFixed(0)}% (${report.grade})`, "good");
1143
+ }
1144
+ finally {
1145
+ if (!sampled)
1146
+ healthSampler.stop();
1147
+ }
1148
+ }
1149
+ finally {
1150
+ this.benchRunning = false;
1151
+ }
1152
+ });
1153
+ }
1154
+ scoreColour(score) {
1155
+ if (score >= 0.75)
1156
+ return style.brightGreen;
1157
+ if (score >= 0.55)
1158
+ return style.brightYellow;
1159
+ if (score >= 0.35)
1160
+ return style.yellow;
1161
+ return style.red;
1162
+ }
1163
+ // ------------------------------------------------------------------ drawing
1164
+ /**
1165
+ * Render, but never let the footer scroll off. The renderer only paints
1166
+ * rows-1 lines; if the layout is taller than the terminal, drop rows from
1167
+ * just above the footer so the keybindings hint is always on screen.
1168
+ */
1169
+ renderFitted(lines) {
1170
+ const cap = Math.max(1, this.screen.rows - 1);
1171
+ const fitted = lines.length > cap ? [...lines.slice(0, cap - 1), lines[lines.length - 1]] : lines;
1172
+ this.screen.render(fitted);
1173
+ }
1174
+ draw() {
1175
+ if (!this.screen.entered)
1176
+ return;
1177
+ const cols = this.screen.columns;
1178
+ if (this.viewMode === "logs") {
1179
+ this.drawLogs(cols);
1180
+ return;
1181
+ }
1182
+ if (this.viewMode === "help") {
1183
+ this.drawHelp(cols);
1184
+ return;
1185
+ }
1186
+ if (this.viewMode === "bench") {
1187
+ this.drawBench(cols);
1188
+ return;
1189
+ }
1190
+ if (this.picker) {
1191
+ this.drawPicker(cols);
1192
+ return;
1193
+ }
1194
+ if (this.search) {
1195
+ this.drawSearch(cols);
1196
+ return;
1197
+ }
1198
+ // Give the model list the width; keep config compact. Hold the combined
1199
+ // panel row to cols-2 so nothing spills into the terminal's last column
1200
+ // (writing there scrolls the view, which read as "config goes off screen").
1201
+ const rightWidth = Math.max(CONFIG_MIN_WIDTH, Math.min(CONFIG_MAX_WIDTH, Math.round(cols * 0.38)));
1202
+ const leftWidth = Math.max(MODEL_MIN_WIDTH, cols - rightWidth - 7);
1203
+ // Render the fixed-height chrome first and measure it, then hand the panels
1204
+ // exactly the rows that are left. The status panel grows once CPU/GPU and
1205
+ // telemetry lines appear, so a static guess pushes the footer off-screen.
1206
+ const header = this.header(cols);
1207
+ const budget = this.budgetPanel(cols - 2);
1208
+ const status = this.statusPanel(cols - 2);
1209
+ const footer = this.keybindings(cols - 1);
1210
+ // Lines around the panels: header + blank + [panels] + blank + budget +
1211
+ // blank + status + blank + footer. The panel box adds 3 rows of its own
1212
+ // chrome (title, its footer, bottom border) on top of listRows.
1213
+ const overhead = 1 + 1 + 3 + 1 + budget.length + 1 + status.length + 1 + footer.length;
1214
+ const listRows = Math.max(1, this.screen.rows - 1 - overhead);
1215
+ const lines = [header, ""];
1216
+ const left = this.modelPanel(leftWidth, listRows);
1217
+ const right = this.configPanel(rightWidth, listRows);
1218
+ const height = Math.max(left.length, right.length);
1219
+ for (let i = 0; i < height; i += 1) {
1220
+ const l = left[i] ?? " ".repeat(leftWidth + 2);
1221
+ const r = right[i] ?? "";
1222
+ lines.push(`${l} ${r}`);
1223
+ }
1224
+ lines.push("");
1225
+ lines.push(...budget);
1226
+ lines.push("");
1227
+ lines.push(...status);
1228
+ lines.push("");
1229
+ lines.push(...footer);
1230
+ this.renderFitted(lines);
1231
+ }
1232
+ /**
1233
+ * Benchmark workspace: the model list on the left, the selected model's
1234
+ * scorecard where Configuration sits in serve mode, and the ranked leaderboard
1235
+ * (or live run progress) where the Status panel sits.
1236
+ */
1237
+ drawBench(cols) {
1238
+ // Keep this identical to draw()'s split so the Benchmark sidebar lands at the
1239
+ // same width as the Configuration panel and the model list does not jump when
1240
+ // toggling between the two screens.
1241
+ const rightWidth = Math.max(CONFIG_MIN_WIDTH, Math.min(CONFIG_MAX_WIDTH, Math.round(cols * 0.38)));
1242
+ const leftWidth = Math.max(MODEL_MIN_WIDTH, cols - rightWidth - 7);
1243
+ const header = this.header(cols);
1244
+ const bottom = this.benchBottom(cols - 2);
1245
+ const footer = this.keybindings(cols - 1);
1246
+ const overhead = 1 + 1 + 3 + 1 + bottom.length + 1 + footer.length;
1247
+ const listRows = Math.max(1, this.screen.rows - 1 - overhead);
1248
+ const lines = [header, ""];
1249
+ const left = this.modelPanel(leftWidth, listRows);
1250
+ const right = this.benchSidebar(rightWidth, listRows);
1251
+ const height = Math.max(left.length, right.length);
1252
+ for (let i = 0; i < height; i += 1) {
1253
+ const l = left[i] ?? " ".repeat(leftWidth + 2);
1254
+ const r = right[i] ?? "";
1255
+ lines.push(`${l} ${r}`);
1256
+ }
1257
+ lines.push("");
1258
+ lines.push(...bottom);
1259
+ lines.push("");
1260
+ lines.push(...footer);
1261
+ this.renderFitted(lines);
1262
+ }
1263
+ /** The selected model's latest scorecard, or a prompt to run one. */
1264
+ benchSidebar(innerWidth, rows) {
1265
+ const lines = [];
1266
+ const model = this.model;
1267
+ if (!model) {
1268
+ lines.push(`${style.grey}no model selected${style.reset}`);
1269
+ }
1270
+ else {
1271
+ lines.push(`${style.bold}${truncate(model.displayName, innerWidth)}${style.reset}`);
1272
+ lines.push(`${style.grey}${model.quant || "?"} ${vram.formatGiB(model.sizeBytes)}${style.reset}`);
1273
+ lines.push("");
1274
+ const rec = this.benchResults.find((r) => r.model.displayName === model.displayName) || null;
1275
+ const ranking = this.rankOf(model);
1276
+ if (this.benchRunning && this.benchModelId === model.id) {
1277
+ lines.push(`${style.brightYellow}benchmarking now…${style.reset}`);
1278
+ }
1279
+ else if (!ranking || !rec) {
1280
+ lines.push(`${style.yellow}not benchmarked yet${style.reset}`);
1281
+ lines.push(`${style.grey}press r to run the suite${style.reset}`);
1282
+ }
1283
+ else {
1284
+ lines.push(`${style.grey}rank${style.reset} ${style.bold}#${ranking.rank}${style.reset}${style.grey} of ${this.rankedModels.length}${style.reset}`);
1285
+ lines.push(`${style.grey}overall${style.reset} ${this.scoreColour(ranking.overall)}${(ranking.overall * 100).toFixed(0)}% ${ranking.grade}${style.reset}`);
1286
+ lines.push(`${style.grey}mean of ${ranking.runs} run${ranking.runs === 1 ? "" : "s"}${ranking.runs > 1 ? ` · ±${(ranking.std * 100).toFixed(1)}pts` : ""}${style.reset}`);
1287
+ lines.push("");
1288
+ for (const t of rec.tasks) {
1289
+ lines.push(`${pad(truncate(t.category, 16), 16)} ${this.scoreColour(t.score)}${(t.score * 100).toFixed(0).padStart(3)}%${style.reset}`);
1290
+ }
1291
+ const conc = rec.tasks.find((t) => t.id === "concurrency");
1292
+ if (conc && conc.detail) {
1293
+ const detail = conc.detail;
1294
+ lines.push("");
1295
+ lines.push(`${style.grey}throughput @${detail.concurrency}${style.reset}`);
1296
+ lines.push(` ${style.grey}gen${style.reset} ${(detail.genPerSecond ?? 0).toFixed(0)} tok/s`);
1297
+ lines.push(` ${style.grey}prompt${style.reset} ${(detail.promptPerSecond ?? 0).toFixed(0)} tok/s`);
1298
+ }
1299
+ // Peak context held by the long-horizon / agentic tasks. It rides in the
1300
+ // live run summary but is lost from the persisted scorecard otherwise, so
1301
+ // surface it here alongside throughput.
1302
+ const held = rec.tasks
1303
+ .map((t) => ({ task: t, detail: t.detail }))
1304
+ .filter((x) => typeof x.detail?.contextUtilization === "number");
1305
+ if (held.length) {
1306
+ lines.push("");
1307
+ lines.push(`${style.grey}context held${style.reset}`);
1308
+ for (const { task, detail } of held) {
1309
+ const pct = Math.round((detail?.contextUtilization ?? 0) * 100);
1310
+ const peak = detail?.peakPromptTokens
1311
+ ? ` ${style.grey}${(detail.peakPromptTokens / 1000).toFixed(1)}k tok${style.reset}`
1312
+ : "";
1313
+ lines.push(` ${style.grey}${pad(truncate(task.category, 14), 14)}${style.reset} ${this.scoreColour(1 - pct / 100)}${String(pct).padStart(3)}%${style.reset}${peak}`);
1314
+ }
1315
+ }
1316
+ lines.push("");
1317
+ lines.push(`${style.grey}ran ${rec.ranAt.slice(0, 10)} · ${rec.grade}${style.reset}`);
1318
+ }
1319
+ }
1320
+ while (lines.length < rows)
1321
+ lines.push("");
1322
+ return box({
1323
+ title: "Benchmark",
1324
+ lines,
1325
+ innerWidth,
1326
+ footer: `${style.grey}r run · esc back${style.reset}`,
1327
+ accent: style.brightCyan,
1328
+ });
1329
+ }
1330
+ /** Live run progress, otherwise the ranked leaderboard. */
1331
+ benchBottom(innerWidth) {
1332
+ if (this.benchRunning) {
1333
+ const lines = this.benchProgress
1334
+ .slice(-6)
1335
+ .map((line) => truncate(` ${line}`, innerWidth - 4));
1336
+ while (lines.length < 6)
1337
+ lines.push("");
1338
+ return box({
1339
+ title: "Benchmark progress",
1340
+ lines,
1341
+ innerWidth: innerWidth - 2,
1342
+ accent: style.brightYellow,
1343
+ });
1344
+ }
1345
+ if (!this.rankedModels.length) {
1346
+ return box({
1347
+ title: "Leaderboard",
1348
+ lines: [
1349
+ `${style.grey}no benchmarks yet — select a model and press r to run one${style.reset}`,
1350
+ ],
1351
+ innerWidth: innerWidth - 2,
1352
+ });
1353
+ }
1354
+ const lines = [];
1355
+ lines.push(`${style.grey}${pad("#", 3)}${pad("model", 38)}${"mean".padStart(8)}${"runs".padStart(6)}${"grade".padStart(10)}${"gen tok/s".padStart(11)}${style.reset}`);
1356
+ for (const entry of this.rankedModels.slice(0, 8)) {
1357
+ const latest = this.benchResults.find((r) => (entry.id && r.model.id === entry.id) || r.model.displayName === entry.displayName);
1358
+ const conc = latest && latest.tasks.find((t) => t.id === "concurrency");
1359
+ const tps = conc && conc.detail ? conc.detail.genPerSecond : null;
1360
+ const selected = this.model && entry.displayName === this.model.displayName;
1361
+ const row = `${pad(String(entry.rank), 3)}${pad(truncate(entry.displayName, 37), 38)}` +
1362
+ `${`${(entry.overall * 100).toFixed(0)}%`.padStart(8)}${String(entry.runs).padStart(6)}` +
1363
+ `${entry.grade.padStart(10)}${(tps != null ? tps.toFixed(0) : "-").padStart(11)}`;
1364
+ lines.push(selected ? `${style.inverse}${pad(row, innerWidth - 2)}${style.reset}` : row);
1365
+ }
1366
+ return box({
1367
+ title: `Leaderboard ${style.grey}(${this.rankedModels.length} ranked, mean of runs)${style.reset}`,
1368
+ lines,
1369
+ innerWidth: innerWidth - 2,
1370
+ });
1371
+ }
1372
+ drawLogs(cols) {
1373
+ const logs = this.supervisor.logLines;
1374
+ const s = this.supervisor.status();
1375
+ const header = this.header(cols);
1376
+ const status = this.statusPanel(cols - 2);
1377
+ const footer = this.keybindings(cols - 1);
1378
+ const title = truncate(`${style.brightCyan}llama-server log${style.reset} ` +
1379
+ `${style.grey}${s.state}${logs.length ? ` · ${logs.length} lines` : ""} · esc or l to go back${style.reset}`, cols - 1);
1380
+ // header + blank + title + blank + [body] + blank + status + blank + footer.
1381
+ const overhead = 1 + 1 + 1 + 1 + 1 + status.length + 1 + footer.length;
1382
+ const bodyRows = Math.max(1, this.screen.rows - 1 - overhead);
1383
+ const body = [];
1384
+ if (logs.length === 0) {
1385
+ body.push(`${style.grey}no logs yet — start a model with s to see llama-server output${style.reset}`);
1386
+ }
1387
+ else {
1388
+ for (const line of logs.slice(Math.max(0, logs.length - bodyRows)))
1389
+ body.push(truncate(line, cols));
1390
+ }
1391
+ while (body.length < bodyRows)
1392
+ body.push("");
1393
+ const lines = [header, "", title, "", ...body, "", ...status, "", ...footer];
1394
+ this.renderFitted(lines);
1395
+ }
1396
+ header(cols) {
1397
+ const title = `${style.bold}${style.brightCyan}Otto Brain${style.reset}`;
1398
+ const rt = `${style.grey}llama.cpp ${this.runtime.label} v${this.runtime.version}${style.reset}`;
1399
+ const g = this.gpuInfo
1400
+ ? `${style.grey}${this.gpuInfo.name} · ${vram.formatGiB(this.gpuInfo.usedBytes)}/${vram.formatGiB(this.gpuInfo.totalBytes)}${style.reset}`
1401
+ : `${style.yellow}no NVIDIA GPU detected${style.reset}`;
1402
+ const endpoint = this.routerServer
1403
+ ? `${style.grey}serving ${this.listenHost}:${this.listenPort}${style.reset}`
1404
+ : `${style.red}router not listening${style.reset}`;
1405
+ return truncate(`${title} ${rt} ${g} ${endpoint}`, cols - 1);
1406
+ }
1407
+ modelPanel(innerWidth, rows) {
1408
+ const list = this.visible;
1409
+ if (this.selected < this.scrollTop)
1410
+ this.scrollTop = this.selected;
1411
+ if (this.selected >= this.scrollTop + rows)
1412
+ this.scrollTop = this.selected - rows + 1;
1413
+ const lines = [];
1414
+ for (let i = this.scrollTop; i < Math.min(list.length, this.scrollTop + rows); i += 1) {
1415
+ const m = list[i];
1416
+ const isSelected = i === this.selected;
1417
+ const running = this.supervisor.model?.id === m.id && this.supervisor.state === "ready";
1418
+ const marker = running ? `${style.brightGreen}●${style.reset}` : " ";
1419
+ const quant = m.quant ? m.quant.padEnd(7) : "-".padEnd(7);
1420
+ const size = vram.formatGiB(m.sizeBytes).padStart(6);
1421
+ const isReasoning = Boolean(m.metadata?.reasoning || m.thinking);
1422
+ const tags = [
1423
+ m.mmprojPath ? `${style.cyan}V${style.reset}` : " ",
1424
+ m.features.mtp ? `${style.magenta}M${style.reset}` : " ",
1425
+ isReasoning ? `${style.green}R${style.reset}` : " ",
1426
+ ].join("");
1427
+ // Benchmark score badge: how this model ranks by our latest measurement.
1428
+ const rank = this.rankOf(m);
1429
+ const score = rank
1430
+ ? `${this.scoreColour(rank.overall)}${String(Math.round(rank.overall * 100)).padStart(3)}%${style.reset}`
1431
+ : `${style.grey} –${style.reset}`;
1432
+ const nameWidth = innerWidth - 7 - 7 - 3 - 2 - 5;
1433
+ const name = truncate(m.displayName, nameWidth);
1434
+ const row = `${marker}${pad(name, nameWidth)} ${score} ${quant}${size} ${tags}`;
1435
+ lines.push(isSelected
1436
+ ? `${style.inverse}${pad(truncate(row.replace(/\x1b\[[0-9;]*m/g, ""), innerWidth), innerWidth)}${style.reset}`
1437
+ : row);
1438
+ }
1439
+ while (lines.length < rows)
1440
+ lines.push("");
1441
+ const title = this.filterMode
1442
+ ? `Models ${style.brightYellow}/${this.filter}${style.reset}`
1443
+ : `Models ${style.grey}(${list.length})${style.reset}`;
1444
+ const footer = `${style.grey}%=benchmark V=vision M=MTP R=reasoning${style.reset}`;
1445
+ return box({
1446
+ title,
1447
+ lines,
1448
+ innerWidth,
1449
+ footer,
1450
+ accent: this.focus === "models" ? style.brightCyan : style.grey,
1451
+ });
1452
+ }
1453
+ configPanel(innerWidth, rows) {
1454
+ // Build the full content first, remembering which line holds the focused
1455
+ // field, then window it to exactly `rows` so the box height always matches
1456
+ // the model list and never runs off the bottom of the screen.
1457
+ const content = [];
1458
+ let focusLine = 0;
1459
+ const model = this.model;
1460
+ const profile = this.profile;
1461
+ if (!model || !profile) {
1462
+ content.push(`${style.grey}no model selected${style.reset}`);
1463
+ }
1464
+ else {
1465
+ const md = model.metadata ?? {};
1466
+ content.push(`${style.grey}arch${style.reset} ${md.arch || "?"} ` +
1467
+ `${style.grey}layers${style.reset} ${md.blockCount ?? "?"} ` +
1468
+ `${style.grey}kv heads${style.reset} ${md.headCountKv ?? "?"}`);
1469
+ if (model.features.mtp || model.features.distilled) {
1470
+ const flags = [
1471
+ model.features.mtp ? "multi-token prediction" : null,
1472
+ model.features.distilled ? "distilled" : null,
1473
+ ]
1474
+ .filter(Boolean)
1475
+ .join(", ");
1476
+ content.push(`${style.grey}${flags}${style.reset}`);
1477
+ }
1478
+ content.push("");
1479
+ const usable = this.usableFields();
1480
+ for (const index of usable) {
1481
+ const field = FIELDS[index];
1482
+ const focused = this.focus === "config" && index === this.fieldIndex;
1483
+ const active = this.editing;
1484
+ const value = active && active.field.key === field.key
1485
+ ? `${style.brightYellow}${active.buffer}_${style.reset}`
1486
+ : field.format(profile, { model });
1487
+ const arrow = focused ? `${style.brightCyan}›${style.reset}` : " ";
1488
+ if (focused)
1489
+ focusLine = content.length;
1490
+ content.push(`${arrow} ${pad(field.label, 17)} ${value}`);
1491
+ const note = field.note?.(profile);
1492
+ if (note)
1493
+ content.push(` ${style.yellow} ${note}${style.reset}`);
1494
+ }
1495
+ const sweepResult = this.sweepResult;
1496
+ if (sweepResult) {
1497
+ content.push("");
1498
+ content.push(`${style.grey}sweep results${style.reset}`);
1499
+ for (const r of sweepResult.results) {
1500
+ if (r.error) {
1501
+ content.push(` ${String(r.budget).padStart(5)} ${style.red}${truncate(r.error, innerWidth - 10)}${style.reset}`);
1502
+ continue;
1503
+ }
1504
+ const best = r.budget === sweepResult.recommended;
1505
+ const mark = best ? `${style.brightGreen}✓${style.reset}` : " ";
1506
+ content.push(` ${String(r.budget).padStart(5)} ${mark} ${(r.contentChars / 1024).toFixed(1).padStart(5)}KB ` +
1507
+ `${r.filesDelivered}/4 files ${(r.elapsedSeconds ?? 0).toFixed(0).padStart(3)}s`);
1508
+ }
1509
+ }
1510
+ }
1511
+ // Window to `rows`, keeping the focused field on screen when it overflows.
1512
+ let lines;
1513
+ if (content.length <= rows) {
1514
+ lines = content;
1515
+ while (lines.length < rows)
1516
+ lines.push("");
1517
+ }
1518
+ else {
1519
+ const start = Math.max(0, Math.min(focusLine - Math.floor(rows / 2), content.length - rows));
1520
+ lines = content.slice(start, start + rows);
1521
+ }
1522
+ return box({
1523
+ title: `Configuration${this.calibration ? ` ${style.brightGreen}calibrated${style.reset}` : ` ${style.yellow}not calibrated${style.reset}`}`,
1524
+ lines,
1525
+ innerWidth,
1526
+ footer: `${style.grey}←→ change · enter edit${style.reset}`,
1527
+ accent: this.focus === "config" ? style.brightCyan : style.grey,
1528
+ });
1529
+ }
1530
+ budgetPanel(innerWidth) {
1531
+ const b = this.budget;
1532
+ if (!b) {
1533
+ return box({
1534
+ title: "VRAM budget",
1535
+ lines: [`${style.grey}unavailable${style.reset}`],
1536
+ innerWidth: innerWidth - 2,
1537
+ });
1538
+ }
1539
+ const cells = Math.max(20, Math.min(60, innerWidth - 40));
1540
+ const bar = meter(b.utilization, cells);
1541
+ const verdict = b.fits
1542
+ ? `${style.brightGreen}fits entirely on GPU${style.reset}`
1543
+ : `${style.red}EXCEEDS VRAM by ${vram.formatGiB(-b.headroomBytes)}${style.reset}`;
1544
+ const breakdown = `weights ${vram.formatGiB(b.weightsBytes)}` +
1545
+ (b.mmprojBytes ? ` + projector ${vram.formatGiB(b.mmprojBytes)}` : "") +
1546
+ ` + kv ${vram.formatGiB(b.kvBytes)}` +
1547
+ ` + overhead ${vram.formatGiB(b.overheadBytes)}` +
1548
+ ` = ${style.bold}${vram.formatGiB(b.totalBytes)}${style.reset}`;
1549
+ const kvLabel = `kv ${(b.kvBytesPerToken / 1024).toFixed(1)} KB/token`;
1550
+ const cal = this.calibration;
1551
+ let sourceNote;
1552
+ if (b.source === "measured") {
1553
+ sourceNote = cal?.inherited
1554
+ ? `${style.yellow}${kvLabel} (measured on a relative - press c to calibrate this model)${style.reset}`
1555
+ : `${style.grey}${kvLabel} (measured)${style.reset}`;
1556
+ }
1557
+ else {
1558
+ const stale = this.model && this.profile
1559
+ ? profiles.hasStaleCalibration(this.store, this.model, this.profile)
1560
+ : false;
1561
+ const hint = stale
1562
+ ? "cache types changed - press c to recalibrate"
1563
+ : "press c to calibrate; usually unlocks more context";
1564
+ sourceNote = `${style.yellow}${kvLabel} (theoretical - ${hint})${style.reset}`;
1565
+ }
1566
+ return box({
1567
+ title: "VRAM budget",
1568
+ lines: [
1569
+ `${bar} ${vram.formatGiB(b.totalBytes)} / ${vram.formatGiB(b.usableBytes)} usable ${verdict}`,
1570
+ breakdown,
1571
+ sourceNote,
1572
+ ],
1573
+ innerWidth: innerWidth - 2,
1574
+ });
1575
+ }
1576
+ statusPanel(innerWidth) {
1577
+ const s = this.supervisor.status();
1578
+ const tone = {
1579
+ info: style.grey,
1580
+ good: style.brightGreen,
1581
+ warn: style.brightYellow,
1582
+ bad: style.red,
1583
+ }[this.status.tone];
1584
+ const stateColour = {
1585
+ ready: style.brightGreen,
1586
+ starting: style.brightYellow,
1587
+ stopping: style.yellow,
1588
+ failed: style.red,
1589
+ stopped: style.grey,
1590
+ }[s.state] || style.grey;
1591
+ const lines = [
1592
+ `${style.grey}server${style.reset} ${stateColour}${s.state}${style.reset}` +
1593
+ (s.model ? ` ${truncate(s.model, 40)}` : "") +
1594
+ (s.pid ? ` ${style.grey}pid ${s.pid}${style.reset}` : ""),
1595
+ `${tone}${truncate(this.status.text, innerWidth - 4)}${style.reset}`,
1596
+ ];
1597
+ // Live resource line: CPU, system RAM, GPU utilisation and VRAM.
1598
+ const sys = this.sys;
1599
+ if (sys) {
1600
+ const bits = [];
1601
+ if (typeof sys.cpu === "number") {
1602
+ bits.push(`${style.grey}cpu${style.reset} ${(sys.cpu * 100).toFixed(0).padStart(3)}%` +
1603
+ `${style.grey}/${sys.cpuCount}c${style.reset}`);
1604
+ }
1605
+ bits.push(`${style.grey}ram${style.reset} ${vram.formatGiB(sys.ramUsedBytes)}/${vram.formatGiB(sys.ramTotalBytes)}`);
1606
+ const gpuSample = sys.gpu;
1607
+ if (gpuSample) {
1608
+ const hot = gpuSample.utilization >= 90 ? style.brightGreen : style.reset;
1609
+ bits.push(`${style.grey}gpu${style.reset} ${hot}${String(gpuSample.utilization).padStart(3)}%${style.reset}`);
1610
+ bits.push(`${style.grey}vram${style.reset} ${vram.formatGiB(gpuSample.usedBytes)}/${vram.formatGiB(gpuSample.totalBytes)}`);
1611
+ if (typeof gpuSample.temperature === "number" && gpuSample.temperature > 0) {
1612
+ bits.push(`${style.grey}${gpuSample.temperature}C${style.reset}`);
1613
+ }
1614
+ }
1615
+ lines.push(bits.join(" "));
1616
+ const slotInfo = sys.slots;
1617
+ if (slotInfo) {
1618
+ const busyColour = slotInfo.busy > 0 ? style.brightGreen : style.grey;
1619
+ lines.push(`${style.grey}slots${style.reset} ${busyColour}${slotInfo.busy} busy${style.reset}` +
1620
+ `${style.grey} / ${slotInfo.total} total${style.reset}` +
1621
+ `${slotInfo.busy >= slotInfo.total ? ` ${style.brightYellow}saturated - further requests queue${style.reset}` : ""}`);
1622
+ }
1623
+ }
1624
+ // Disk: total space the models take, and free space where new downloads land.
1625
+ const modelBytes = totalModelBytes(this.catalog);
1626
+ const diskLine = `${style.grey}disk${style.reset} models ${vram.formatGiB(modelBytes)}` +
1627
+ (this.disk
1628
+ ? ` ${vram.formatGiB(this.disk.freeBytes)} free of ${vram.formatGiB(this.disk.totalBytes)}`
1629
+ : "");
1630
+ lines.push(diskLine);
1631
+ const t = this.telemetry.totals;
1632
+ if (t.requests) {
1633
+ lines.push(`${style.grey}requests${style.reset} ${t.requests} ` +
1634
+ `${style.brightGreen}ok ${t.ok}${style.reset} ` +
1635
+ `${t.reasoningOnly ? style.red : style.grey}reasoning-only ${t.reasoningOnly}${style.reset} ` +
1636
+ `${t.truncated ? style.brightYellow : style.grey}truncated ${t.truncated}${style.reset}`);
1637
+ }
1638
+ const warning = this.telemetry.warning;
1639
+ if (warning)
1640
+ lines.push(`${style.brightYellow}${truncate(warning, innerWidth - 4)}${style.reset}`);
1641
+ return box({ title: "Status", lines, innerWidth: innerWidth - 2 });
1642
+ }
1643
+ /** Full-screen reference so no option ever has to be guessed. */
1644
+ drawHelp(cols) {
1645
+ const inner = Math.min(cols - 4, 76);
1646
+ const rows = [];
1647
+ const section = (t) => {
1648
+ rows.push("", `${style.brightCyan}${t}${style.reset}`);
1649
+ };
1650
+ const item = (k, d) => {
1651
+ rows.push(` ${style.brightCyan}${pad(k, 12)}${style.reset}${style.grey}${d}${style.reset}`);
1652
+ };
1653
+ section("Navigate");
1654
+ item("↑ ↓", "move within the focused panel");
1655
+ item("PgUp PgDn", "jump by 8");
1656
+ item("Tab", "switch between the Models and Configuration panels");
1657
+ section("Change settings (Configuration panel)");
1658
+ item("← →", "change the selected field (toggle / cycle / ± step)");
1659
+ item("- +", "same as ← →");
1660
+ item("Enter", "edit a number field — type digits, Enter saves, Esc cancels");
1661
+ item("m", "set context to the largest size that fits in VRAM");
1662
+ section("Run the model");
1663
+ item("s", "start / load the selected model");
1664
+ item("x", "stop the running model");
1665
+ item("c", "calibrate — measure real VRAM per token");
1666
+ item("w", "sweep — find the best reasoning budget");
1667
+ section("Manage models");
1668
+ item("f", "find on Hugging Face — search and add a new model");
1669
+ item("g", "get a quant — pick Q4/Q5/Q6… to download for this repo");
1670
+ item("D", "delete the selected model (frees disk, asks to confirm)");
1671
+ section("Views");
1672
+ item("b", "benchmark mode — rank models, run the coding suite");
1673
+ item("l", "view the live llama-server log");
1674
+ item("/", "filter the model list (Enter apply, Esc clear)");
1675
+ item("r", "rescan the models folder");
1676
+ item("?", "this help");
1677
+ section("Anywhere");
1678
+ item("Esc", "leave logs / help / the current mode");
1679
+ item("q Ctrl-C", "quit Otto Brain");
1680
+ const lines = [this.header(cols), ""];
1681
+ lines.push(...box({
1682
+ title: "Help — every key and what it does",
1683
+ lines: rows,
1684
+ innerWidth: inner,
1685
+ footer: `${style.grey}esc or ? to go back${style.reset}`,
1686
+ accent: style.brightCyan,
1687
+ }));
1688
+ this.renderFitted(lines);
1689
+ }
1690
+ /**
1691
+ * The key hints for the current mode, as an array of lines. Groups are
1692
+ * deliberately broken onto separate lines — navigation first, then the
1693
+ * actions — and each group wraps further only if the terminal is too narrow.
1694
+ */
1695
+ keybindings(cols) {
1696
+ let groups;
1697
+ if (this.confirming) {
1698
+ groups = [
1699
+ [
1700
+ ["y", "confirm delete"],
1701
+ ["n", "cancel"],
1702
+ ],
1703
+ ];
1704
+ }
1705
+ else if (this.filterMode) {
1706
+ groups = [
1707
+ [
1708
+ ["type", "to filter"],
1709
+ ["enter", "apply"],
1710
+ ["esc", "clear & exit"],
1711
+ ],
1712
+ ];
1713
+ }
1714
+ else if (this.editing) {
1715
+ groups = [
1716
+ [
1717
+ ["0-9", "type value"],
1718
+ ["enter", "save"],
1719
+ ["esc", "cancel"],
1720
+ ],
1721
+ ];
1722
+ }
1723
+ else if (this.viewMode === "logs") {
1724
+ groups = [
1725
+ [
1726
+ ["esc", "back"],
1727
+ ["l", "back"],
1728
+ ["q", "quit"],
1729
+ ],
1730
+ ];
1731
+ }
1732
+ else if (this.viewMode === "help") {
1733
+ groups = [
1734
+ [
1735
+ ["esc", "back"],
1736
+ ["?", "back"],
1737
+ ["q", "quit"],
1738
+ ],
1739
+ ];
1740
+ }
1741
+ else if (this.viewMode === "bench") {
1742
+ groups = [
1743
+ [
1744
+ ["↑↓", "model"],
1745
+ ["r", "run benchmark"],
1746
+ ["x", "stop"],
1747
+ ["b", "serve mode"],
1748
+ ["q", "quit"],
1749
+ ],
1750
+ ];
1751
+ }
1752
+ else {
1753
+ groups = [
1754
+ // Navigation — line one.
1755
+ [
1756
+ ["↑↓", "select"],
1757
+ ["tab", "panel"],
1758
+ ["←→", "change"],
1759
+ ["enter", "edit"],
1760
+ ],
1761
+ // Actions — line two onward.
1762
+ [
1763
+ ["s", "start"],
1764
+ ["x", "stop"],
1765
+ ["m", "max ctx"],
1766
+ ["c", "calibrate"],
1767
+ ["w", "sweep"],
1768
+ ["f", "find on HF"],
1769
+ ["g", "get quant"],
1770
+ ["D", "delete"],
1771
+ ["b", "benchmarks"],
1772
+ ["l", "logs"],
1773
+ ["/", "filter"],
1774
+ ["r", "rescan"],
1775
+ ["?", "help"],
1776
+ ["q", "quit"],
1777
+ ],
1778
+ ];
1779
+ }
1780
+ const gap = " ";
1781
+ const lines = [];
1782
+ for (const group of groups) {
1783
+ const cells = group.map(([k, v]) => `${style.brightCyan}${k}${style.reset} ${style.grey}${v}${style.reset}`);
1784
+ let line = "";
1785
+ for (const cell of cells) {
1786
+ const candidate = line ? `${line}${gap}${cell}` : cell;
1787
+ if (line && width(candidate) > cols) {
1788
+ lines.push(line);
1789
+ line = cell;
1790
+ }
1791
+ else {
1792
+ line = candidate;
1793
+ }
1794
+ }
1795
+ if (line)
1796
+ lines.push(line);
1797
+ }
1798
+ return lines.length ? lines : [""];
1799
+ }
1800
+ }
1801
+ //# sourceMappingURL=app.js.map