@otto-code/brain 0.8.9 → 0.8.12

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 (59) hide show
  1. package/dist/commands/calibrate.js +9 -0
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.d.ts +1 -0
  5. package/dist/commands/pull.js +12 -3
  6. package/dist/commands/search.d.ts +1 -0
  7. package/dist/commands/search.js +12 -2
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +1 -1
  10. package/dist/config/profile-edit.d.ts +88 -1
  11. package/dist/config/profile-edit.js +280 -29
  12. package/dist/config/profiles.js +16 -0
  13. package/dist/config/schema.d.ts +608 -0
  14. package/dist/config/schema.js +58 -0
  15. package/dist/config/store.js +7 -4
  16. package/dist/gguf.d.ts +7 -0
  17. package/dist/gguf.js +15 -2
  18. package/dist/models/download.d.ts +1 -1
  19. package/dist/models/download.js +2 -2
  20. package/dist/models/enrich.d.ts +6 -0
  21. package/dist/models/enrich.js +27 -1
  22. package/dist/models/index.d.ts +1 -1
  23. package/dist/models/index.js +4 -3
  24. package/dist/ops/calibrate.d.ts +38 -3
  25. package/dist/ops/calibrate.js +68 -19
  26. package/dist/ops/report.js +51 -1
  27. package/dist/ops/results.d.ts +57 -11
  28. package/dist/ops/results.js +75 -10
  29. package/dist/ops/sweep.d.ts +38 -1
  30. package/dist/ops/sweep.js +61 -10
  31. package/dist/runtime/args.d.ts +15 -2
  32. package/dist/runtime/args.js +60 -5
  33. package/dist/runtime/managed.js +2 -2
  34. package/dist/service/activity.d.ts +19 -0
  35. package/dist/service/activity.js +47 -4
  36. package/dist/service/host-api.d.ts +25 -4
  37. package/dist/service/host-api.js +82 -16
  38. package/dist/service/log-format.d.ts +18 -0
  39. package/dist/service/log-format.js +32 -0
  40. package/dist/service/router.d.ts +70 -2
  41. package/dist/service/router.js +219 -21
  42. package/dist/service/run-log.d.ts +6 -1
  43. package/dist/service/run-log.js +46 -4
  44. package/dist/service/scheduler.d.ts +227 -24
  45. package/dist/service/scheduler.js +395 -63
  46. package/dist/service/serve.d.ts +4 -0
  47. package/dist/service/serve.js +302 -117
  48. package/dist/service/status-events.d.ts +14 -1
  49. package/dist/service/status-events.js +111 -12
  50. package/dist/service/supervisor.d.ts +9 -7
  51. package/dist/service/supervisor.js +37 -12
  52. package/dist/sysmon.d.ts +15 -0
  53. package/dist/sysmon.js +56 -9
  54. package/dist/tui/app.d.ts +8 -2
  55. package/dist/tui/app.js +65 -17
  56. package/dist/types.d.ts +18 -0
  57. package/dist/vram.d.ts +37 -0
  58. package/dist/vram.js +57 -18
  59. package/package.json +1 -1
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 { formatReasoningBudget, loadBrainConfig, loadProfilesStore, saveProfilesStore, UNRESTRICTED_REASONING_BUDGET, } from "../config/index.js";
4
+ import { formatReasoningBudget, loadBrainConfig, loadProfilesStore, profileWarnings, 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";
@@ -61,6 +61,27 @@ export const FIELDS = [
61
61
  values: CACHE_CYCLE,
62
62
  format: (p) => p.cacheTypeV,
63
63
  },
64
+ {
65
+ key: "parallelSlots",
66
+ label: "Parallel slots",
67
+ kind: "number",
68
+ step: 1,
69
+ min: 1,
70
+ max: () => 16,
71
+ format: (p) => String(p.parallelSlots),
72
+ note: (p) => p.parallelSlots > 1 ? `${p.parallelSlots} concurrent requests, sharing one KV pool` : null,
73
+ },
74
+ {
75
+ key: "cachedChats",
76
+ label: "Cached KVs",
77
+ kind: "number",
78
+ step: 1,
79
+ min: 0,
80
+ max: () => 64,
81
+ format: (p) => (p.cachedChats ?? 0) > 0 ? String(p.cachedChats) : `default ${style.grey}(0)${style.reset}`,
82
+ // No inline note: the RAM estimate is the warning's job, and the warning
83
+ // lives in one place (profile-edit.ts). The panel reads it from there.
84
+ },
64
85
  {
65
86
  key: "flashAttention",
66
87
  label: "Flash attention",
@@ -97,6 +118,21 @@ export const FIELDS = [
97
118
  },
98
119
  note: (p) => p.reasoningBudget === -1 ? "unrestricted budget can consume every token on thinking" : null,
99
120
  },
121
+ {
122
+ key: "preserveReasoning",
123
+ label: "Preserve reasoning",
124
+ kind: "cycle",
125
+ // The stored tri-state, in the order PRESERVE_REASONING_CYCLE names it.
126
+ values: [null, true, false],
127
+ format: (p) => p.preserveReasoning === true
128
+ ? `${style.brightGreen}on${style.reset}`
129
+ : p.preserveReasoning === false
130
+ ? `${style.yellow}off${style.reset}`
131
+ : `${style.grey}template default${style.reset}`,
132
+ // Offered wherever the template has thinking to preserve, not only where it
133
+ // spells the setting as a kwarg. See profileFieldDescriptors for why.
134
+ enabled: (ctx) => Boolean(ctx.model?.metadata?.reasoning || ctx.model?.reasoningPreservation),
135
+ },
100
136
  {
101
137
  key: "gpuLayers",
102
138
  label: "GPU layers",
@@ -106,16 +142,6 @@ export const FIELDS = [
106
142
  max: () => 999,
107
143
  format: (p) => p.gpuLayers >= 999 ? `all ${style.grey}(999)${style.reset}` : String(p.gpuLayers),
108
144
  },
109
- {
110
- key: "parallelSlots",
111
- label: "Parallel slots",
112
- kind: "number",
113
- step: 1,
114
- min: 1,
115
- max: () => 16,
116
- format: (p) => String(p.parallelSlots),
117
- note: (p) => p.parallelSlots > 1 ? `${p.parallelSlots} concurrent requests, sharing one KV pool` : null,
118
- },
119
145
  ];
120
146
  export class App {
121
147
  constructor({ runtime, listenPort, listenHost }) {
@@ -1179,11 +1205,14 @@ export class App {
1179
1205
  return;
1180
1206
  return this.guard("calibration", async () => {
1181
1207
  await this.supervisor.stop();
1208
+ this.supervisor.recordLog(`operation calibrate: ${model.displayName}`);
1182
1209
  const measurement = await calibrate({
1183
1210
  runtime: this.runtime,
1184
1211
  model,
1185
1212
  profile,
1213
+ supervisor: this.supervisor,
1186
1214
  onProgress: (p) => {
1215
+ this.supervisor.recordLog(`operation calibrate: ${p.phase} ${p.contextSize.toLocaleString()}${p.reason ? `: ${p.reason}` : ""}${p.error ? `: ${p.error}` : ""}`);
1187
1216
  if (p.phase === "loading")
1188
1217
  this.setStatus(`calibrating: loading at ${p.contextSize.toLocaleString()} ctx…`, "info");
1189
1218
  if (p.phase === "measured")
@@ -1217,6 +1246,7 @@ export class App {
1217
1246
  this.setStatus(appliedTo !== null
1218
1247
  ? `${measuredMsg} - context ${before.toLocaleString()} -> ${appliedTo.toLocaleString()}`
1219
1248
  : measuredMsg, "good");
1249
+ this.supervisor.recordLog(`operation calibrate: saved measurement for ${model.displayName}`);
1220
1250
  });
1221
1251
  }
1222
1252
  runSweep() {
@@ -1226,11 +1256,14 @@ export class App {
1226
1256
  return;
1227
1257
  return this.guard("sweep", async () => {
1228
1258
  await this.supervisor.stop();
1259
+ this.supervisor.recordLog(`operation sweep: ${model.displayName}`);
1229
1260
  const result = await sweep({
1230
1261
  runtime: this.runtime,
1231
1262
  model,
1232
1263
  profile,
1264
+ supervisor: this.supervisor,
1233
1265
  onProgress: (p) => {
1266
+ this.supervisor.recordLog(`operation sweep: budget ${p.budget} ${p.phase}${p.error ? `: ${p.error}` : ""}`);
1234
1267
  if (p.phase === "loading")
1235
1268
  this.setStatus(`sweep: loading with budget ${p.budget}…`, "info");
1236
1269
  if (p.phase === "generating")
@@ -1245,6 +1278,7 @@ export class App {
1245
1278
  profile.reasoningBudget = result.recommended;
1246
1279
  this.persist();
1247
1280
  this.setStatus(`sweep complete - reasoning budget set to ${result.recommended}`, "good");
1281
+ this.supervisor.recordLog(`operation sweep: saved budget ${result.recommended}`);
1248
1282
  }
1249
1283
  else {
1250
1284
  this.setStatus("sweep produced no usable result", "warn");
@@ -1626,14 +1660,14 @@ export class App {
1626
1660
  const header = this.header(cols);
1627
1661
  const status = this.statusPanel(cols - 2);
1628
1662
  const footer = this.keybindings(cols - 1);
1629
- const title = truncate(`${style.brightCyan}llama-server log${style.reset} ` +
1663
+ const title = truncate(`${style.brightCyan}Brain log${style.reset} ` +
1630
1664
  `${style.grey}${s.state}${logs.length ? ` · ${logs.length} lines` : ""} · esc or l to go back${style.reset}`, cols - 1);
1631
1665
  // header + blank + title + blank + [body] + blank + status + blank + footer.
1632
1666
  const overhead = 1 + 1 + 1 + 1 + 1 + status.length + 1 + footer.length;
1633
1667
  const bodyRows = Math.max(1, this.screen.rows - 1 - overhead);
1634
1668
  const body = [];
1635
1669
  if (logs.length === 0) {
1636
- body.push(`${style.grey}no logs yet - start a model with s to see llama-server output${style.reset}`);
1670
+ body.push(`${style.grey}no logs yet - start a model or operation to see Brain output${style.reset}`);
1637
1671
  }
1638
1672
  else {
1639
1673
  for (const line of logs.slice(Math.max(0, logs.length - bodyRows)))
@@ -1739,9 +1773,23 @@ export class App {
1739
1773
  if (focused)
1740
1774
  focusLine = content.length;
1741
1775
  content.push(`${arrow} ${pad(field.label, 17)} ${value}`);
1742
- const note = field.note?.(profile);
1743
- if (note)
1744
- content.push(` ${style.yellow} ${note}${style.reset}`);
1776
+ // The cachedChats estimate is a ProfileWarning, not a field note: it is
1777
+ // computed once in profile-edit.ts, colored by how much of the machine's
1778
+ // RAM it would take, and the app editor renders the very same object.
1779
+ const cachedWarning = field.key === "cachedChats"
1780
+ ? profileWarnings(profile, model, this.store).find((w) => w.field === "cachedChats")
1781
+ : undefined;
1782
+ const note = cachedWarning?.message ?? field.note?.(profile);
1783
+ if (note) {
1784
+ const tone = cachedWarning?.severity === "error"
1785
+ ? style.red
1786
+ : cachedWarning?.severity === "warn"
1787
+ ? style.yellow
1788
+ : field.key === "cachedChats"
1789
+ ? style.grey
1790
+ : style.yellow;
1791
+ content.push(` ${tone} ${note}${style.reset}`);
1792
+ }
1745
1793
  }
1746
1794
  const sweepResult = this.sweepResult;
1747
1795
  if (sweepResult) {
@@ -1923,7 +1971,7 @@ export class App {
1923
1971
  item("u", "reset the selected model's name to its default (asks to confirm)");
1924
1972
  section("Views");
1925
1973
  item("b", "benchmark mode - rank models, run the coding suite");
1926
- item("l", "view the live llama-server log");
1974
+ item("l", "view the live Brain log");
1927
1975
  item("/", "filter the model list (Enter apply, Esc clear)");
1928
1976
  item("r", "rescan the models folder");
1929
1977
  item("?", "this help");
package/dist/types.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface ModelMetadata {
21
21
  * flags a reasoning model - works for any local model, catalog or not.
22
22
  */
23
23
  reasoning?: boolean;
24
+ /** Native template key detected from `preserve_thinking` or `preserve_reasoning`. */
25
+ reasoningPreservationArgument?: string;
24
26
  [key: string]: unknown;
25
27
  }
26
28
  export interface ModelFeatures {
@@ -28,6 +30,16 @@ export interface ModelFeatures {
28
30
  imatrix: boolean;
29
31
  distilled: boolean;
30
32
  }
33
+ /** Catalog-declared native chat-template argument names for reasoning controls. */
34
+ export interface ReasoningTemplate {
35
+ enableThinkingArgument: string;
36
+ effortArgument: string;
37
+ }
38
+ /** The native argument behind Brain's provider-neutral Preserve reasoning setting. */
39
+ export interface ReasoningPreservation {
40
+ templateArgument: string;
41
+ default?: boolean;
42
+ }
31
43
  export type ModelComponentRole = "vision_projector" | "speculative_drafter";
32
44
  /** A catalog-declared companion artifact resolved against local disk. */
33
45
  export interface ModelComponent {
@@ -69,6 +81,12 @@ export interface Model {
69
81
  thinking?: boolean;
70
82
  /** Per-request reasoning levels accepted by the model's OpenAI-compatible API. */
71
83
  reasoningEfforts?: string[];
84
+ /** The model-native default among `reasoningEfforts`, when one is declared. */
85
+ reasoningEffortDefault?: string;
86
+ /** Template argument names needed to apply this model's reasoning controls. */
87
+ reasoningTemplate?: ReasoningTemplate;
88
+ /** Native template argument used by the model-profile Preserve reasoning setting. */
89
+ reasoningPreservation?: ReasoningPreservation;
72
90
  /** The catalog's advertised max context, if known. */
73
91
  contextMax?: number;
74
92
  /** Back-reference: the id of the reconciled catalog entry, if matched. */
package/dist/vram.d.ts CHANGED
@@ -18,6 +18,43 @@ export declare function listCacheTypes(): string[];
18
18
  /** Worst-case KV bytes per token, assuming every layer holds a full cache. */
19
19
  export declare function theoreticalKvBytesPerToken(metadata: ModelMetadata | null, cacheTypeK: string, cacheTypeV: string): number | null;
20
20
  export type BudgetSource = "measured" | "theoretical" | "unknown";
21
+ /**
22
+ * KV bytes per token for a profile: the measurement when one exists, otherwise
23
+ * the theoretical bound.
24
+ *
25
+ * The two are not interchangeable. The theoretical formula overestimates badly
26
+ * on architectures that only keep a full cache on a subset of layers - measured
27
+ * at 3.5x for Qwen3.8-27B and 20x for Gemma-4-31B on this repo's own stored
28
+ * calibrations - which is the whole reason `calibrate` exists. Callers that
29
+ * spend real memory on this number must therefore branch on `source` rather
30
+ * than treating the theoretical figure as an answer.
31
+ */
32
+ export declare function kvBytesPerToken(model: Model, profile: Profile, calibration?: Calibration | null): {
33
+ value: number;
34
+ source: BudgetSource;
35
+ theoretical: number | null;
36
+ };
37
+ export interface PromptCacheSize {
38
+ /** One chat at its full per-slot window - the worst case for a single entry. */
39
+ perChatBytes: number;
40
+ /** What `--cache-ram` is set to, in bytes. */
41
+ totalBytes: number;
42
+ source: BudgetSource;
43
+ }
44
+ /**
45
+ * Turn `cachedChats` into a `--cache-ram` byte budget.
46
+ *
47
+ * llama-server parks the KV state of a chat that loses its slot in host RAM and
48
+ * copies it back when that chat returns, instead of re-prefilling its whole
49
+ * conversation. An entry is at most one slot's worth of KV, and a slot is
50
+ * `contextSize / parallelSlots` tokens, so the count the user picks is the
51
+ * count of chats guaranteed to survive. Real conversations rarely fill their
52
+ * window, so in practice more of them fit: the number is a floor, not a cap.
53
+ *
54
+ * Returns null when the size cannot be computed - `cachedChats` is 0 (leave
55
+ * llama.cpp's default alone) or nothing is known about this model's KV cost.
56
+ */
57
+ export declare function promptCacheSize(model: Model, profile: Profile, calibration?: Calibration | null): PromptCacheSize | null;
21
58
  export interface Budget {
22
59
  weightsBytes: number;
23
60
  mmprojBytes: number;
package/dist/vram.js CHANGED
@@ -32,6 +32,52 @@ export function theoreticalKvBytesPerToken(metadata, cacheTypeK, cacheTypeV) {
32
32
  (keyLength * cacheTypeBytes(cacheTypeK) +
33
33
  valueLength * cacheTypeBytes(cacheTypeV)));
34
34
  }
35
+ /**
36
+ * KV bytes per token for a profile: the measurement when one exists, otherwise
37
+ * the theoretical bound.
38
+ *
39
+ * The two are not interchangeable. The theoretical formula overestimates badly
40
+ * on architectures that only keep a full cache on a subset of layers - measured
41
+ * at 3.5x for Qwen3.8-27B and 20x for Gemma-4-31B on this repo's own stored
42
+ * calibrations - which is the whole reason `calibrate` exists. Callers that
43
+ * spend real memory on this number must therefore branch on `source` rather
44
+ * than treating the theoretical figure as an answer.
45
+ */
46
+ export function kvBytesPerToken(model, profile, calibration) {
47
+ const theoretical = theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
48
+ if (calibration && calibration.kvBytesPerToken > 0) {
49
+ return { value: calibration.kvBytesPerToken, source: "measured", theoretical };
50
+ }
51
+ if (theoretical !== null)
52
+ return { value: theoretical, source: "theoretical", theoretical };
53
+ return { value: 0, source: "unknown", theoretical };
54
+ }
55
+ /**
56
+ * Turn `cachedChats` into a `--cache-ram` byte budget.
57
+ *
58
+ * llama-server parks the KV state of a chat that loses its slot in host RAM and
59
+ * copies it back when that chat returns, instead of re-prefilling its whole
60
+ * conversation. An entry is at most one slot's worth of KV, and a slot is
61
+ * `contextSize / parallelSlots` tokens, so the count the user picks is the
62
+ * count of chats guaranteed to survive. Real conversations rarely fill their
63
+ * window, so in practice more of them fit: the number is a floor, not a cap.
64
+ *
65
+ * Returns null when the size cannot be computed - `cachedChats` is 0 (leave
66
+ * llama.cpp's default alone) or nothing is known about this model's KV cost.
67
+ */
68
+ export function promptCacheSize(model, profile, calibration) {
69
+ const chats = Math.floor(profile.cachedChats ?? 0);
70
+ if (chats < 1)
71
+ return null;
72
+ const { value, source } = kvBytesPerToken(model, profile, calibration);
73
+ if (value <= 0 || profile.contextSize <= 0)
74
+ return null;
75
+ const perSlotTokens = Math.floor(profile.contextSize / Math.max(1, profile.parallelSlots || 1));
76
+ if (perSlotTokens <= 0)
77
+ return null;
78
+ const perChatBytes = value * perSlotTokens;
79
+ return { perChatBytes, totalBytes: perChatBytes * chats, source };
80
+ }
35
81
  /** Compute a VRAM budget for a profile. */
36
82
  export function budget({ model, profile, calibration = null, totalVramBytes, reserveBytes = 1.5 * GIB, }) {
37
83
  const weights = model.sizeBytes;
@@ -46,22 +92,8 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
46
92
  : 0;
47
93
  const mmproj = projector.reduce((total, component) => total + component.bytes, 0) ||
48
94
  (components.length ? 0 : componentBytes);
49
- const theoretical = theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
50
- let kvBytesPerToken;
51
- let source;
52
- if (calibration && calibration.kvBytesPerToken > 0) {
53
- kvBytesPerToken = calibration.kvBytesPerToken;
54
- source = "measured";
55
- }
56
- else if (theoretical !== null) {
57
- kvBytesPerToken = theoretical;
58
- source = "theoretical";
59
- }
60
- else {
61
- kvBytesPerToken = 0;
62
- source = "unknown";
63
- }
64
- const kv = kvBytesPerToken * profile.contextSize;
95
+ const { value: perToken, source, theoretical } = kvBytesPerToken(model, profile, calibration);
96
+ const kv = perToken * profile.contextSize;
65
97
  // The speculative decoder keeps its own KV cache. Until component-specific
66
98
  // calibration exists, reserve the same conservative per-token cost.
67
99
  const drafterKv = drafters.length * kv;
@@ -84,7 +116,7 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
84
116
  usableBytes: usable,
85
117
  totalVramBytes,
86
118
  reserveBytes,
87
- kvBytesPerToken,
119
+ kvBytesPerToken: perToken,
88
120
  source,
89
121
  theoreticalKvBytesPerToken: theoretical,
90
122
  fits: total <= usable,
@@ -107,7 +139,14 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
107
139
  const room = probe.usableBytes - fixed;
108
140
  if (room <= 0)
109
141
  return null;
110
- const tokens = Math.floor(room / probe.kvBytesPerToken);
142
+ // More than one KV pool can scale with the context - a speculative drafter
143
+ // keeps its own. Derive the per-token cost from the probe rather than from
144
+ // `kvBytesPerToken` alone, so this can never hand back a context that
145
+ // `budget()` then declares does not fit.
146
+ const perToken = (probe.kvBytes + probe.drafterKvBytes) / step;
147
+ if (perToken <= 0)
148
+ return null;
149
+ const tokens = Math.floor(room / perToken);
111
150
  const native = model.metadata?.contextLength;
112
151
  const contextLimit = typeof native === "number" && native > 0 ? native * profile.contextMultiplier : tokens;
113
152
  const capped = Math.min(tokens, contextLimit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.9",
3
+ "version": "0.8.12",
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": {