@otto-code/brain 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) 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 +1 -1
  22. package/dist/commands/scan.d.ts +1 -1
  23. package/dist/commands/scan.js +6 -1
  24. package/dist/commands/search.d.ts +1 -1
  25. package/dist/commands/share.js +2 -2
  26. package/dist/commands/sweep.d.ts +2 -2
  27. package/dist/commands/sweep.js +22 -8
  28. package/dist/commands/ui.d.ts +1 -1
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +2 -1
  31. package/dist/config/otto-home.js +1 -1
  32. package/dist/config/paths.d.ts +1 -0
  33. package/dist/config/paths.js +4 -0
  34. package/dist/config/profile-edit.d.ts +94 -0
  35. package/dist/config/profile-edit.js +269 -0
  36. package/dist/config/profiles.d.ts +2 -2
  37. package/dist/config/profiles.js +1 -1
  38. package/dist/config/schema.d.ts +4 -4
  39. package/dist/config/schema.js +6 -6
  40. package/dist/config/store.d.ts +1 -1
  41. package/dist/config/store.js +1 -1
  42. package/dist/models/download.js +1 -1
  43. package/dist/models/enrich.d.ts +2 -2
  44. package/dist/models/index.d.ts +1 -1
  45. package/dist/models/index.js +1 -1
  46. package/dist/models/pick.d.ts +9 -0
  47. package/dist/models/pick.js +24 -1
  48. package/dist/ops/report.js +28 -28
  49. package/dist/ops/results.d.ts +128 -9
  50. package/dist/ops/results.js +77 -5
  51. package/dist/output/render.js +1 -1
  52. package/dist/output/types.d.ts +1 -1
  53. package/dist/runtime/args.d.ts +1 -1
  54. package/dist/runtime/args.js +1 -1
  55. package/dist/runtime/managed.js +4 -4
  56. package/dist/service/activity.d.ts +83 -0
  57. package/dist/service/activity.js +216 -0
  58. package/dist/service/host-api.d.ts +132 -0
  59. package/dist/service/host-api.js +397 -0
  60. package/dist/service/http-util.d.ts +27 -0
  61. package/dist/service/http-util.js +72 -0
  62. package/dist/service/model-selector.d.ts +2 -2
  63. package/dist/service/model-selector.js +6 -6
  64. package/dist/service/router.d.ts +28 -4
  65. package/dist/service/router.js +128 -94
  66. package/dist/service/scheduler.d.ts +2 -2
  67. package/dist/service/scheduler.js +1 -1
  68. package/dist/service/serve.d.ts +2 -2
  69. package/dist/service/serve.js +51 -8
  70. package/dist/service/supervisor.d.ts +6 -0
  71. package/dist/service/supervisor.js +2 -0
  72. package/dist/service/tailscale.js +1 -1
  73. package/dist/service/tls.d.ts +4 -4
  74. package/dist/service/tls.js +3 -3
  75. package/dist/sysmon.d.ts +20 -4
  76. package/dist/sysmon.js +42 -18
  77. package/dist/tui/app.d.ts +12 -2
  78. package/dist/tui/app.js +46 -22
  79. package/dist/vram.d.ts +8 -1
  80. package/dist/vram.js +6 -3
  81. package/package.json +1 -1
package/dist/sysmon.d.ts CHANGED
@@ -7,17 +7,29 @@ import { query } from "./gpu.js";
7
7
  * inferred from request counting, so it reflects what the engine is really
8
8
  * doing. That matters for agentic use: several concurrent requests are only
9
9
  * genuinely parallel if there are free slots to take them, and slots share one
10
- * KV pool so more concurrency costs context per request.
10
+ * KV pool - so more concurrency costs context per request.
11
11
  */
12
12
  /** A CPU busy-fraction sampler: returns the fraction in [0,1], or null. */
13
13
  export interface CpuSampler {
14
14
  (): number | null;
15
15
  }
16
- /** Slot occupancy from the running server. */
16
+ /**
17
+ * Slot occupancy from the running server.
18
+ *
19
+ * `busy` is the sum of `prefill` and `decode`. The split matters because the two
20
+ * phases feel completely different from outside: prefill is a single batched
21
+ * pass over the prompt that pins the GPU and returns nothing, decode is the
22
+ * token-at-a-time stream. A UI that can only say "busy" cannot tell a long
23
+ * prompt being ingested from a model that has started answering.
24
+ */
17
25
  export interface SlotInfo {
18
26
  total: number;
19
27
  busy: number;
20
28
  idle: number;
29
+ /** Slots ingesting a prompt: processing, but not a single token emitted yet. */
30
+ prefill: number;
31
+ /** Slots emitting tokens. */
32
+ decode: number;
21
33
  contexts: number[];
22
34
  }
23
35
  /** One combined reading for the status panel. */
@@ -37,9 +49,13 @@ interface Endpoint {
37
49
  /** CPU busy fraction, sampled between successive calls. */
38
50
  export declare function createCpuSampler(): CpuSampler;
39
51
  /**
40
- * Slot occupancy from the running server.
41
- * @returns {{total:number, busy:number, idle:number, contexts:number[]}|null}
52
+ * Reduce llama-server's `/slots` array to the occupancy the UI shows.
53
+ *
54
+ * Exported separately from the fetch so the phase split can be tested against
55
+ * the field spellings real llama.cpp builds emit, without a live server.
42
56
  */
57
+ export declare function summariseSlots(rows: unknown[]): SlotInfo;
58
+ /** Slot occupancy from the running server. */
43
59
  declare function slots({ host, port }: {
44
60
  host: string;
45
61
  port: number;
package/dist/sysmon.js CHANGED
@@ -51,28 +51,45 @@ function fetchJson({ host, port, path: urlPath, timeout = 2500, }) {
51
51
  req.on("error", () => resolve(null));
52
52
  });
53
53
  }
54
+ /** Whether a slot is doing anything. Field naming has varied across versions. */
55
+ function isProcessing(rec) {
56
+ if (typeof rec.is_processing === "boolean")
57
+ return rec.is_processing;
58
+ if (typeof rec.state === "number")
59
+ return rec.state !== 0;
60
+ return false;
61
+ }
62
+ /** How many tokens this slot has emitted for the request it is on. */
63
+ function decodedTokens(rec) {
64
+ for (const key of ["n_decoded", "n_decoded_tokens", "tokens_predicted"]) {
65
+ const value = rec[key];
66
+ if (typeof value === "number" && Number.isFinite(value))
67
+ return value;
68
+ }
69
+ // No counter at all: report a non-zero so the slot lands in decode rather than
70
+ // claiming a prefill that may never have been happening.
71
+ return 1;
72
+ }
54
73
  /**
55
- * Slot occupancy from the running server.
56
- * @returns {{total:number, busy:number, idle:number, contexts:number[]}|null}
74
+ * Reduce llama-server's `/slots` array to the occupancy the UI shows.
75
+ *
76
+ * Exported separately from the fetch so the phase split can be tested against
77
+ * the field spellings real llama.cpp builds emit, without a live server.
57
78
  */
58
- async function slots({ host, port }) {
59
- const data = await fetchJson({ host, port, path: "/slots" });
60
- if (!Array.isArray(data))
61
- return null;
62
- const rows = data;
63
- // Field naming has varied across llama.cpp versions; accept either.
64
- const busy = rows.filter((s) => {
65
- const rec = s;
66
- if (typeof rec.is_processing === "boolean")
67
- return rec.is_processing;
68
- if (typeof rec.state === "number")
69
- return rec.state !== 0;
70
- return false;
71
- }).length;
79
+ export function summariseSlots(rows) {
80
+ const busyRows = rows.filter((s) => isProcessing(s));
81
+ // A busy slot that has not yet decoded a token is still ingesting its prompt.
82
+ // The decoded counter is the only field that separates the two phases, and it
83
+ // has been spelled three ways across llama.cpp versions; a slot that reports
84
+ // none of them counts as decode, because a busy slot that cannot prove it is
85
+ // still prefilling is far more likely to be mid-answer than mid-prompt.
86
+ const prefill = busyRows.filter((s) => decodedTokens(s) === 0).length;
72
87
  return {
73
88
  total: rows.length,
74
- busy,
75
- idle: rows.length - busy,
89
+ busy: busyRows.length,
90
+ idle: rows.length - busyRows.length,
91
+ prefill,
92
+ decode: busyRows.length - prefill,
76
93
  contexts: rows.map((s) => {
77
94
  const rec = s;
78
95
  const nCtx = typeof rec.n_ctx === "number" ? rec.n_ctx : undefined;
@@ -81,6 +98,13 @@ async function slots({ host, port }) {
81
98
  }),
82
99
  };
83
100
  }
101
+ /** Slot occupancy from the running server. */
102
+ async function slots({ host, port }) {
103
+ const data = await fetchJson({ host, port, path: "/slots" });
104
+ if (!Array.isArray(data))
105
+ return null;
106
+ return summariseSlots(data);
107
+ }
84
108
  export { slots };
85
109
  /** One combined reading for the status panel. */
86
110
  export async function sample(sampler, { host, port } = {}) {
package/dist/tui/app.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Screen } from "./screen.js";
2
2
  import type { DiskUsage, QuantOption, RepoQuants, ModelSearchResult } from "../models/index.js";
3
+ import * as vram from "../vram.js";
3
4
  import { Supervisor } from "../service/supervisor.js";
4
5
  import { Telemetry } from "../service/router.js";
5
6
  import http from "node:http";
@@ -103,6 +104,15 @@ export declare class App {
103
104
  telemetry: Telemetry;
104
105
  supervisor: Supervisor;
105
106
  routerServer: http.Server | null;
107
+ /**
108
+ * The VRAM fit behind the resident model, so a benchmark can record whether
109
+ * the profile it measured was the profile the user configured. Keyed by model
110
+ * id because a fit computed for one model says nothing about the next.
111
+ */
112
+ lastFit: {
113
+ modelId: string;
114
+ fit: vram.FitResult;
115
+ } | null;
106
116
  filterMode: boolean;
107
117
  confirming: ConfirmState | null;
108
118
  picker: PickerState | null;
@@ -199,8 +209,8 @@ export declare class App {
199
209
  drawHelp(cols: number): void;
200
210
  /**
201
211
  * The key hints for the current mode, as an array of lines. Groups are
202
- * deliberately broken onto separate lines navigation first, then the
203
- * actions and each group wraps further only if the terminal is too narrow.
212
+ * deliberately broken onto separate lines - navigation first, then the
213
+ * actions - and each group wraps further only if the terminal is too narrow.
204
214
  */
205
215
  keybindings(cols: number): string[];
206
216
  }
package/dist/tui/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Screen, box, meter, style, pad, truncate, width, onKeys } from "./screen.js";
2
2
  import { scanModels, managedModelsDir, diskUsage, totalModelBytes, planDelete, deleteModelFiles, listRepoQuants, searchModels, downloadRepoFiles, resolveHfToken, } from "../models/index.js";
3
3
  import * as profiles from "../config/profiles.js";
4
- import { loadBrainConfig, loadProfilesStore, saveProfilesStore } from "../config/index.js";
4
+ import { formatReasoningBudget, loadBrainConfig, loadProfilesStore, saveProfilesStore, UNRESTRICTED_REASONING_BUDGET, } from "../config/index.js";
5
5
  import * as vram from "../vram.js";
6
6
  import * as gpu from "../gpu.js";
7
7
  import { calibrate } from "../ops/calibrate.js";
@@ -84,8 +84,8 @@ export const FIELDS = [
84
84
  kind: "cycle",
85
85
  values: REASONING_CYCLE,
86
86
  format: (p) => {
87
- if (p.reasoningBudget === -1)
88
- return `${style.red}unrestricted${style.reset}`;
87
+ if (p.reasoningBudget === UNRESTRICTED_REASONING_BUDGET)
88
+ return `${style.red}${formatReasoningBudget(p.reasoningBudget)}${style.reset}`;
89
89
  if (p.reasoningBudget === 0)
90
90
  return `${style.cyan}thinking off${style.reset}`;
91
91
  return `${p.reasoningBudget} tokens`;
@@ -114,6 +114,12 @@ export const FIELDS = [
114
114
  ];
115
115
  export class App {
116
116
  constructor({ runtime, listenPort, listenHost }) {
117
+ /**
118
+ * The VRAM fit behind the resident model, so a benchmark can record whether
119
+ * the profile it measured was the profile the user configured. Keyed by model
120
+ * id because a fit computed for one model says nothing about the next.
121
+ */
122
+ this.lastFit = null;
117
123
  this.filterMode = false;
118
124
  this.confirming = null;
119
125
  this.picker = null;
@@ -199,6 +205,7 @@ export class App {
199
205
  async loadModelFitted(target) {
200
206
  const info = this.gpuInfo || (await gpu.query());
201
207
  let profile = profiles.forModel(this.store, target);
208
+ this.lastFit = null;
202
209
  if (info) {
203
210
  const fit = vram.fitToBudget({
204
211
  model: target,
@@ -208,6 +215,9 @@ export class App {
208
215
  });
209
216
  if (!fit.adjusted && !fit.budget.fits)
210
217
  throw new Error(fit.reason ?? undefined);
218
+ // Held for the benchmark to record: once `fit.profile` is applied, the
219
+ // context the user actually asked for is gone from every other source.
220
+ this.lastFit = { modelId: target.id, fit };
211
221
  profile = fit.profile;
212
222
  }
213
223
  this.setStatus(`switching to ${target.displayName}…`, "info");
@@ -615,7 +625,7 @@ export class App {
615
625
  }
616
626
  const plan = planDelete(model);
617
627
  this.confirming = { kind: "delete", model };
618
- this.setStatus(`delete ${model.displayName} frees ${vram.formatGiB(plan.bytes)}` +
628
+ this.setStatus(`delete ${model.displayName} - frees ${vram.formatGiB(plan.bytes)}` +
619
629
  `${plan.includesProjector ? " (incl. projector)" : ""}? y / n`, "warn");
620
630
  this.draw();
621
631
  }
@@ -629,7 +639,7 @@ export class App {
629
639
  const plan = deleteModelFiles(confirming.model);
630
640
  this.reload();
631
641
  void this.refreshDisk();
632
- this.setStatus(`deleted ${confirming.model.displayName} freed ${vram.formatGiB(plan.bytes)}`, "good");
642
+ this.setStatus(`deleted ${confirming.model.displayName} - freed ${vram.formatGiB(plan.bytes)}`, "good");
633
643
  });
634
644
  }
635
645
  else if (key === "n" || key === "escape") {
@@ -893,7 +903,7 @@ export class App {
893
903
  }
894
904
  const lines = [this.header(cols), ""];
895
905
  lines.push(...box({
896
- title: `Download quant ${truncate(picker.repo, Math.max(8, inner - 18))}`,
906
+ title: `Download quant - ${truncate(picker.repo, Math.max(8, inner - 18))}`,
897
907
  lines: rows,
898
908
  innerWidth: inner,
899
909
  footer: picker.loading
@@ -1101,7 +1111,7 @@ export class App {
1101
1111
  const archiveId = archive.runId(model);
1102
1112
  const healthSampler = health.start();
1103
1113
  // stop() is idempotent (clearInterval); guarantee the 1s nvidia-smi
1104
- // sampler never outlives the run even if runSuite throws otherwise a
1114
+ // sampler never outlives the run even if runSuite throws - otherwise a
1105
1115
  // failed bench leaks a recurring subprocess.
1106
1116
  let sampled = false;
1107
1117
  try {
@@ -1120,7 +1130,7 @@ export class App {
1120
1130
  `${p.title}: ${(p.score * 100).toFixed(0)}% ${p.summary}`;
1121
1131
  }
1122
1132
  else if (p.phase === "failed")
1123
- this.benchProgress.push(`${p.title}: failed ${p.summary}`);
1133
+ this.benchProgress.push(`${p.title}: failed - ${p.summary}`);
1124
1134
  this.draw();
1125
1135
  },
1126
1136
  });
@@ -1136,10 +1146,24 @@ export class App {
1136
1146
  runtime: `${this.runtime.label} v${this.runtime.version}`,
1137
1147
  system: report.system,
1138
1148
  archiveId,
1149
+ args: this.supervisor.args,
1150
+ // Only when it belongs to the model actually being benchmarked - the
1151
+ // model may have been resident since before this fit was computed.
1152
+ fit: this.lastFit?.modelId === model.id ? this.lastFit.fit : null,
1153
+ calibration: profile ? profiles.getCalibration(this.store, model, profile) : null,
1154
+ suite: {
1155
+ // The TUI runs the full static suite; only concurrency varies, and
1156
+ // it tracks the profile's slot count the same way runSuite is called.
1157
+ execute: true,
1158
+ concurrency: Math.max(1, profile?.parallelSlots || 3),
1159
+ depths: null,
1160
+ only: null,
1161
+ mined: false,
1162
+ },
1139
1163
  });
1140
1164
  this.loadBenchResults();
1141
1165
  this.loadRankings();
1142
- this.benchProgress.push(`done overall ${(report.overall * 100).toFixed(0)}% (${report.grade})`);
1166
+ this.benchProgress.push(`done - overall ${(report.overall * 100).toFixed(0)}% (${report.grade})`);
1143
1167
  this.setStatus(`benchmark complete: ${model.displayName} ${(report.overall * 100).toFixed(0)}% (${report.grade})`, "good");
1144
1168
  }
1145
1169
  finally {
@@ -1347,7 +1371,7 @@ export class App {
1347
1371
  return box({
1348
1372
  title: "Leaderboard",
1349
1373
  lines: [
1350
- `${style.grey}no benchmarks yet select a model and press r to run one${style.reset}`,
1374
+ `${style.grey}no benchmarks yet - select a model and press r to run one${style.reset}`,
1351
1375
  ],
1352
1376
  innerWidth: innerWidth - 2,
1353
1377
  });
@@ -1383,7 +1407,7 @@ export class App {
1383
1407
  const bodyRows = Math.max(1, this.screen.rows - 1 - overhead);
1384
1408
  const body = [];
1385
1409
  if (logs.length === 0) {
1386
- body.push(`${style.grey}no logs yet start a model with s to see llama-server output${style.reset}`);
1410
+ body.push(`${style.grey}no logs yet - start a model with s to see llama-server output${style.reset}`);
1387
1411
  }
1388
1412
  else {
1389
1413
  for (const line of logs.slice(Math.max(0, logs.length - bodyRows)))
@@ -1658,19 +1682,19 @@ export class App {
1658
1682
  section("Change settings (Configuration panel)");
1659
1683
  item("← →", "change the selected field (toggle / cycle / ± step)");
1660
1684
  item("- +", "same as ← →");
1661
- item("Enter", "edit a number field type digits, Enter saves, Esc cancels");
1685
+ item("Enter", "edit a number field - type digits, Enter saves, Esc cancels");
1662
1686
  item("m", "set context to the largest size that fits in VRAM");
1663
1687
  section("Run the model");
1664
1688
  item("s", "start / load the selected model");
1665
1689
  item("x", "stop the running model");
1666
- item("c", "calibrate measure real VRAM per token");
1667
- item("w", "sweep find the best reasoning budget");
1690
+ item("c", "calibrate - measure real VRAM per token");
1691
+ item("w", "sweep - find the best reasoning budget");
1668
1692
  section("Manage models");
1669
- item("f", "find on Hugging Face search and add a new model");
1670
- item("g", "get a quant pick Q4/Q5/Q6… to download for this repo");
1693
+ item("f", "find on Hugging Face - search and add a new model");
1694
+ item("g", "get a quant - pick Q4/Q5/Q6… to download for this repo");
1671
1695
  item("D", "delete the selected model (frees disk, asks to confirm)");
1672
1696
  section("Views");
1673
- item("b", "benchmark mode rank models, run the coding suite");
1697
+ item("b", "benchmark mode - rank models, run the coding suite");
1674
1698
  item("l", "view the live llama-server log");
1675
1699
  item("/", "filter the model list (Enter apply, Esc clear)");
1676
1700
  item("r", "rescan the models folder");
@@ -1680,7 +1704,7 @@ export class App {
1680
1704
  item("q Ctrl-C", "quit Otto Brain");
1681
1705
  const lines = [this.header(cols), ""];
1682
1706
  lines.push(...box({
1683
- title: "Help every key and what it does",
1707
+ title: "Help - every key and what it does",
1684
1708
  lines: rows,
1685
1709
  innerWidth: inner,
1686
1710
  footer: `${style.grey}esc or ? to go back${style.reset}`,
@@ -1690,8 +1714,8 @@ export class App {
1690
1714
  }
1691
1715
  /**
1692
1716
  * The key hints for the current mode, as an array of lines. Groups are
1693
- * deliberately broken onto separate lines navigation first, then the
1694
- * actions and each group wraps further only if the terminal is too narrow.
1717
+ * deliberately broken onto separate lines - navigation first, then the
1718
+ * actions - and each group wraps further only if the terminal is too narrow.
1695
1719
  */
1696
1720
  keybindings(cols) {
1697
1721
  let groups;
@@ -1752,14 +1776,14 @@ export class App {
1752
1776
  }
1753
1777
  else {
1754
1778
  groups = [
1755
- // Navigation line one.
1779
+ // Navigation - line one.
1756
1780
  [
1757
1781
  ["↑↓", "select"],
1758
1782
  ["tab", "panel"],
1759
1783
  ["←→", "change"],
1760
1784
  ["enter", "edit"],
1761
1785
  ],
1762
- // Actions line two onward.
1786
+ // Actions - line two onward.
1763
1787
  [
1764
1788
  ["s", "start"],
1765
1789
  ["x", "stop"],
package/dist/vram.d.ts CHANGED
@@ -53,12 +53,19 @@ export interface FitResult {
53
53
  adjusted: boolean;
54
54
  reason: string | null;
55
55
  budget: Budget;
56
+ /**
57
+ * The context the caller asked for, before any adjustment. `profile` is the
58
+ * one that will actually run, so once a fit has been applied the original is
59
+ * unrecoverable from it - and a benchmark that does not record what it asked
60
+ * for cannot later explain why it scored the way it did.
61
+ */
62
+ requestedContextSize: number;
56
63
  }
57
64
  /**
58
65
  * Adapt a profile to the hardware it is about to run on.
59
66
  *
60
67
  * Refusing to load because a saved profile asks for more context than this
61
- * machine has is unhelpful when a slightly smaller context would work and it
68
+ * machine has is unhelpful when a slightly smaller context would work - and it
62
69
  * is the difference between a model being usable on a 32GB desktop and a 24GB
63
70
  * laptop. Clamp the context instead, and report what changed.
64
71
  */
package/dist/vram.js CHANGED
@@ -99,14 +99,15 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
99
99
  * Adapt a profile to the hardware it is about to run on.
100
100
  *
101
101
  * Refusing to load because a saved profile asks for more context than this
102
- * machine has is unhelpful when a slightly smaller context would work and it
102
+ * machine has is unhelpful when a slightly smaller context would work - and it
103
103
  * is the difference between a model being usable on a 32GB desktop and a 24GB
104
104
  * laptop. Clamp the context instead, and report what changed.
105
105
  */
106
106
  export function fitToBudget({ model, profile, calibration, totalVramBytes, reserveBytes = 1.5 * GIB, }) {
107
+ const requestedContextSize = profile.contextSize;
107
108
  const initial = budget({ model, profile, calibration, totalVramBytes, reserveBytes });
108
109
  if (initial.fits) {
109
- return { profile, adjusted: false, reason: null, budget: initial };
110
+ return { profile, adjusted: false, reason: null, budget: initial, requestedContextSize };
110
111
  }
111
112
  const max = maxContextThatFits({ model, profile, calibration, totalVramBytes, reserveBytes });
112
113
  if (!max || max < 4096) {
@@ -116,14 +117,16 @@ export function fitToBudget({ model, profile, calibration, totalVramBytes, reser
116
117
  reason: `does not fit at any usable context (needs ${formatGiB(initial.totalBytes)}, ` +
117
118
  `${formatGiB(initial.usableBytes)} usable)`,
118
119
  budget: initial,
120
+ requestedContextSize,
119
121
  };
120
122
  }
121
123
  const fitted = { ...profile, contextSize: max };
122
124
  return {
123
125
  profile: fitted,
124
126
  adjusted: true,
125
- reason: `context reduced ${profile.contextSize.toLocaleString()} -> ${max.toLocaleString()} to fit ${formatGiB(totalVramBytes)} of VRAM`,
127
+ reason: `context reduced ${requestedContextSize.toLocaleString()} -> ${max.toLocaleString()} to fit ${formatGiB(totalVramBytes)} of VRAM`,
126
128
  budget: budget({ model, profile: fitted, calibration, totalVramBytes, reserveBytes }),
129
+ requestedContextSize,
127
130
  };
128
131
  }
129
132
  export function formatGiB(bytes, digits = 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {