@otto-code/brain 0.7.6 → 0.8.1

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 (87) hide show
  1. package/dist/bench/context-corpus.js +3 -3
  2. package/dist/bench/corpus.js +2 -2
  3. package/dist/bench/curated-repos.js +3 -3
  4. package/dist/bench/health.d.ts +1 -1
  5. package/dist/bench/health.js +2 -2
  6. package/dist/bench/tasks.js +2 -2
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/commands/bench.d.ts +1 -1
  10. package/dist/commands/bench.js +27 -4
  11. package/dist/commands/calibrate.d.ts +1 -1
  12. package/dist/commands/calibrate.js +5 -2
  13. package/dist/commands/catalog.d.ts +1 -1
  14. package/dist/commands/config.d.ts +1 -1
  15. package/dist/commands/lifecycle.js +1 -1
  16. package/dist/commands/pull.d.ts +1 -1
  17. package/dist/commands/pull.js +9 -4
  18. package/dist/commands/report.d.ts +1 -1
  19. package/dist/commands/rescore.d.ts +1 -1
  20. package/dist/commands/rescore.js +1 -1
  21. package/dist/commands/runtime.d.ts +2 -1
  22. package/dist/commands/runtime.js +32 -3
  23. package/dist/commands/scan.d.ts +1 -1
  24. package/dist/commands/scan.js +6 -1
  25. package/dist/commands/search.d.ts +1 -1
  26. package/dist/commands/share.js +2 -2
  27. package/dist/commands/sweep.d.ts +2 -2
  28. package/dist/commands/sweep.js +22 -8
  29. package/dist/commands/ui.d.ts +1 -1
  30. package/dist/config/index.d.ts +2 -1
  31. package/dist/config/index.js +2 -1
  32. package/dist/config/otto-home.js +1 -1
  33. package/dist/config/paths.d.ts +1 -0
  34. package/dist/config/paths.js +4 -0
  35. package/dist/config/profile-edit.d.ts +94 -0
  36. package/dist/config/profile-edit.js +269 -0
  37. package/dist/config/profiles.d.ts +2 -2
  38. package/dist/config/profiles.js +1 -1
  39. package/dist/config/schema.d.ts +4 -4
  40. package/dist/config/schema.js +6 -6
  41. package/dist/config/store.d.ts +1 -1
  42. package/dist/config/store.js +1 -1
  43. package/dist/models/download.js +1 -1
  44. package/dist/models/enrich.d.ts +2 -2
  45. package/dist/models/index.d.ts +1 -1
  46. package/dist/models/index.js +1 -1
  47. package/dist/models/pick.d.ts +9 -0
  48. package/dist/models/pick.js +24 -1
  49. package/dist/ops/report.js +28 -28
  50. package/dist/ops/results.d.ts +128 -9
  51. package/dist/ops/results.js +77 -5
  52. package/dist/output/render.js +1 -1
  53. package/dist/output/types.d.ts +1 -1
  54. package/dist/runtime/args.d.ts +17 -4
  55. package/dist/runtime/args.js +20 -14
  56. package/dist/runtime/index.d.ts +9 -3
  57. package/dist/runtime/index.js +21 -3
  58. package/dist/runtime/lmstudio.d.ts +2 -2
  59. package/dist/runtime/lmstudio.js +115 -28
  60. package/dist/runtime/managed.d.ts +72 -8
  61. package/dist/runtime/managed.js +404 -38
  62. package/dist/service/activity.d.ts +83 -0
  63. package/dist/service/activity.js +216 -0
  64. package/dist/service/host-api.d.ts +132 -0
  65. package/dist/service/host-api.js +397 -0
  66. package/dist/service/http-util.d.ts +27 -0
  67. package/dist/service/http-util.js +72 -0
  68. package/dist/service/model-selector.d.ts +2 -2
  69. package/dist/service/model-selector.js +6 -6
  70. package/dist/service/router.d.ts +28 -4
  71. package/dist/service/router.js +128 -94
  72. package/dist/service/scheduler.d.ts +2 -2
  73. package/dist/service/scheduler.js +1 -1
  74. package/dist/service/serve.d.ts +2 -2
  75. package/dist/service/serve.js +51 -8
  76. package/dist/service/supervisor.d.ts +6 -0
  77. package/dist/service/supervisor.js +2 -0
  78. package/dist/service/tailscale.js +1 -1
  79. package/dist/service/tls.d.ts +4 -4
  80. package/dist/service/tls.js +3 -3
  81. package/dist/sysmon.d.ts +20 -4
  82. package/dist/sysmon.js +42 -18
  83. package/dist/tui/app.d.ts +12 -2
  84. package/dist/tui/app.js +46 -22
  85. package/dist/vram.d.ts +8 -1
  86. package/dist/vram.js +6 -3
  87. package/package.json +1 -1
@@ -26,7 +26,7 @@ function escapeHtml(text) {
26
26
  return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
27
27
  }
28
28
  function formatGiB(bytes) {
29
- return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "";
29
+ return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "-";
30
30
  }
31
31
  /**
32
32
  * Per-model score card: each task labelled directly with its weight, then the
@@ -77,8 +77,8 @@ function groupedBars(records, columns, weightOf) {
77
77
  (weight
78
78
  ? `<text x="${labelWidth - 8}" y="${y + barHeight - 2}" text-anchor="end" class="twt">×${weight}</text>`
79
79
  : "") +
80
- bar(labelWidth, y, score * chartWidth, "scorebar", `${label}: ${(score * 100).toFixed(0)}%${weight ? ` (weight ${weight})` : ""} ${escapeHtml(task ? task.summary : "not run")}`) +
81
- `<text x="${labelWidth + chartWidth + 8}" y="${y + barHeight - 2}" class="val">${task ? `${(score * 100).toFixed(0)}%` : ""}</text>`);
80
+ bar(labelWidth, y, score * chartWidth, "scorebar", `${label}: ${(score * 100).toFixed(0)}%${weight ? ` (weight ${weight})` : ""} - ${escapeHtml(task ? task.summary : "not run")}`) +
81
+ `<text x="${labelWidth + chartWidth + 8}" y="${y + barHeight - 2}" class="val">${task ? `${(score * 100).toFixed(0)}%` : "-"}</text>`);
82
82
  });
83
83
  // The weighted Overall, set off by a divider so it reads as the sum of the
84
84
  // task rows above it rather than one more task.
@@ -138,7 +138,7 @@ function depthChart(series, { valueOf, label, unit, formatValue }) {
138
138
  // 2px surface ring keeps overlapping markers separable.
139
139
  parts.push(`<circle cx="${sx(p.promptTokens || 0).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="4.5" ` +
140
140
  `fill="var(--series-${i + 1})" stroke="var(--surface-1)" stroke-width="2">` +
141
- `<title>${escapeHtml(s.name)} ${(p.promptTokens || 0).toLocaleString()} tokens: ` +
141
+ `<title>${escapeHtml(s.name)} - ${(p.promptTokens || 0).toLocaleString()} tokens: ` +
142
142
  `${formatValue(v)} ${escapeHtml(unit)}</title></circle>`);
143
143
  }
144
144
  });
@@ -175,7 +175,7 @@ function runsChart(series) {
175
175
  for (const p of ordered) {
176
176
  parts.push(`<circle cx="${sx(p.runIndex).toFixed(1)}" cy="${sy(p.score).toFixed(1)}" r="4.5" ` +
177
177
  `fill="var(--series-${i + 1})" stroke="var(--surface-1)" stroke-width="2">` +
178
- `<title>${escapeHtml(s.name)} run ${p.runIndex}: ${(p.score * 100).toFixed(0)}%</title></circle>`);
178
+ `<title>${escapeHtml(s.name)} - run ${p.runIndex}: ${(p.score * 100).toFixed(0)}%</title></circle>`);
179
179
  }
180
180
  });
181
181
  return (`<svg viewBox="0 0 ${width} ${height}" width="100%" role="img" ` +
@@ -261,12 +261,12 @@ function build(records, allRuns = records) {
261
261
  const cells = columns
262
262
  .map((c) => {
263
263
  const task = r.tasks.find((t) => t.id === c.id);
264
- return `<td class="num">${task ? `${(task.score * 100).toFixed(0)}%` : ""}</td>`;
264
+ return `<td class="num">${task ? `${(task.score * 100).toFixed(0)}%` : "-"}</td>`;
265
265
  })
266
266
  .join("");
267
- return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "")}</td>` +
267
+ return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "-")}</td>` +
268
268
  `<td class="num">${(r.profile?.contextSize || 0).toLocaleString()}</td>` +
269
- `<td class="num">${r.profile?.reasoningBudget ?? ""}</td>` +
269
+ `<td class="num">${r.profile?.reasoningBudget ?? "-"}</td>` +
270
270
  `${cells}<td class="num strong">${(r.overall * 100).toFixed(0)}%</td>` +
271
271
  `<td class="num">${formatGiB(r.vramBytes)}</td>` +
272
272
  `<td class="muted">${escapeHtml(r.ranAt.slice(0, 16).replace("T", " "))}</td></tr>`);
@@ -290,18 +290,18 @@ function build(records, allRuns = records) {
290
290
  </header>
291
291
 
292
292
  <section class="tiles">
293
- ${statTile("Best model", best.model.displayName.slice(0, 26), `${(best.overall * 100).toFixed(0)}% ${best.grade}`)}
293
+ ${statTile("Best model", best.model.displayName.slice(0, 26), `${(best.overall * 100).toFixed(0)}% - ${best.grade}`)}
294
294
  ${statTile("Runs recorded", records.length, `${columns.length} task categories`)}
295
- ${statTile("Peak generation", fastest ? `${fastest.toFixed(0)} tok/s` : "", "at shallowest depth measured")}
295
+ ${statTile("Peak generation", fastest ? `${fastest.toFixed(0)} tok/s` : "-", "at shallowest depth measured")}
296
296
  ${statTile("VRAM at load", formatGiB(best.vramBytes), best.loadSeconds ? `loaded in ${best.loadSeconds.toFixed(1)}s` : "")}
297
297
  </section>
298
298
 
299
299
  <section class="card">
300
300
  <h2>Scores by task</h2>
301
- <p class="cap">Each row is one task, scored 0–100% from objectively verifiable outcomes tool calls
301
+ <p class="cap">Each row is one task, scored 0–100% from objectively verifiable outcomes - tool calls
302
302
  checked against expected name and arguments, generated code compiled and its tests executed. The
303
303
  <b>×N</b> after a task is its weight. <b>Overall</b> is the weighted mean of the task scores,
304
- <span class="mono">Σ(score × weight) ÷ Σ(weight)</span> so a heavier task moves the headline
304
+ <span class="mono">Σ(score × weight) ÷ Σ(weight)</span> - so a heavier task moves the headline
305
305
  number more, and it is not the plain average of the bars above it.</p>
306
306
  ${groupedBars(ranked, columns, weightOf)}
307
307
  </section>
@@ -325,7 +325,7 @@ function build(records, allRuns = records) {
325
325
  <section class="card">
326
326
  <h2>Throughput against prompt depth</h2>
327
327
  <p class="cap">Generation speed at the same depths. A collapse here usually means the KV cache
328
- spilled out of VRAM the failure this tool's budget check exists to prevent.</p>
328
+ spilled out of VRAM - the failure this tool's budget check exists to prevent.</p>
329
329
  ${legend(depthSeries.map((s) => s.name.slice(0, 30)))}
330
330
  ${depthChart(depthSeries, {
331
331
  valueOf: (p) => p.generatePerSecond,
@@ -340,7 +340,7 @@ function build(records, allRuns = records) {
340
340
  <section class="card">
341
341
  <h2>Does each task actually tell us anything?</h2>
342
342
  <p class="cap">A task every model passes cannot rank them, however hard it looks. This is the
343
- score spread across all runs low spread means the task is saturated and its contribution to
343
+ score spread across all runs - low spread means the task is saturated and its contribution to
344
344
  the overall figure is inflating everyone equally. The weight is how hard that task pulls on the
345
345
  overall score, so a saturated <em>heavy</em> task matters most.</p>
346
346
  <div class="scroll">
@@ -352,9 +352,9 @@ function build(records, allRuns = records) {
352
352
  const tone = s.verdict === "saturated" ? "bad" : s.verdict === "weak" ? "warn" : "good";
353
353
  return (`<tr><td>${escapeHtml(s.category)}</td>` +
354
354
  `<td class="num">×${weightOf.get(s.id) ?? "?"}</td>` +
355
- `<td class="num">${s.min === undefined ? "" : `${(s.min * 100).toFixed(0)}%`}</td>` +
356
- `<td class="num">${s.max === undefined ? "" : `${(s.max * 100).toFixed(0)}%`}</td>` +
357
- `<td class="num strong">${s.spread === null ? "" : `${(s.spread * 100).toFixed(0)} pts`}</td>` +
355
+ `<td class="num">${s.min === undefined ? "-" : `${(s.min * 100).toFixed(0)}%`}</td>` +
356
+ `<td class="num">${s.max === undefined ? "-" : `${(s.max * 100).toFixed(0)}%`}</td>` +
357
+ `<td class="num strong">${s.spread === null ? "-" : `${(s.spread * 100).toFixed(0)} pts`}</td>` +
358
358
  `<td class="v-${tone}">${escapeHtml(s.verdict)}</td></tr>`);
359
359
  })
360
360
  .join("")}</tbody>
@@ -362,7 +362,7 @@ function build(records, allRuns = records) {
362
362
  </div>
363
363
  ${saturated.length
364
364
  ? `<ul class="notes"><li><strong>${saturated.map((s) => escapeHtml(s.category)).join(", ")}</strong>
365
- ${saturated.length === 1 ? "is" : "are"} saturated every model scores the same, so
365
+ ${saturated.length === 1 ? "is" : "are"} saturated - every model scores the same, so
366
366
  ${saturated.length === 1 ? "it needs" : "they need"} to get harder before
367
367
  ${saturated.length === 1 ? "it" : "they"} can contribute to a ranking.</li></ul>`
368
368
  : ""}
@@ -372,7 +372,7 @@ function build(records, allRuns = records) {
372
372
  <h2>Is the benchmark consistent?</h2>
373
373
  <p class="cap">The same model + settings, measured more than once. A method you can trust holds a
374
374
  flat line across runs; a jagged one means the score is noisy and small gaps between models are
375
- not real. Runs are numbered, not dated only how many times we have measured matters.</p>
375
+ not real. Runs are numbered, not dated - only how many times we have measured matters.</p>
376
376
  ${consistencySeries.length
377
377
  ? `
378
378
  ${legend(consistencySeries.map((s) => s.name.slice(0, 30)))}
@@ -380,7 +380,7 @@ function build(records, allRuns = records) {
380
380
  <p class="axl">run number</p>`
381
381
  : `
382
382
  <p class="empty">No config has been run more than once yet. Re-run a model a few times to see
383
- its spread the table below shows mean ± spread as repeats accumulate.</p>`}
383
+ its spread - the table below shows mean ± spread as repeats accumulate.</p>`}
384
384
  <div class="scroll">
385
385
  <table>
386
386
  <thead><tr><th>Model</th><th class="num">Runs</th><th class="num">Mean</th>
@@ -392,9 +392,9 @@ function build(records, allRuns = records) {
392
392
  const tone = v.count < 2 ? "muted" : spreadPts > 8 ? "v-warn" : "v-good";
393
393
  return (`<tr><td>${escapeHtml(v.model.displayName)}</td>` +
394
394
  `<td class="num">${v.count}</td>` +
395
- `<td class="num strong">${o.mean === undefined ? "" : `${(o.mean * 100).toFixed(0)}%`}</td>` +
396
- `<td class="num ${tone}">${v.count < 2 ? "" : `${spreadPts.toFixed(1)} pts`}</td>` +
397
- `<td class="num">${v.count < 2 ? "" : `${((o.min ?? 0) * 100).toFixed(0)}–${((o.max ?? 0) * 100).toFixed(0)}%`}</td></tr>`);
395
+ `<td class="num strong">${o.mean === undefined ? "-" : `${(o.mean * 100).toFixed(0)}%`}</td>` +
396
+ `<td class="num ${tone}">${v.count < 2 ? "-" : `${spreadPts.toFixed(1)} pts`}</td>` +
397
+ `<td class="num">${v.count < 2 ? "-" : `${((o.min ?? 0) * 100).toFixed(0)}–${((o.max ?? 0) * 100).toFixed(0)}%`}</td></tr>`);
398
398
  })
399
399
  .join("")}</tbody>
400
400
  </table>
@@ -406,7 +406,7 @@ function build(records, allRuns = records) {
406
406
  <section class="card">
407
407
  <h2>System health during runs</h2>
408
408
  <p class="cap">The machine state each score was measured under. A throttled or power-capped run is
409
- slower and lower for reasons that have nothing to do with the model read those scores with
409
+ slower and lower for reasons that have nothing to do with the model - read those scores with
410
410
  suspicion, or re-run them once the GPU has cooled.</p>
411
411
  <div class="scroll">
412
412
  <table>
@@ -418,10 +418,10 @@ function build(records, allRuns = records) {
418
418
  const throttled = h.thermalThrottle || h.powerThrottle;
419
419
  const label = h.thermalThrottle ? "thermal" : h.powerThrottle ? "power" : "no";
420
420
  return (`<tr><td>${escapeHtml(r.model.displayName)}</td>` +
421
- `<td class="num">${h.tempC ? `${h.tempC.max}°C` : ""}</td>` +
422
- `<td class="num">${h.powerW ? `${h.powerW.avg.toFixed(0)} W` : ""}</td>` +
423
- `<td class="num">${h.gpuUtilPct ? `${h.gpuUtilPct.avg.toFixed(0)}%` : ""}</td>` +
424
- `<td class="num">${h.vramUsedMiB ? `${(h.vramUsedMiB.max / 1024).toFixed(1)} GB` : ""}</td>` +
421
+ `<td class="num">${h.tempC ? `${h.tempC.max}°C` : "-"}</td>` +
422
+ `<td class="num">${h.powerW ? `${h.powerW.avg.toFixed(0)} W` : "-"}</td>` +
423
+ `<td class="num">${h.gpuUtilPct ? `${h.gpuUtilPct.avg.toFixed(0)}%` : "-"}</td>` +
424
+ `<td class="num">${h.vramUsedMiB ? `${(h.vramUsedMiB.max / 1024).toFixed(1)} GB` : "-"}</td>` +
425
425
  `<td class="${throttled ? "v-bad" : "v-good"}">${throttled ? `⚠ ${label}` : "no"}</td></tr>`);
426
426
  })
427
427
  .join("")}</tbody>
@@ -1,5 +1,6 @@
1
1
  import type { GpuInfo, Model, ModelFeatures } from "../types.js";
2
- import type { Profile } from "../config/schema.js";
2
+ import type { Calibration, Profile } from "../config/schema.js";
3
+ import type { FitResult } from "../vram.js";
3
4
  declare const RESULTS_DIR: string;
4
5
  /** Aggregate of a numeric health series (nvidia-smi samples during a run). */
5
6
  export interface AggStat {
@@ -49,7 +50,14 @@ export interface BenchReport {
49
50
  archiveId?: string | null;
50
51
  system?: SystemHealth | null;
51
52
  }
52
- /** The model identity persisted with a run. */
53
+ /**
54
+ * The model identity persisted with a run.
55
+ *
56
+ * The geometry fields are here because a context size is only sensible against
57
+ * them: 32k on a model whose GGUF header says 8k native is a setup error, not a
58
+ * capability, and layer/KV-head counts are what make a KV cost per token
59
+ * explicable rather than just large.
60
+ */
53
61
  export interface RecordModel {
54
62
  id: string | null;
55
63
  displayName: string;
@@ -58,15 +66,92 @@ export interface RecordModel {
58
66
  sizeBytes: number | null;
59
67
  publisher: string | null;
60
68
  features: ModelFeatures | null;
69
+ /** Native context from the GGUF header - the ceiling `contextSize` sits under. */
70
+ contextLength: number | null;
71
+ blockCount: number | null;
72
+ headCountKv: number | null;
61
73
  }
62
- /** The subset of a profile persisted with a run. */
74
+ /**
75
+ * The profile persisted with a run: every field that reaches llama-server.
76
+ *
77
+ * Schema 1 kept six of these. The omitted ones are exactly the ones that explain
78
+ * a bad score - `gpuLayers` short of the model's layer count silently runs part
79
+ * of it on the CPU, `parallelSlots` splits the context between slots so the
80
+ * effective window is a fraction of `contextSize`, and `extraArgs` can override
81
+ * anything above.
82
+ */
63
83
  export interface RecordProfile {
64
84
  contextSize: number;
65
85
  cacheTypeK: string;
66
86
  cacheTypeV: string;
67
87
  reasoningBudget: number;
88
+ reasoningBudgetMessage: string | null;
68
89
  vision: boolean;
69
90
  flashAttention: boolean;
91
+ gpuLayers: number | null;
92
+ parallelSlots: number | null;
93
+ batchSize: number | null;
94
+ ubatchSize: number | null;
95
+ extraArgs: string[];
96
+ }
97
+ /**
98
+ * How the run was actually set up, as opposed to how it was configured.
99
+ *
100
+ * The gap between the two is where bad scores come from. `fitToBudget` will cut
101
+ * a profile's context down to whatever fits the GPU and run anyway, which is the
102
+ * right call at load time and a silent lie afterwards: the record would say the
103
+ * model scored 41% at 128k context when it was really measured at 16k. Same for
104
+ * the KV budget - a context chosen off the theoretical formula (which
105
+ * overestimates by up to 4x) is a different measurement from one chosen off a
106
+ * calibration, even when the numbers land in the same place.
107
+ */
108
+ export interface RecordSetup {
109
+ /**
110
+ * The exact llama-server argv this run was served with. Every other field
111
+ * here is a convenience; this one is the ground truth, and it is what makes a
112
+ * run reproducible by hand.
113
+ */
114
+ args: string[] | null;
115
+ /** True when the VRAM fit had to change the profile for the run to happen. */
116
+ adjusted: boolean;
117
+ /** The context the profile asked for, before any fit adjustment. */
118
+ requestedContextSize: number | null;
119
+ /** Why the fit changed the profile, in the fitter's own words. */
120
+ adjustReason: string | null;
121
+ /** Where the KV bytes/token came from. `theoretical` means nobody measured. */
122
+ kvSource: "measured" | "theoretical" | "unknown" | null;
123
+ /**
124
+ * True when the calibration came from a relative with the same attention
125
+ * geometry rather than from this file. Never present it as measured.
126
+ */
127
+ kvInherited: boolean;
128
+ kvBytesPerToken: number | null;
129
+ /** The formula's answer, kept beside the measured one to expose the gap. */
130
+ theoreticalKvBytesPerToken: number | null;
131
+ /** What the budget predicted the run would take, against `vramBytes` observed. */
132
+ predictedVramBytes: number | null;
133
+ reserveBytes: number | null;
134
+ headroomBytes: number | null;
135
+ }
136
+ /**
137
+ * Which suite graded the run.
138
+ *
139
+ * Two runs scored on different task sets are not comparable, and neither are a
140
+ * run that was allowed to execute generated code and one that was only allowed
141
+ * to syntax-check it. `configKey` deliberately does not include any of this (see
142
+ * `configKey()` below), so it is recorded here instead of silently merging
143
+ * unlike runs into one group.
144
+ */
145
+ export interface RecordSuite {
146
+ /** False when `--no-execute` limited grading to a syntax check. */
147
+ execute: boolean | null;
148
+ concurrency: number | null;
149
+ /** Prompt depths for the depth-scaling task, when overridden. */
150
+ depths: number[] | null;
151
+ /** Task ids the run was restricted to (`--only`), when restricted. */
152
+ only: string[] | null;
153
+ /** True when the static suite was replaced by tasks mined from a repo. */
154
+ mined: boolean;
70
155
  }
71
156
  /** The GPU identity persisted with a run. */
72
157
  export interface RecordGpu {
@@ -85,12 +170,24 @@ export interface RecordTask {
85
170
  detail: unknown;
86
171
  error: string | null;
87
172
  }
88
- /** One stored benchmark run — the shared shape used across results and report. */
173
+ /**
174
+ * One stored benchmark run - the shared shape used across results and report.
175
+ *
176
+ * `schema` is 2 as of the setup-capture change: 2 carries the full profile plus
177
+ * `setup` and `suite`; 1 carried six profile fields and neither. Readers must
178
+ * treat everything added in 2 as absent on an older record rather than assuming
179
+ * it, because those runs are still perfectly good scores - they just cannot say
180
+ * what they were measured with.
181
+ */
89
182
  export interface RunRecord {
90
183
  schema: number;
91
184
  ranAt: string;
92
185
  model: RecordModel;
93
186
  profile: RecordProfile | null;
187
+ /** Schema 2+. Absent on runs saved before setup capture. */
188
+ setup: RecordSetup | null;
189
+ /** Schema 2+. Absent on runs saved before setup capture. */
190
+ suite: RecordSuite | null;
94
191
  configKey: string;
95
192
  archiveId: string | null;
96
193
  gpu: RecordGpu | null;
@@ -106,15 +203,29 @@ export interface RunRecord {
106
203
  file?: string;
107
204
  runIndex?: number;
108
205
  }
109
- /** Arguments to `save`. */
206
+ /**
207
+ * Arguments to `save`.
208
+ *
209
+ * Everything past `report` is optional so a caller that cannot supply it still
210
+ * writes a usable record - but every caller that CAN should, because a run saved
211
+ * without its setup is a score nobody can explain later.
212
+ */
110
213
  export interface SaveOptions {
111
214
  model: Model | null;
215
+ /** The profile the run was actually served with (post-fit, not as configured). */
112
216
  profile: Profile | null;
113
217
  report: BenchReport;
114
218
  gpu?: GpuInfo | null;
115
219
  runtime?: string | null;
116
220
  archiveId?: string | null;
117
221
  system?: SystemHealth | null;
222
+ /** The argv the supervisor launched, straight from `Supervisor.args`. */
223
+ args?: string[] | null;
224
+ /** The VRAM fit that produced `profile`, including what it had to change. */
225
+ fit?: FitResult | null;
226
+ /** The calibration the fit was computed against, if there was one. */
227
+ calibration?: Calibration | null;
228
+ suite?: Partial<RecordSuite> | null;
118
229
  timestamp?: Date;
119
230
  }
120
231
  /** Result of `save`: the written file path plus the persisted record. */
@@ -172,9 +283,17 @@ export interface TaskColumn {
172
283
  category: string;
173
284
  }
174
285
  declare function slugify(text: string): string;
175
- /** Stable identity for "same model, same settings", so reruns can be grouped. */
286
+ /**
287
+ * Stable identity for "same model, same settings", so reruns can be grouped.
288
+ *
289
+ * Deliberately unchanged when the record grew: this string is the grouping key
290
+ * every stored run was written with, and widening it would not re-key history -
291
+ * it would split each model's past runs from its future ones and quietly reset
292
+ * every variance figure on the page. New settings are recorded as data, not
293
+ * folded in here.
294
+ */
176
295
  declare function configKey(profile: Profile | null): string;
177
- declare function save({ model, profile, report, gpu, runtime, archiveId, system, timestamp, }: SaveOptions): SaveResult;
296
+ declare function save({ model, profile, report, gpu, runtime, archiveId, system, args, fit, calibration, suite, timestamp, }: SaveOptions): SaveResult;
178
297
  /** Every stored run, newest first. */
179
298
  declare function loadAll(): RunRecord[];
180
299
  /**
@@ -185,13 +304,13 @@ declare function latestPerConfig(records?: RunRecord[]): RunRecord[];
185
304
  /**
186
305
  * Group every run by model+config (all runs kept, not just the latest), and
187
306
  * number them oldest→newest so a run can be plotted against run number. Wall
188
- * time is deliberately not the axis only how many times we have measured it.
307
+ * time is deliberately not the axis - only how many times we have measured it.
189
308
  */
190
309
  declare function grouped(records?: RunRecord[]): Group[];
191
310
  /** count / mean / sample-std / min / max of a numeric series. */
192
311
  declare function stats(values: Array<number | undefined>): Stats | null;
193
312
  /**
194
- * Per-config spread of overall and per-task scores across repeated runs the
313
+ * Per-config spread of overall and per-task scores across repeated runs - the
195
314
  * measure of whether the benchmark method is itself consistent.
196
315
  */
197
316
  declare function variance(records?: RunRecord[]): VarianceRow[];
@@ -8,6 +8,16 @@ import { fileURLToPath } from "node:url";
8
8
  * compared and charted later. Runs record the configuration they were measured
9
9
  * under, because a score is meaningless without the quant, context size and
10
10
  * reasoning budget that produced it.
11
+ *
12
+ * **A run records every value it was measured with, not a summary of them.** A
13
+ * bad score is far more often a bad setup than a bad model, and the difference
14
+ * is only visible from the settings: a context the VRAM fit had to cut, a
15
+ * reasoning budget the model spends entirely on thinking, a KV quant that
16
+ * wrecked recall, weights that fell off the GPU because `gpuLayers` did not
17
+ * cover them, a budget estimated from the formula rather than measured. None of
18
+ * that is recoverable after the fact, so it is all written down at save time -
19
+ * including the exact llama-server argv the run was served with, which is the
20
+ * only true statement of what ran.
11
21
  */
12
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
13
23
  const ROOT = path.resolve(HERE, "..", "..");
@@ -19,7 +29,15 @@ function slugify(text) {
19
29
  .replace(/^-|-$/g, "")
20
30
  .slice(0, 80);
21
31
  }
22
- /** Stable identity for "same model, same settings", so reruns can be grouped. */
32
+ /**
33
+ * Stable identity for "same model, same settings", so reruns can be grouped.
34
+ *
35
+ * Deliberately unchanged when the record grew: this string is the grouping key
36
+ * every stored run was written with, and widening it would not re-key history -
37
+ * it would split each model's past runs from its future ones and quietly reset
38
+ * every variance figure on the page. New settings are recorded as data, not
39
+ * folded in here.
40
+ */
23
41
  function configKey(profile) {
24
42
  if (!profile)
25
43
  return "unknown";
@@ -30,10 +48,42 @@ function configKey(profile) {
30
48
  profile.vision ? "vision" : "novision",
31
49
  ].join("_");
32
50
  }
33
- function save({ model, profile, report, gpu = null, runtime = null, archiveId = null, system = null, timestamp = new Date(), }) {
51
+ /** Read a numeric GGUF metadata field, which is `null` when the header lacked it. */
52
+ function metadataNumber(model, key) {
53
+ const value = model?.metadata?.[key];
54
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
55
+ }
56
+ /**
57
+ * Fold the fit and the calibration into the record's setup block.
58
+ *
59
+ * `fit.profile` is the profile that actually ran, so the requested context has
60
+ * to be read from what the fit was HANDED, not from what it returned - by the
61
+ * time `save` sees a profile, the adjustment has already been applied and is
62
+ * invisible.
63
+ */
64
+ function buildSetup({ args, fit, calibration, }) {
65
+ if (!args && !fit && !calibration) {
66
+ return null;
67
+ }
68
+ const budget = fit?.budget ?? null;
69
+ return {
70
+ args: args ?? null,
71
+ adjusted: fit?.adjusted ?? false,
72
+ requestedContextSize: fit?.requestedContextSize ?? null,
73
+ adjustReason: fit?.reason ?? null,
74
+ kvSource: budget?.source ?? null,
75
+ kvInherited: calibration?.inherited === true,
76
+ kvBytesPerToken: budget?.kvBytesPerToken ?? null,
77
+ theoreticalKvBytesPerToken: budget?.theoreticalKvBytesPerToken ?? null,
78
+ predictedVramBytes: budget?.totalBytes ?? null,
79
+ reserveBytes: budget?.reserveBytes ?? null,
80
+ headroomBytes: budget?.headroomBytes ?? null,
81
+ };
82
+ }
83
+ function save({ model, profile, report, gpu = null, runtime = null, archiveId = null, system = null, args = null, fit = null, calibration = null, suite = null, timestamp = new Date(), }) {
34
84
  fs.mkdirSync(RESULTS_DIR, { recursive: true });
35
85
  const record = {
36
- schema: 1,
86
+ schema: 2,
37
87
  ranAt: timestamp.toISOString(),
38
88
  model: {
39
89
  id: model?.id ?? null,
@@ -43,15 +93,37 @@ function save({ model, profile, report, gpu = null, runtime = null, archiveId =
43
93
  sizeBytes: model?.sizeBytes ?? null,
44
94
  publisher: model?.publisher ?? null,
45
95
  features: model?.features ?? null,
96
+ contextLength: metadataNumber(model, "contextLength"),
97
+ blockCount: metadataNumber(model, "blockCount"),
98
+ headCountKv: metadataNumber(model, "headCountKv"),
46
99
  },
100
+ // The whole profile, not a summary of it. `ProfileSchema` is `.passthrough()`
101
+ // and its numeric fields carry defaults, so read them defensively: a profile
102
+ // written by an older brain can be missing anything below `vision`.
47
103
  profile: profile
48
104
  ? {
49
105
  contextSize: profile.contextSize,
50
106
  cacheTypeK: profile.cacheTypeK,
51
107
  cacheTypeV: profile.cacheTypeV,
52
108
  reasoningBudget: profile.reasoningBudget,
109
+ reasoningBudgetMessage: profile.reasoningBudgetMessage ?? null,
53
110
  vision: profile.vision,
54
111
  flashAttention: profile.flashAttention,
112
+ gpuLayers: profile.gpuLayers ?? null,
113
+ parallelSlots: profile.parallelSlots ?? null,
114
+ batchSize: profile.batchSize ?? null,
115
+ ubatchSize: profile.ubatchSize ?? null,
116
+ extraArgs: profile.extraArgs ?? [],
117
+ }
118
+ : null,
119
+ setup: buildSetup({ args, fit, calibration }),
120
+ suite: suite
121
+ ? {
122
+ execute: suite.execute ?? null,
123
+ concurrency: suite.concurrency ?? null,
124
+ depths: suite.depths ?? null,
125
+ only: suite.only ?? null,
126
+ mined: suite.mined ?? false,
55
127
  }
56
128
  : null,
57
129
  configKey: configKey(profile),
@@ -119,7 +191,7 @@ function latestPerConfig(records = loadAll()) {
119
191
  /**
120
192
  * Group every run by model+config (all runs kept, not just the latest), and
121
193
  * number them oldest→newest so a run can be plotted against run number. Wall
122
- * time is deliberately not the axis only how many times we have measured it.
194
+ * time is deliberately not the axis - only how many times we have measured it.
123
195
  */
124
196
  function grouped(records = loadAll()) {
125
197
  const groups = new Map();
@@ -157,7 +229,7 @@ function stats(values) {
157
229
  };
158
230
  }
159
231
  /**
160
- * Per-config spread of overall and per-task scores across repeated runs the
232
+ * Per-config spread of overall and per-task scores across repeated runs - the
161
233
  * measure of whether the benchmark method is itself consistent.
162
234
  */
163
235
  function variance(records = loadAll()) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Format renderers for the output layer. chalk lives here and nowhere else
2
+ * Format renderers for the output layer. chalk lives here and nowhere else -
3
3
  * command handlers express color declaratively via ColumnDef.color, and only
4
4
  * these renderers turn a result into text. Table output is ANSI-aware so colored
5
5
  * cells still align.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The output contract, mirroring @otto-code/cli's output layer: command handlers
3
- * do not print they return a typed result plus a schema, and a wrapper renders
3
+ * do not print - they return a typed result plus a schema, and a wrapper renders
4
4
  * it in the user-selected format (table / json / yaml / quiet). This is what lets
5
5
  * every `otto brain` command support `--format`, `--json`, and `--quiet` uniformly.
6
6
  */
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Translates a profile into llama-server arguments and builds the PATH the child
3
+ * needs. Runtime-source agnostic: works the same for an LM Studio runtime or a
4
+ * managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
5
+ */
1
6
  import type { Profile } from "../config/schema.js";
2
7
  import type { Runtime } from "../types.js";
3
8
  export interface ServeTarget {
@@ -5,14 +10,22 @@ export interface ServeTarget {
5
10
  host?: string;
6
11
  }
7
12
  /**
8
- * PATH value the child process needs so the stub can resolve its DLLs. Both the
9
- * runtime dir and its vendor dir go first, ahead of the inherited PATH.
13
+ * Loader environment the child process needs so it can resolve its shared
14
+ * libraries. Both the runtime dir and its vendor dir go first, ahead of the
15
+ * inherited values.
16
+ *
17
+ * PATH is the Windows half of this (the DLL-stub trap). The other two platforms
18
+ * do not read PATH for libraries at all: a llama.cpp tarball puts
19
+ * `libggml*.so`/`libllama.so` (or the `.dylib` equivalents) next to the binary,
20
+ * so Linux needs LD_LIBRARY_PATH and macOS needs DYLD_LIBRARY_PATH or the
21
+ * binary dies at load with an unresolved-library error before it prints a line.
22
+ * PATH is still set everywhere - harmless, and it keeps the shape uniform.
10
23
  */
11
- export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
24
+ export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
12
25
  /**
13
26
  * Translate a profile into llama-server arguments.
14
27
  *
15
- * Only settings that demonstrably matter for stable local inference are emitted
28
+ * Only settings that demonstrably matter for stable local inference are emitted -
16
29
  * no experimental sampler knobs.
17
30
  */
18
31
  export declare function buildArgs(profile: Profile, { port, host }: ServeTarget): string[];
@@ -1,26 +1,32 @@
1
1
  /**
2
- * Translates a profile into llama-server arguments and builds the PATH the child
3
- * needs. Runtime-source agnostic: works the same for an LM Studio runtime or a
4
- * managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
5
- */
6
- import path from "node:path";
7
- /**
8
- * PATH value the child process needs so the stub can resolve its DLLs. Both the
9
- * runtime dir and its vendor dir go first, ahead of the inherited PATH.
2
+ * Loader environment the child process needs so it can resolve its shared
3
+ * libraries. Both the runtime dir and its vendor dir go first, ahead of the
4
+ * inherited values.
5
+ *
6
+ * PATH is the Windows half of this (the DLL-stub trap). The other two platforms
7
+ * do not read PATH for libraries at all: a llama.cpp tarball puts
8
+ * `libggml*.so`/`libllama.so` (or the `.dylib` equivalents) next to the binary,
9
+ * so Linux needs LD_LIBRARY_PATH and macOS needs DYLD_LIBRARY_PATH or the
10
+ * binary dies at load with an unresolved-library error before it prints a line.
11
+ * PATH is still set everywhere - harmless, and it keeps the shape uniform.
10
12
  */
11
- export function buildEnv(runtime, baseEnv = process.env) {
13
+ export function buildEnv(runtime, baseEnv = process.env, platform = process.platform) {
12
14
  const parts = [runtime.dir];
13
15
  if (runtime.vendorDir)
14
16
  parts.push(runtime.vendorDir);
15
- return {
16
- ...baseEnv,
17
- PATH: `${parts.join(path.delimiter)}${path.delimiter}${baseEnv.PATH || ""}`,
18
- };
17
+ const delimiter = platform === "win32" ? ";" : ":";
18
+ const prepend = (existing) => `${parts.join(delimiter)}${delimiter}${existing || ""}`;
19
+ const env = { ...baseEnv, PATH: prepend(baseEnv.PATH) };
20
+ if (platform === "darwin")
21
+ env.DYLD_LIBRARY_PATH = prepend(baseEnv.DYLD_LIBRARY_PATH);
22
+ else if (platform !== "win32")
23
+ env.LD_LIBRARY_PATH = prepend(baseEnv.LD_LIBRARY_PATH);
24
+ return env;
19
25
  }
20
26
  /**
21
27
  * Translate a profile into llama-server arguments.
22
28
  *
23
- * Only settings that demonstrably matter for stable local inference are emitted
29
+ * Only settings that demonstrably matter for stable local inference are emitted -
24
30
  * no experimental sampler knobs.
25
31
  */
26
32
  export function buildArgs(profile, { port, host = "127.0.0.1" }) {
@@ -1,13 +1,19 @@
1
1
  import type { BrainConfig } from "../config/schema.js";
2
2
  import type { Runtime } from "../types.js";
3
- import { type InstallProgress } from "./managed.js";
3
+ import { type InstallProgress, type RuntimeTarget } from "./managed.js";
4
4
  export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
5
5
  export { buildArgs, buildEnv, formatCommand, type ServeTarget } from "./args.js";
6
- export { installManagedRuntime, listManagedRuntimes, defaultRuntimeSpec, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type InstallProgress, } from "./managed.js";
6
+ export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
7
7
  /** Every runtime available on this machine, managed first then LM Studio. */
8
8
  export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
9
+ /**
10
+ * Whether this machine has an NVIDIA GPU, for picking a managed build. Returns
11
+ * false rather than throwing when nvidia-smi is absent, which is the normal
12
+ * case on macOS and on AMD/Intel machines.
13
+ */
14
+ export declare function probeNvidiaGpu(): Promise<boolean>;
9
15
  /** The runtime to use given config, or null when none is available. */
10
16
  export declare function resolveRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv): Runtime | null;
11
17
  /** Ensure a runtime exists, downloading the default managed build if none does. */
12
- export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void): Promise<Runtime>;
18
+ export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void, target?: RuntimeTarget): Promise<Runtime>;
13
19
  //# sourceMappingURL=index.d.ts.map