@otto-code/brain 0.8.10 → 0.8.13

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 (65) hide show
  1. package/dist/commands/bench.js +2 -2
  2. package/dist/commands/calibrate.js +11 -2
  3. package/dist/commands/catalog.d.ts +1 -0
  4. package/dist/commands/catalog.js +1 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +12 -3
  7. package/dist/commands/search.d.ts +1 -0
  8. package/dist/commands/search.js +12 -2
  9. package/dist/config/index.d.ts +2 -2
  10. package/dist/config/index.js +2 -2
  11. package/dist/config/profile-edit.d.ts +88 -1
  12. package/dist/config/profile-edit.js +294 -43
  13. package/dist/config/profiles.d.ts +19 -3
  14. package/dist/config/profiles.js +52 -4
  15. package/dist/config/schema.d.ts +616 -0
  16. package/dist/config/schema.js +65 -3
  17. package/dist/config/store.js +7 -4
  18. package/dist/gguf.d.ts +7 -0
  19. package/dist/gguf.js +15 -2
  20. package/dist/models/download.d.ts +1 -1
  21. package/dist/models/download.js +2 -2
  22. package/dist/models/enrich.d.ts +6 -0
  23. package/dist/models/enrich.js +27 -1
  24. package/dist/models/index.d.ts +1 -1
  25. package/dist/models/index.js +4 -3
  26. package/dist/ops/archive.d.ts +14 -1
  27. package/dist/ops/archive.js +9 -5
  28. package/dist/ops/calibrate.d.ts +38 -3
  29. package/dist/ops/calibrate.js +68 -19
  30. package/dist/ops/report.js +51 -1
  31. package/dist/ops/results.d.ts +77 -11
  32. package/dist/ops/results.js +84 -14
  33. package/dist/ops/sweep.d.ts +38 -1
  34. package/dist/ops/sweep.js +61 -10
  35. package/dist/runtime/args.d.ts +15 -2
  36. package/dist/runtime/args.js +60 -5
  37. package/dist/runtime/managed.js +2 -2
  38. package/dist/service/activity.d.ts +19 -0
  39. package/dist/service/activity.js +47 -4
  40. package/dist/service/host-api.d.ts +28 -4
  41. package/dist/service/host-api.js +109 -28
  42. package/dist/service/log-format.d.ts +18 -0
  43. package/dist/service/log-format.js +32 -0
  44. package/dist/service/process-pool.d.ts +45 -0
  45. package/dist/service/process-pool.js +271 -0
  46. package/dist/service/router.d.ts +74 -3
  47. package/dist/service/router.js +277 -51
  48. package/dist/service/run-log.d.ts +6 -1
  49. package/dist/service/run-log.js +46 -4
  50. package/dist/service/scheduler.d.ts +250 -31
  51. package/dist/service/scheduler.js +408 -63
  52. package/dist/service/serve.d.ts +4 -0
  53. package/dist/service/serve.js +376 -142
  54. package/dist/service/status-events.d.ts +14 -1
  55. package/dist/service/status-events.js +112 -12
  56. package/dist/service/supervisor.d.ts +9 -7
  57. package/dist/service/supervisor.js +37 -12
  58. package/dist/sysmon.d.ts +15 -0
  59. package/dist/sysmon.js +56 -9
  60. package/dist/tui/app.d.ts +8 -2
  61. package/dist/tui/app.js +83 -26
  62. package/dist/types.d.ts +18 -0
  63. package/dist/vram.d.ts +37 -0
  64. package/dist/vram.js +57 -18
  65. package/package.json +1 -1
@@ -38,8 +38,8 @@
38
38
  * also needs `libcublas`, `libcudart` and `libnccl.so.2` - and NCCL ships in
39
39
  * neither NVIDIA redistributable. Do not reopen this without a measurement on
40
40
  * hardware that does *not* report NV_coopmat2, which is the one case where
41
- * the gap could still be real. Full evidence:
42
- * findings/linux-gpu-acceleration/2026-08-04-cuda-vs-vulkan-and-cuda-asset-origins.md
41
+ * the gap could still be real. Measured 2026-08-04; the full method and
42
+ * numbers are the "cuda vs vulkan" finding in Otto Knowledge.
43
43
  * - **No Linux asset ships `libgomp.so.1`**, which `llama-server` hard-links,
44
44
  * so a host without `libgomp1` installs a runtime that then exits 127 on
45
45
  * spawn. Windows bundles its OpenMP runtime (`libomp140.x86_64.dll`); Linux
@@ -64,6 +64,14 @@ export interface InferenceActivitySnapshot {
64
64
  processing: number;
65
65
  thinking: number;
66
66
  generating: number;
67
+ /**
68
+ * Per-slot stage join, present only when at least one tracked request was
69
+ * pinned to a llama-server slot by the router (host API v3). Keys are the
70
+ * engine's slot ids as reported by `/slots`; values are that request's
71
+ * proxy-side stage. Absent (not just empty) on brains that predate the join,
72
+ * which is how an old client tells "no join data" from "no pinned requests".
73
+ */
74
+ slotStages?: Record<string, InferenceStage>;
67
75
  }
68
76
  /**
69
77
  * Which in-flight completions are currently mid-thought.
@@ -91,6 +99,17 @@ export declare class ReasoningTracker {
91
99
  onChange(listener: () => void): () => void;
92
100
  /** A completion was dispatched to llama-server and awaits its first output delta. */
93
101
  begin(requestId: string): void;
102
+ /**
103
+ * Record the engine slot this request was pinned to. Called exactly once per
104
+ * request, at dispatch - the pin is injected into the outbound body before
105
+ * the request goes out, so the association exists before the first chunk and
106
+ * `observe` never has to learn about it.
107
+ *
108
+ * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
109
+ * different slot replaces it, so a request that somehow moves slots (a
110
+ * restarted engine hands a task out again) reports where it is now.
111
+ */
112
+ setSlot(requestId: string, slotId: number): void;
94
113
  /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
95
114
  observe(requestId: string, text: string): void;
96
115
  /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
@@ -9,7 +9,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
10
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
11
  };
12
- var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
12
+ var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_slots, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
13
13
  /**
14
14
  * What long-running work currently owns the brain, and which stage each live
15
15
  * inference request has reached.
@@ -197,6 +197,12 @@ export class ReasoningTracker {
197
197
  constructor() {
198
198
  _ReasoningTracker_instances.add(this);
199
199
  _ReasoningTracker_requests.set(this, new Map());
200
+ /**
201
+ * The llama-server slot a request was pinned to at dispatch, so its proxy-side
202
+ * stage can be attributed to the engine row the panel actually shows. Set once
203
+ * per request (see `setSlot`), never on the per-chunk path.
204
+ */
205
+ _ReasoningTracker_slots.set(this, new Map());
200
206
  /** Tail of the last transport chunk, so a field name split by TCP is still detected. */
201
207
  _ReasoningTracker_tails.set(this, new Map());
202
208
  /** Models/runtimes that leave reasoning inline as `<think>…</think>`. */
@@ -222,6 +228,24 @@ export class ReasoningTracker {
222
228
  __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
223
229
  __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
224
230
  }
231
+ /**
232
+ * Record the engine slot this request was pinned to. Called exactly once per
233
+ * request, at dispatch - the pin is injected into the outbound body before
234
+ * the request goes out, so the association exists before the first chunk and
235
+ * `observe` never has to learn about it.
236
+ *
237
+ * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
238
+ * different slot replaces it, so a request that somehow moves slots (a
239
+ * restarted engine hands a task out again) reports where it is now.
240
+ */
241
+ setSlot(requestId, slotId) {
242
+ if (!Number.isInteger(slotId) || slotId < 0)
243
+ return;
244
+ if (__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId) === slotId)
245
+ return;
246
+ __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").set(requestId, slotId);
247
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
248
+ }
225
249
  /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
226
250
  observe(requestId, text) {
227
251
  const current = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(requestId);
@@ -261,6 +285,7 @@ export class ReasoningTracker {
261
285
  /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
262
286
  end(requestId) {
263
287
  __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(requestId);
288
+ __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").delete(requestId);
264
289
  __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").delete(requestId);
265
290
  __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
266
291
  __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
@@ -279,14 +304,32 @@ export class ReasoningTracker {
279
304
  thinking: 0,
280
305
  generating: 0,
281
306
  };
282
- for (const stage of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values())
307
+ let slotStages;
308
+ for (const [requestId, stage] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
283
309
  result[stage] += 1;
310
+ const slot = __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId);
311
+ if (slot === undefined)
312
+ continue;
313
+ (slotStages ?? (slotStages = {}))[String(slot)] = stage;
314
+ }
315
+ if (slotStages)
316
+ result.slotStages = slotStages;
284
317
  return result;
285
318
  }
286
319
  }
287
- _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
320
+ _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_slots = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
288
321
  const snapshot = this.snapshot;
289
- const key = `${snapshot.activeRequests}:${snapshot.processing}:${snapshot.thinking}:${snapshot.generating}`;
322
+ // The slot join rides in the key too: pinning a request to a slot is a
323
+ // state change even when no stage count moves, and it is the field the
324
+ // Overview rows read. The map is bounded by concurrency, so the digest is
325
+ // cheap enough to build on every announce.
326
+ const slotKey = snapshot.slotStages
327
+ ? Object.entries(snapshot.slotStages)
328
+ .map(([slot, stage]) => `${slot}:${stage}`)
329
+ .sort()
330
+ .join(",")
331
+ : "";
332
+ const key = `${snapshot.activeRequests}:${snapshot.processing}:${snapshot.thinking}:${snapshot.generating}:${slotKey}`;
290
333
  if (key === __classPrivateFieldGet(this, _ReasoningTracker_lastSnapshot, "f"))
291
334
  return;
292
335
  __classPrivateFieldSet(this, _ReasoningTracker_lastSnapshot, key, "f");
@@ -26,8 +26,11 @@ import type { RankedModel } from "../ops/results.js";
26
26
  import type { GpuInfo, Model } from "../types.js";
27
27
  import * as vram from "../vram.js";
28
28
  import type { SystemSample } from "../sysmon.js";
29
- import type { BrainStatusPublisher } from "./status-events.js";
29
+ import type { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
30
30
  import type { Supervisor } from "./supervisor.js";
31
+ import type { ModelScheduler } from "./scheduler.js";
32
+ import type { BrainRunLog } from "./run-log.js";
33
+ import type { BrainLogArea } from "./log-format.js";
31
34
  /**
32
35
  * The management API's own version, additive to the capability flags.
33
36
  *
@@ -72,12 +75,16 @@ export interface HostCapabilities {
72
75
  events: boolean;
73
76
  /** Bounded live inference stages, token counts and throughput on status events. */
74
77
  liveInference: boolean;
78
+ /** Every completed Brain log line arrives immediately on the SSE stream. */
79
+ logEvents: boolean;
75
80
  /** Whether writes are currently permitted (allowRemoteConfig). */
76
81
  writable: boolean;
77
82
  /** POST/GET /__host/jobs and POST /__host/jobs/cancel. */
78
83
  jobs: boolean;
79
84
  /** POST /__host/restart delegates a restart to the service owner. */
80
85
  restart: boolean;
86
+ /** Multiple independently supervised model processes are supported. */
87
+ processPool: boolean;
81
88
  }
82
89
  /** A long-running operation owned by this brain host, not its caller. */
83
90
  export interface HostJob {
@@ -86,6 +93,8 @@ export interface HostJob {
86
93
  label: string;
87
94
  target: string | null;
88
95
  status: "running" | "succeeded" | "failed" | "canceled";
96
+ /** Positive while the shared scheduler has not admitted this operation yet. */
97
+ queuePosition?: number | null;
89
98
  percent: number | null;
90
99
  message: string | null;
91
100
  error: string | null;
@@ -93,10 +102,15 @@ export interface HostJob {
93
102
  finishedAt: string | null;
94
103
  }
95
104
  export interface HostJobRunner {
96
- start: (kind: HostJob["kind"], target: string | null, args: string[]) => HostJob;
105
+ start: (kind: HostJob["kind"], target: string | null, args: string[],
106
+ /** A bundle entry owns its companion-artifact queue, not the whole host. */
107
+ pull?: {
108
+ entryKey: string;
109
+ components: string[];
110
+ }) => HostJob;
97
111
  list: () => HostJob[];
98
112
  cancel: (jobId: string) => Promise<HostJob[]>;
99
- query: (args: string[]) => Promise<unknown>;
113
+ query: (args: string[], area?: BrainLogArea) => Promise<unknown>;
100
114
  }
101
115
  export interface HostApiDeps {
102
116
  supervisor: Supervisor;
@@ -110,6 +124,9 @@ export interface HostApiDeps {
110
124
  queryGpuInfo: () => Promise<GpuInfo | null>;
111
125
  getRanking: () => RankedModel[];
112
126
  loadModel: (model: Model) => Promise<void>;
127
+ unloadModels?: () => Promise<void>;
128
+ /** The process-pool scheduler shared by completions and resident operations. */
129
+ scheduler?: ModelScheduler<Supervisor> | null;
113
130
  /** Mirrors POST /__host/config's gate: may a network caller change things? */
114
131
  getAllowWrite: () => boolean;
115
132
  /** The managed models directory, for disk accounting. Null when unresolvable. */
@@ -121,10 +138,16 @@ export interface HostApiDeps {
121
138
  * its daemon keeps polling status.
122
139
  */
123
140
  statusEvents?: BrainStatusPublisher | null;
141
+ /** The append-only line stream behind `GET /__host/events`. */
142
+ logEvents?: BrainLogPublisher | null;
124
143
  /** Long operations that must execute on this brain's machine. */
125
144
  jobs?: HostJobRunner;
145
+ /** The append-only log owned by this Brain service run. */
146
+ runLog?: BrainRunLog;
126
147
  /** Gracefully restart the serving process after its HTTP acknowledgement. */
127
148
  restart?: () => void;
149
+ /** Durable service-session operation log. */
150
+ log?: (area: BrainLogArea, message: string) => void;
128
151
  }
129
152
  /** One row of the model inventory: the scan, metadata, profile and score joined. */
130
153
  export interface InventoryRow {
@@ -153,7 +176,7 @@ export interface InventoryRow {
153
176
  budget: vram.Budget | null;
154
177
  maxContextThatFits: number | null;
155
178
  score: RankedModel | null;
156
- state: "loaded" | "loading" | "not-loaded";
179
+ state: "loaded" | "loading" | "unloading" | "active" | "queued" | "not-loaded";
157
180
  warnings: ReturnType<typeof profileWarnings>;
158
181
  components: NonNullable<Model["components"]> | null;
159
182
  }
@@ -184,6 +207,7 @@ export declare function buildInventoryRow(params: {
184
207
  gpu: GpuInfo | null;
185
208
  ranking: RankedModel[];
186
209
  supervisor: Supervisor;
210
+ scheduler?: ModelScheduler<Supervisor> | null;
187
211
  runtimeBuild?: number | null;
188
212
  }): InventoryRow;
189
213
  export interface HostApi {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
3
3
  import { familyHostingProfileId, hostingFamily, removeHostingProfileMaterialization, } from "../config/hosting-profiles.js";
4
- import { forModel, getCalibration, put } from "../config/profiles.js";
4
+ import { forModel, getCalibrationForBudget, put } from "../config/profiles.js";
5
5
  import { HostingProfileSchema, } from "../config/schema.js";
6
6
  import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
7
7
  import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
@@ -170,13 +170,23 @@ function hostingProfilesFor(store, model) {
170
170
  const family = hostingFamily(model.family);
171
171
  return Object.values(store.hostingProfiles).filter((candidate) => candidate.family === family);
172
172
  }
173
- function stateOf(supervisor, model) {
174
- if (!supervisor.model || supervisor.model.id !== model.id)
175
- return "not-loaded";
176
- if (supervisor.state === "ready")
173
+ function stateOf(supervisor, scheduler, model) {
174
+ const residentSupervisor = (typeof scheduler?.supervisorFor === "function" ? scheduler.supervisorFor(model.id) : null) ??
175
+ (supervisor.model?.id === model.id ? supervisor : null);
176
+ const resident = residentSupervisor !== null;
177
+ if (resident) {
178
+ if (residentSupervisor.state === "starting")
179
+ return "loading";
180
+ if (residentSupervisor.state === "stopping")
181
+ return "unloading";
182
+ }
183
+ const stats = scheduler?.stats();
184
+ if (stats?.active?.modelId === model.id)
185
+ return "active";
186
+ if ((stats?.waitingModelIds[model.id] ?? 0) > 0)
187
+ return "queued";
188
+ if (resident && residentSupervisor.state === "ready")
177
189
  return "loaded";
178
- if (supervisor.state === "starting")
179
- return "loading";
180
190
  return "not-loaded";
181
191
  }
182
192
  /**
@@ -187,9 +197,9 @@ function stateOf(supervisor, model) {
187
197
  * client would otherwise have to correlate three unrelated lists by display name.
188
198
  */
189
199
  export function buildInventoryRow(params) {
190
- const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
200
+ const { model, store, defaults, gpu, ranking, supervisor, scheduler = null, runtimeBuild: activeRuntimeBuild = null, } = params;
191
201
  const profile = forModel(store, model, defaults);
192
- const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
202
+ const calibration = getCalibrationForBudget(store, model, profile);
193
203
  const budgetOptions = gpu
194
204
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
195
205
  : null;
@@ -222,7 +232,7 @@ export function buildInventoryRow(params) {
222
232
  budget: budgetOptions ? vram.budget(budgetOptions) : null,
223
233
  maxContextThatFits: budgetOptions ? vram.maxContextThatFits(budgetOptions) : null,
224
234
  score: ranked,
225
- state: stateOf(supervisor, model),
235
+ state: stateOf(supervisor, scheduler, model),
226
236
  warnings: profileWarnings(profile, model, store),
227
237
  components: model.components?.map((component) => {
228
238
  if (component.minRuntimeBuild === undefined ||
@@ -267,7 +277,23 @@ function resolveModel(catalog, needle) {
267
277
  */
268
278
  function profileFromQuery(base, params, model) {
269
279
  const patch = {};
270
- const numeric = ["contextSize", "gpuLayers", "parallelSlots", "reasoningBudget"];
280
+ // The samplers cost no VRAM and so change nothing in the budget this powers,
281
+ // but they ride in the same draft the editor sends. Parse them anyway: an
282
+ // unparsed key reaches sanitizeProfilePatch as the string "0.8" and throws,
283
+ // which would fail the whole preview over a field it does not even price.
284
+ const numeric = [
285
+ "contextSize",
286
+ "gpuLayers",
287
+ "parallelSlots",
288
+ "cachedChats",
289
+ "reasoningBudget",
290
+ "temperature",
291
+ "topP",
292
+ "topK",
293
+ "minP",
294
+ "presencePenalty",
295
+ "repeatPenalty",
296
+ ];
271
297
  for (const key of numeric) {
272
298
  const raw = params.get(key);
273
299
  if (raw !== null && raw !== "")
@@ -283,6 +309,16 @@ function profileFromQuery(base, params, model) {
283
309
  if (raw !== null && raw !== "")
284
310
  patch[key] = raw === "true" || raw === "1";
285
311
  }
312
+ // Tri-state, and every spelling a client might use for it. Unknown text is
313
+ // dropped rather than thrown on: this field prices nothing, so a value this
314
+ // route cannot read must not take the whole budget preview down with it.
315
+ const preserve = params.get("preserveReasoning");
316
+ if (preserve === "true" || preserve === "on")
317
+ patch.preserveReasoning = true;
318
+ else if (preserve === "false" || preserve === "off")
319
+ patch.preserveReasoning = false;
320
+ else if (preserve === "default" || preserve === "null")
321
+ patch.preserveReasoning = null;
286
322
  if (Object.keys(patch).length === 0)
287
323
  return base;
288
324
  return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
@@ -306,9 +342,11 @@ export function createHostApi(deps) {
306
342
  // would make a daemon stop polling and see nothing.
307
343
  events: Boolean(deps.statusEvents?.ready),
308
344
  liveInference: Boolean(deps.statusEvents?.ready),
345
+ logEvents: Boolean(deps.statusEvents?.ready && deps.logEvents),
309
346
  writable: deps.getAllowWrite(),
310
347
  jobs: Boolean(deps.jobs),
311
348
  restart: Boolean(deps.restart),
349
+ processPool: true,
312
350
  });
313
351
  /** Refuse a write unless the owner opted into remote configuration. */
314
352
  const guardWrite = (res) => {
@@ -328,6 +366,7 @@ export function createHostApi(deps) {
328
366
  gpu,
329
367
  ranking,
330
368
  supervisor: deps.supervisor,
369
+ scheduler: deps.scheduler,
331
370
  runtimeBuild: runtimeBuild(deps.supervisor.runtime),
332
371
  }));
333
372
  };
@@ -377,16 +416,17 @@ export function createHostApi(deps) {
377
416
  // A setting is only unapplied when it was changed on the currently
378
417
  // resident model. Edits to an unloaded model take effect naturally
379
418
  // when it is next loaded and do not earn a misleading reload badge.
380
- const requiresRestart = deps.supervisor.model?.id === model.id;
419
+ const requiresRestart = Boolean((typeof deps.scheduler?.supervisorFor === "function"
420
+ ? deps.scheduler.supervisorFor(model.id)
421
+ : null) ?? (deps.supervisor.model?.id === model.id ? deps.supervisor : null));
381
422
  if (requiresRestart)
382
423
  store.pendingReloadModelIds[model.id] = true;
383
424
  deps.saveProfiles(store);
425
+ deps.log?.("model", `updated profile for ${model.displayName}${requiresRestart ? "; reload required" : ""}`);
384
426
  // Return the recomputed budget so an edit costs one round trip rather
385
427
  // than a write followed by a read the UI has to sequence.
386
428
  const gpu = await deps.queryGpuInfo();
387
- const calibration = profile.calibrationRequired
388
- ? null
389
- : getCalibration(store, model, profile);
429
+ const calibration = getCalibrationForBudget(store, model, profile);
390
430
  const options = gpu
391
431
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
392
432
  : null;
@@ -448,6 +488,7 @@ export function createHostApi(deps) {
448
488
  // the brain is restarted. Reset already follows this pattern below.
449
489
  const catalog = deps.rescan();
450
490
  const updated = resolveModel(catalog, model.id);
491
+ deps.log?.("library", `renamed ${model.displayName} to ${displayName}`);
451
492
  sendJson(res, { displayName: updated ? updated.displayName : displayName });
452
493
  });
453
494
  };
@@ -460,6 +501,7 @@ export function createHostApi(deps) {
460
501
  deleteDisplayName(model.id);
461
502
  const catalog = deps.rescan();
462
503
  const updated = resolveModel(catalog, model.id);
504
+ deps.log?.("library", `reset display name for ${model.displayName}`);
463
505
  sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
464
506
  });
465
507
  };
@@ -483,7 +525,7 @@ export function createHostApi(deps) {
483
525
  const options = {
484
526
  model,
485
527
  profile,
486
- calibration: profile.calibrationRequired ? null : getCalibration(store, model, profile),
528
+ calibration: getCalibrationForBudget(store, model, profile),
487
529
  totalVramBytes: gpu.totalBytes,
488
530
  };
489
531
  sendJson(res, {
@@ -511,15 +553,21 @@ export function createHostApi(deps) {
511
553
  return;
512
554
  }
513
555
  try {
556
+ deps.log?.("model", `loading ${model.displayName}`);
514
557
  await deps.loadModel(model);
558
+ deps.log?.("model", `loaded ${model.displayName}`);
559
+ const resident = (typeof deps.scheduler?.supervisorFor === "function"
560
+ ? deps.scheduler.supervisorFor(model.id)
561
+ : null) ?? deps.supervisor;
515
562
  sendJson(res, {
516
- status: deps.supervisor.status(),
563
+ status: resident.status(),
517
564
  // What actually got used: loadModel fits the profile to VRAM, so the
518
565
  // context here may be lower than the one saved.
519
- profile: deps.supervisor.profile,
566
+ profile: resident.profile,
520
567
  });
521
568
  }
522
569
  catch (error) {
570
+ deps.log?.("model", `failed to load ${model.displayName}: ${errorMessage(error)}`);
523
571
  sendError(res, 409, `could not load ${model.displayName}: ${errorMessage(error)}`);
524
572
  }
525
573
  })();
@@ -527,7 +575,12 @@ export function createHostApi(deps) {
527
575
  const handleUnload = (res) => {
528
576
  void (async () => {
529
577
  try {
530
- await deps.supervisor.stop();
578
+ deps.log?.("model", "unloading resident model");
579
+ if (deps.unloadModels)
580
+ await deps.unloadModels();
581
+ else
582
+ await deps.supervisor.stop();
583
+ deps.log?.("model", "resident model unloaded");
531
584
  sendJson(res, { status: deps.supervisor.status() });
532
585
  }
533
586
  catch (error) {
@@ -536,13 +589,17 @@ export function createHostApi(deps) {
536
589
  })();
537
590
  };
538
591
  const handleDelete = (res, model) => {
539
- if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
592
+ const resident = typeof deps.scheduler?.supervisorFor === "function"
593
+ ? deps.scheduler.supervisorFor(model.id)
594
+ : null;
595
+ if (resident && resident.state !== "stopped") {
540
596
  sendError(res, 409, "stop the model before deleting it");
541
597
  return;
542
598
  }
543
599
  try {
544
600
  const plan = deleteModelFiles(model);
545
601
  const catalog = deps.rescan();
602
+ deps.log?.("library", `deleted ${model.displayName}; freed ${plan.bytes} bytes`);
546
603
  sendJson(res, {
547
604
  deleted: plan.files,
548
605
  freedBytes: plan.bytes,
@@ -555,13 +612,17 @@ export function createHostApi(deps) {
555
612
  }
556
613
  };
557
614
  const handleComponentDelete = (res, model, componentId) => {
558
- if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
615
+ const resident = typeof deps.scheduler?.supervisorFor === "function"
616
+ ? deps.scheduler.supervisorFor(model.id)
617
+ : null;
618
+ if (resident && resident.state !== "stopped") {
559
619
  sendError(res, 409, "stop the model before removing a bundle component");
560
620
  return;
561
621
  }
562
622
  try {
563
623
  const plan = deleteComponentFile(model, componentId);
564
624
  deps.rescan();
625
+ deps.log?.("library", `deleted ${componentId} from ${model.displayName}; freed ${plan.bytes} bytes`);
565
626
  sendJson(res, {
566
627
  deleted: plan.files,
567
628
  freedBytes: plan.bytes,
@@ -575,10 +636,11 @@ export function createHostApi(deps) {
575
636
  const handleLogs = (res, params) => {
576
637
  const raw = Number(params.get("limit"));
577
638
  const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
639
+ const session = deps.runLog?.tail(limit);
578
640
  const all = deps.supervisor.logLines;
579
641
  sendJson(res, {
580
- lines: all.slice(-limit),
581
- total: all.length,
642
+ lines: session?.lines ?? all.slice(-limit),
643
+ total: session?.total ?? all.length,
582
644
  state: deps.supervisor.state,
583
645
  command: deps.supervisor.command,
584
646
  });
@@ -610,7 +672,13 @@ export function createHostApi(deps) {
610
672
  return;
611
673
  res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
612
674
  };
675
+ const writeLog = (line) => {
676
+ if (res.writableEnded || res.destroyed)
677
+ return;
678
+ res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`);
679
+ };
613
680
  let unsubscribe = () => { };
681
+ let unsubscribeLogs = () => { };
614
682
  const keepalive = setInterval(() => {
615
683
  if (res.writableEnded || res.destroyed)
616
684
  return;
@@ -620,6 +688,7 @@ export function createHostApi(deps) {
620
688
  const teardown = () => {
621
689
  clearInterval(keepalive);
622
690
  unsubscribe();
691
+ unsubscribeLogs();
623
692
  };
624
693
  // The publisher ends the response on host shutdown: an open SSE response is
625
694
  // an open connection, and `server.close()` waits for those.
@@ -628,6 +697,7 @@ export function createHostApi(deps) {
628
697
  if (!res.writableEnded && !res.destroyed)
629
698
  res.end();
630
699
  });
700
+ unsubscribeLogs = deps.logEvents?.subscribe(writeLog) ?? (() => { });
631
701
  // Both ends matter: `close` on the request covers a client that walked away,
632
702
  // and `close` on the response covers the service shutting the socket down.
633
703
  req.on("close", teardown);
@@ -664,6 +734,7 @@ export function createHostApi(deps) {
664
734
  }
665
735
  if (!guardWrite(res))
666
736
  return true;
737
+ deps.log?.("server", "restart requested through the management API");
667
738
  sendJson(res, { accepted: true });
668
739
  queueMicrotask(() => deps.restart?.());
669
740
  return true;
@@ -737,6 +808,7 @@ export function createHostApi(deps) {
737
808
  "--",
738
809
  model,
739
810
  ],
811
+ pull: { entryKey: model, components: components ?? [] },
740
812
  };
741
813
  },
742
814
  },
@@ -763,6 +835,7 @@ export function createHostApi(deps) {
763
835
  "--",
764
836
  repo,
765
837
  ],
838
+ pull: { entryKey: `${repo}#${quant}`, components: components ?? [] },
766
839
  };
767
840
  },
768
841
  },
@@ -838,7 +911,9 @@ export function createHostApi(deps) {
838
911
  }
839
912
  try {
840
913
  const spec = start.makeArgs(result.body);
841
- sendJson(res, { job: deps.jobs?.start(start.kind, spec.target, spec.args) ?? null });
914
+ sendJson(res, {
915
+ job: deps.jobs?.start(start.kind, spec.target, spec.args, spec.pull) ?? null,
916
+ });
842
917
  }
843
918
  catch (error) {
844
919
  sendError(res, 400, errorMessage(error));
@@ -847,27 +922,32 @@ export function createHostApi(deps) {
847
922
  return true;
848
923
  }
849
924
  if (route === "/__host/catalog" && method === "GET") {
850
- void deps.jobs?.query(["catalog", "--json"]).then((models) => sendJson(res, { models }));
925
+ deps.log?.("library", "refreshing the model catalog");
926
+ void deps.jobs
927
+ ?.query(["catalog", "--json"], "library")
928
+ .then((models) => sendJson(res, { models }));
851
929
  return true;
852
930
  }
853
931
  if (route === "/__host/runtimes" && method === "GET") {
854
932
  void deps.jobs
855
- ?.query(["runtime", "list", "--json"])
933
+ ?.query(["runtime", "list", "--json"], "library")
856
934
  .then((runtimes) => sendJson(res, { runtimes }));
857
935
  return true;
858
936
  }
859
937
  if (route === "/__host/hf/search" && method === "GET") {
860
938
  const query = params.get("query") ?? "";
861
939
  const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
940
+ deps.log?.("library", `searching Hugging Face for ${JSON.stringify(query)} (limit ${limit})`);
862
941
  void deps.jobs
863
- ?.query(["search", "--json", "--limit", String(limit), "--", query])
942
+ ?.query(["search", "--json", "--limit", String(limit), "--", query], "library")
864
943
  .then((results) => sendJson(res, { results }));
865
944
  return true;
866
945
  }
867
946
  if (route === "/__host/hf/quants" && method === "GET") {
868
947
  const repo = params.get("repo") ?? "";
948
+ deps.log?.("library", `listing Hugging Face quants for ${repo}`);
869
949
  void deps.jobs
870
- ?.query(["add", "--list-quants", "--json", "--", repo])
950
+ ?.query(["add", "--list-quants", "--json", "--", repo], "library")
871
951
  .then((quants) => sendJson(res, { quants }));
872
952
  return true;
873
953
  }
@@ -913,6 +993,7 @@ export function createHostApi(deps) {
913
993
  // without restarting the host or unloading its resident model.
914
994
  if (route === "/__host/models/rescan" && method === "POST") {
915
995
  const models = deps.rescan();
996
+ deps.log?.("library", `rescanned model library: ${models.length} models`);
916
997
  sendJson(res, { models: models.length });
917
998
  return true;
918
999
  }
@@ -0,0 +1,18 @@
1
+ /** Stable source and subsystem markers for the one Brain service-session log. */
2
+ export type BrainLogArea = "library" | "model" | "api" | "server";
3
+ /**
4
+ * Every service-owned event carries both its process source and operation area.
5
+ * llama-server output is separately marked by `formatLlamaServerLog`.
6
+ */
7
+ export declare function formatBrainLog(area: BrainLogArea, message: string): string;
8
+ /**
9
+ * Remove llama.cpp's elapsed-time, level and component columns. Otto owns the
10
+ * timestamp and source marker, and the remaining message is what an operator
11
+ * needs to diagnose the runtime.
12
+ */
13
+ export declare function stripLlamaServerPrefix(message: string): string;
14
+ /** Preserve the useful llama.cpp message while making its process boundary explicit. */
15
+ export declare function formatLlamaServerLog(message: string): string;
16
+ /** Place source tags ahead of the timestamp so they are scannable in a dense log. */
17
+ export declare function timestampBrainLogLine(timestamp: string, line: string): string;
18
+ //# sourceMappingURL=log-format.d.ts.map
@@ -0,0 +1,32 @@
1
+ const TAGGED_LINE = /^\[(?:brain|llama-server)\]/u;
2
+ const SOURCE_AND_AREA = /^(\[(?:brain|llama-server)\])(?:\s+(\[(?:library|model|api|server)\]))?\s*(.*)$/u;
3
+ const LLAMA_SERVER_PREFIX = /^\d+(?:\.\d+){3}\s+[A-Z]\s+\S+\s+(?:\S+:\s+)?(.+)$/u;
4
+ /**
5
+ * Every service-owned event carries both its process source and operation area.
6
+ * llama-server output is separately marked by `formatLlamaServerLog`.
7
+ */
8
+ export function formatBrainLog(area, message) {
9
+ return TAGGED_LINE.test(message) ? message : `[brain] [${area}] ${message}`;
10
+ }
11
+ /**
12
+ * Remove llama.cpp's elapsed-time, level and component columns. Otto owns the
13
+ * timestamp and source marker, and the remaining message is what an operator
14
+ * needs to diagnose the runtime.
15
+ */
16
+ export function stripLlamaServerPrefix(message) {
17
+ return LLAMA_SERVER_PREFIX.exec(message)?.[1] ?? message;
18
+ }
19
+ /** Preserve the useful llama.cpp message while making its process boundary explicit. */
20
+ export function formatLlamaServerLog(message) {
21
+ return TAGGED_LINE.test(message) ? message : `[llama-server] ${stripLlamaServerPrefix(message)}`;
22
+ }
23
+ /** Place source tags ahead of the timestamp so they are scannable in a dense log. */
24
+ export function timestampBrainLogLine(timestamp, line) {
25
+ const tagged = formatBrainLog("server", line);
26
+ const match = SOURCE_AND_AREA.exec(tagged);
27
+ if (!match)
28
+ return `${timestamp} ${tagged}`;
29
+ const [, source, area, message] = match;
30
+ return `${source}${area ? ` ${area}` : ""} ${timestamp}${message ? ` ${message}` : ""}`;
31
+ }
32
+ //# sourceMappingURL=log-format.js.map