@otto-code/brain 0.8.10 → 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
@@ -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 { Scheduler } 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,6 +75,8 @@ 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. */
@@ -86,6 +91,8 @@ export interface HostJob {
86
91
  label: string;
87
92
  target: string | null;
88
93
  status: "running" | "succeeded" | "failed" | "canceled";
94
+ /** Positive while the shared scheduler has not admitted this operation yet. */
95
+ queuePosition?: number | null;
89
96
  percent: number | null;
90
97
  message: string | null;
91
98
  error: string | null;
@@ -93,10 +100,15 @@ export interface HostJob {
93
100
  finishedAt: string | null;
94
101
  }
95
102
  export interface HostJobRunner {
96
- start: (kind: HostJob["kind"], target: string | null, args: string[]) => HostJob;
103
+ start: (kind: HostJob["kind"], target: string | null, args: string[],
104
+ /** A bundle entry owns its companion-artifact queue, not the whole host. */
105
+ pull?: {
106
+ entryKey: string;
107
+ components: string[];
108
+ }) => HostJob;
97
109
  list: () => HostJob[];
98
110
  cancel: (jobId: string) => Promise<HostJob[]>;
99
- query: (args: string[]) => Promise<unknown>;
111
+ query: (args: string[], area?: BrainLogArea) => Promise<unknown>;
100
112
  }
101
113
  export interface HostApiDeps {
102
114
  supervisor: Supervisor;
@@ -110,6 +122,8 @@ export interface HostApiDeps {
110
122
  queryGpuInfo: () => Promise<GpuInfo | null>;
111
123
  getRanking: () => RankedModel[];
112
124
  loadModel: (model: Model) => Promise<void>;
125
+ /** The single model queue shared by completions and resident operations. */
126
+ scheduler?: Scheduler | null;
113
127
  /** Mirrors POST /__host/config's gate: may a network caller change things? */
114
128
  getAllowWrite: () => boolean;
115
129
  /** The managed models directory, for disk accounting. Null when unresolvable. */
@@ -121,10 +135,16 @@ export interface HostApiDeps {
121
135
  * its daemon keeps polling status.
122
136
  */
123
137
  statusEvents?: BrainStatusPublisher | null;
138
+ /** The append-only line stream behind `GET /__host/events`. */
139
+ logEvents?: BrainLogPublisher | null;
124
140
  /** Long operations that must execute on this brain's machine. */
125
141
  jobs?: HostJobRunner;
142
+ /** The append-only log owned by this Brain service run. */
143
+ runLog?: BrainRunLog;
126
144
  /** Gracefully restart the serving process after its HTTP acknowledgement. */
127
145
  restart?: () => void;
146
+ /** Durable service-session operation log. */
147
+ log?: (area: BrainLogArea, message: string) => void;
128
148
  }
129
149
  /** One row of the model inventory: the scan, metadata, profile and score joined. */
130
150
  export interface InventoryRow {
@@ -153,7 +173,7 @@ export interface InventoryRow {
153
173
  budget: vram.Budget | null;
154
174
  maxContextThatFits: number | null;
155
175
  score: RankedModel | null;
156
- state: "loaded" | "loading" | "not-loaded";
176
+ state: "loaded" | "loading" | "unloading" | "active" | "queued" | "not-loaded";
157
177
  warnings: ReturnType<typeof profileWarnings>;
158
178
  components: NonNullable<Model["components"]> | null;
159
179
  }
@@ -184,6 +204,7 @@ export declare function buildInventoryRow(params: {
184
204
  gpu: GpuInfo | null;
185
205
  ranking: RankedModel[];
186
206
  supervisor: Supervisor;
207
+ scheduler?: Scheduler | null;
187
208
  runtimeBuild?: number | null;
188
209
  }): InventoryRow;
189
210
  export interface HostApi {
@@ -170,13 +170,21 @@ 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 resident = supervisor.model?.id === model.id;
175
+ if (resident) {
176
+ if (supervisor.state === "starting")
177
+ return "loading";
178
+ if (supervisor.state === "stopping")
179
+ return "unloading";
180
+ }
181
+ const stats = scheduler?.stats();
182
+ if (stats?.active?.modelId === model.id)
183
+ return "active";
184
+ if ((stats?.waitingModelIds[model.id] ?? 0) > 0)
185
+ return "queued";
186
+ if (resident && supervisor.state === "ready")
177
187
  return "loaded";
178
- if (supervisor.state === "starting")
179
- return "loading";
180
188
  return "not-loaded";
181
189
  }
182
190
  /**
@@ -187,7 +195,7 @@ function stateOf(supervisor, model) {
187
195
  * client would otherwise have to correlate three unrelated lists by display name.
188
196
  */
189
197
  export function buildInventoryRow(params) {
190
- const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
198
+ const { model, store, defaults, gpu, ranking, supervisor, scheduler = null, runtimeBuild: activeRuntimeBuild = null, } = params;
191
199
  const profile = forModel(store, model, defaults);
192
200
  const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
193
201
  const budgetOptions = gpu
@@ -222,7 +230,7 @@ export function buildInventoryRow(params) {
222
230
  budget: budgetOptions ? vram.budget(budgetOptions) : null,
223
231
  maxContextThatFits: budgetOptions ? vram.maxContextThatFits(budgetOptions) : null,
224
232
  score: ranked,
225
- state: stateOf(supervisor, model),
233
+ state: stateOf(supervisor, scheduler, model),
226
234
  warnings: profileWarnings(profile, model, store),
227
235
  components: model.components?.map((component) => {
228
236
  if (component.minRuntimeBuild === undefined ||
@@ -267,7 +275,23 @@ function resolveModel(catalog, needle) {
267
275
  */
268
276
  function profileFromQuery(base, params, model) {
269
277
  const patch = {};
270
- const numeric = ["contextSize", "gpuLayers", "parallelSlots", "reasoningBudget"];
278
+ // The samplers cost no VRAM and so change nothing in the budget this powers,
279
+ // but they ride in the same draft the editor sends. Parse them anyway: an
280
+ // unparsed key reaches sanitizeProfilePatch as the string "0.8" and throws,
281
+ // which would fail the whole preview over a field it does not even price.
282
+ const numeric = [
283
+ "contextSize",
284
+ "gpuLayers",
285
+ "parallelSlots",
286
+ "cachedChats",
287
+ "reasoningBudget",
288
+ "temperature",
289
+ "topP",
290
+ "topK",
291
+ "minP",
292
+ "presencePenalty",
293
+ "repeatPenalty",
294
+ ];
271
295
  for (const key of numeric) {
272
296
  const raw = params.get(key);
273
297
  if (raw !== null && raw !== "")
@@ -283,6 +307,16 @@ function profileFromQuery(base, params, model) {
283
307
  if (raw !== null && raw !== "")
284
308
  patch[key] = raw === "true" || raw === "1";
285
309
  }
310
+ // Tri-state, and every spelling a client might use for it. Unknown text is
311
+ // dropped rather than thrown on: this field prices nothing, so a value this
312
+ // route cannot read must not take the whole budget preview down with it.
313
+ const preserve = params.get("preserveReasoning");
314
+ if (preserve === "true" || preserve === "on")
315
+ patch.preserveReasoning = true;
316
+ else if (preserve === "false" || preserve === "off")
317
+ patch.preserveReasoning = false;
318
+ else if (preserve === "default" || preserve === "null")
319
+ patch.preserveReasoning = null;
286
320
  if (Object.keys(patch).length === 0)
287
321
  return base;
288
322
  return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
@@ -306,6 +340,7 @@ export function createHostApi(deps) {
306
340
  // would make a daemon stop polling and see nothing.
307
341
  events: Boolean(deps.statusEvents?.ready),
308
342
  liveInference: Boolean(deps.statusEvents?.ready),
343
+ logEvents: Boolean(deps.statusEvents?.ready && deps.logEvents),
309
344
  writable: deps.getAllowWrite(),
310
345
  jobs: Boolean(deps.jobs),
311
346
  restart: Boolean(deps.restart),
@@ -328,6 +363,7 @@ export function createHostApi(deps) {
328
363
  gpu,
329
364
  ranking,
330
365
  supervisor: deps.supervisor,
366
+ scheduler: deps.scheduler,
331
367
  runtimeBuild: runtimeBuild(deps.supervisor.runtime),
332
368
  }));
333
369
  };
@@ -381,6 +417,7 @@ export function createHostApi(deps) {
381
417
  if (requiresRestart)
382
418
  store.pendingReloadModelIds[model.id] = true;
383
419
  deps.saveProfiles(store);
420
+ deps.log?.("model", `updated profile for ${model.displayName}${requiresRestart ? "; reload required" : ""}`);
384
421
  // Return the recomputed budget so an edit costs one round trip rather
385
422
  // than a write followed by a read the UI has to sequence.
386
423
  const gpu = await deps.queryGpuInfo();
@@ -448,6 +485,7 @@ export function createHostApi(deps) {
448
485
  // the brain is restarted. Reset already follows this pattern below.
449
486
  const catalog = deps.rescan();
450
487
  const updated = resolveModel(catalog, model.id);
488
+ deps.log?.("library", `renamed ${model.displayName} to ${displayName}`);
451
489
  sendJson(res, { displayName: updated ? updated.displayName : displayName });
452
490
  });
453
491
  };
@@ -460,6 +498,7 @@ export function createHostApi(deps) {
460
498
  deleteDisplayName(model.id);
461
499
  const catalog = deps.rescan();
462
500
  const updated = resolveModel(catalog, model.id);
501
+ deps.log?.("library", `reset display name for ${model.displayName}`);
463
502
  sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
464
503
  });
465
504
  };
@@ -511,7 +550,9 @@ export function createHostApi(deps) {
511
550
  return;
512
551
  }
513
552
  try {
553
+ deps.log?.("model", `loading ${model.displayName}`);
514
554
  await deps.loadModel(model);
555
+ deps.log?.("model", `loaded ${model.displayName}`);
515
556
  sendJson(res, {
516
557
  status: deps.supervisor.status(),
517
558
  // What actually got used: loadModel fits the profile to VRAM, so the
@@ -520,6 +561,7 @@ export function createHostApi(deps) {
520
561
  });
521
562
  }
522
563
  catch (error) {
564
+ deps.log?.("model", `failed to load ${model.displayName}: ${errorMessage(error)}`);
523
565
  sendError(res, 409, `could not load ${model.displayName}: ${errorMessage(error)}`);
524
566
  }
525
567
  })();
@@ -527,7 +569,9 @@ export function createHostApi(deps) {
527
569
  const handleUnload = (res) => {
528
570
  void (async () => {
529
571
  try {
572
+ deps.log?.("model", "unloading resident model");
530
573
  await deps.supervisor.stop();
574
+ deps.log?.("model", "resident model unloaded");
531
575
  sendJson(res, { status: deps.supervisor.status() });
532
576
  }
533
577
  catch (error) {
@@ -543,6 +587,7 @@ export function createHostApi(deps) {
543
587
  try {
544
588
  const plan = deleteModelFiles(model);
545
589
  const catalog = deps.rescan();
590
+ deps.log?.("library", `deleted ${model.displayName}; freed ${plan.bytes} bytes`);
546
591
  sendJson(res, {
547
592
  deleted: plan.files,
548
593
  freedBytes: plan.bytes,
@@ -562,6 +607,7 @@ export function createHostApi(deps) {
562
607
  try {
563
608
  const plan = deleteComponentFile(model, componentId);
564
609
  deps.rescan();
610
+ deps.log?.("library", `deleted ${componentId} from ${model.displayName}; freed ${plan.bytes} bytes`);
565
611
  sendJson(res, {
566
612
  deleted: plan.files,
567
613
  freedBytes: plan.bytes,
@@ -575,10 +621,11 @@ export function createHostApi(deps) {
575
621
  const handleLogs = (res, params) => {
576
622
  const raw = Number(params.get("limit"));
577
623
  const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
624
+ const session = deps.runLog?.tail(limit);
578
625
  const all = deps.supervisor.logLines;
579
626
  sendJson(res, {
580
- lines: all.slice(-limit),
581
- total: all.length,
627
+ lines: session?.lines ?? all.slice(-limit),
628
+ total: session?.total ?? all.length,
582
629
  state: deps.supervisor.state,
583
630
  command: deps.supervisor.command,
584
631
  });
@@ -610,7 +657,13 @@ export function createHostApi(deps) {
610
657
  return;
611
658
  res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
612
659
  };
660
+ const writeLog = (line) => {
661
+ if (res.writableEnded || res.destroyed)
662
+ return;
663
+ res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`);
664
+ };
613
665
  let unsubscribe = () => { };
666
+ let unsubscribeLogs = () => { };
614
667
  const keepalive = setInterval(() => {
615
668
  if (res.writableEnded || res.destroyed)
616
669
  return;
@@ -620,6 +673,7 @@ export function createHostApi(deps) {
620
673
  const teardown = () => {
621
674
  clearInterval(keepalive);
622
675
  unsubscribe();
676
+ unsubscribeLogs();
623
677
  };
624
678
  // The publisher ends the response on host shutdown: an open SSE response is
625
679
  // an open connection, and `server.close()` waits for those.
@@ -628,6 +682,7 @@ export function createHostApi(deps) {
628
682
  if (!res.writableEnded && !res.destroyed)
629
683
  res.end();
630
684
  });
685
+ unsubscribeLogs = deps.logEvents?.subscribe(writeLog) ?? (() => { });
631
686
  // Both ends matter: `close` on the request covers a client that walked away,
632
687
  // and `close` on the response covers the service shutting the socket down.
633
688
  req.on("close", teardown);
@@ -664,6 +719,7 @@ export function createHostApi(deps) {
664
719
  }
665
720
  if (!guardWrite(res))
666
721
  return true;
722
+ deps.log?.("server", "restart requested through the management API");
667
723
  sendJson(res, { accepted: true });
668
724
  queueMicrotask(() => deps.restart?.());
669
725
  return true;
@@ -737,6 +793,7 @@ export function createHostApi(deps) {
737
793
  "--",
738
794
  model,
739
795
  ],
796
+ pull: { entryKey: model, components: components ?? [] },
740
797
  };
741
798
  },
742
799
  },
@@ -763,6 +820,7 @@ export function createHostApi(deps) {
763
820
  "--",
764
821
  repo,
765
822
  ],
823
+ pull: { entryKey: `${repo}#${quant}`, components: components ?? [] },
766
824
  };
767
825
  },
768
826
  },
@@ -838,7 +896,9 @@ export function createHostApi(deps) {
838
896
  }
839
897
  try {
840
898
  const spec = start.makeArgs(result.body);
841
- sendJson(res, { job: deps.jobs?.start(start.kind, spec.target, spec.args) ?? null });
899
+ sendJson(res, {
900
+ job: deps.jobs?.start(start.kind, spec.target, spec.args, spec.pull) ?? null,
901
+ });
842
902
  }
843
903
  catch (error) {
844
904
  sendError(res, 400, errorMessage(error));
@@ -847,27 +907,32 @@ export function createHostApi(deps) {
847
907
  return true;
848
908
  }
849
909
  if (route === "/__host/catalog" && method === "GET") {
850
- void deps.jobs?.query(["catalog", "--json"]).then((models) => sendJson(res, { models }));
910
+ deps.log?.("library", "refreshing the model catalog");
911
+ void deps.jobs
912
+ ?.query(["catalog", "--json"], "library")
913
+ .then((models) => sendJson(res, { models }));
851
914
  return true;
852
915
  }
853
916
  if (route === "/__host/runtimes" && method === "GET") {
854
917
  void deps.jobs
855
- ?.query(["runtime", "list", "--json"])
918
+ ?.query(["runtime", "list", "--json"], "library")
856
919
  .then((runtimes) => sendJson(res, { runtimes }));
857
920
  return true;
858
921
  }
859
922
  if (route === "/__host/hf/search" && method === "GET") {
860
923
  const query = params.get("query") ?? "";
861
924
  const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
925
+ deps.log?.("library", `searching Hugging Face for ${JSON.stringify(query)} (limit ${limit})`);
862
926
  void deps.jobs
863
- ?.query(["search", "--json", "--limit", String(limit), "--", query])
927
+ ?.query(["search", "--json", "--limit", String(limit), "--", query], "library")
864
928
  .then((results) => sendJson(res, { results }));
865
929
  return true;
866
930
  }
867
931
  if (route === "/__host/hf/quants" && method === "GET") {
868
932
  const repo = params.get("repo") ?? "";
933
+ deps.log?.("library", `listing Hugging Face quants for ${repo}`);
869
934
  void deps.jobs
870
- ?.query(["add", "--list-quants", "--json", "--", repo])
935
+ ?.query(["add", "--list-quants", "--json", "--", repo], "library")
871
936
  .then((quants) => sendJson(res, { quants }));
872
937
  return true;
873
938
  }
@@ -913,6 +978,7 @@ export function createHostApi(deps) {
913
978
  // without restarting the host or unloading its resident model.
914
979
  if (route === "/__host/models/rescan" && method === "POST") {
915
980
  const models = deps.rescan();
981
+ deps.log?.("library", `rescanned model library: ${models.length} models`);
916
982
  sendJson(res, { models: models.length });
917
983
  return true;
918
984
  }
@@ -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
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import { Scheduler } from "./scheduler.js";
2
3
  import type { Supervisor } from "./supervisor.js";
3
4
  import { type RankedModel } from "../ops/results.js";
4
5
  import type { GpuInfo, Model } from "../types.js";
@@ -6,8 +7,9 @@ import type { Profile } from "../config/schema.js";
6
7
  import { type HostApi } from "./host-api.js";
7
8
  import type { BrainStatusPublisher } from "./status-events.js";
8
9
  type Verdict = "ok" | "reasoning-only" | "truncated" | "failed";
9
- /** A logger sink; only `warn` is used by the router. */
10
+ /** Optional durable operational-log sink for completion lifecycle events. */
10
11
  export interface Logger {
12
+ info?(message: string): void;
11
13
  warn(message: string): void;
12
14
  }
13
15
  /** A source of the catalog: a getter, a snapshot array, or nothing. */
@@ -88,6 +90,8 @@ export interface ModelEntry {
88
90
  reasoning: boolean;
89
91
  /** Optional per-model values accepted by the OpenAI-compatible endpoint. */
90
92
  reasoning_efforts?: string[];
93
+ /** Optional model-native default among `reasoning_efforts`. */
94
+ reasoning_effort_default?: string;
91
95
  loaded_context_length?: number;
92
96
  }
93
97
  /**
@@ -105,6 +109,68 @@ export declare function describeModel(model: Model | null, options?: DescribeOpt
105
109
  * model when no catalog provider is wired in.
106
110
  */
107
111
  export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog): ModelEntry[];
112
+ /**
113
+ * Map an OpenAI-compatible effort request onto a model's own chat-template
114
+ * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
115
+ * controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
116
+ * entries that declare these names are rewritten, so generic models and GPT-OSS
117
+ * keep their existing server-native request handling.
118
+ */
119
+ export declare function applyModelReasoningTemplate(body: Buffer, model: Model): Buffer;
120
+ /**
121
+ * Pin the request to one llama-server slot by adding the engine's own
122
+ * `id_slot` field to the request body (host API v3).
123
+ *
124
+ * Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
125
+ * slot id, so without a pin the router can only correlate a request to a slot
126
+ * by elimination, and with several concurrent requests that guess is exactly
127
+ * the lie the Overview panel used to tell. llama-server honors the pin on every
128
+ * completion endpoint: if the named slot is free the task lands there, and if
129
+ * it is busy the engine DEFERS the task internally - it never reassigns the
130
+ * task elsewhere and never fails the request - so the slot this router names is
131
+ * always the slot the request ends up on (possibly after waiting on it).
132
+ *
133
+ * `null` returns the body untouched: no pin, and therefore no join data for
134
+ * this request, which is the same degraded state as an older brain. A body this
135
+ * cannot parse is forwarded exactly as-is - an unfamiliar request must reach
136
+ * llama-server and get llama-server's own answer, not a 400 invented here.
137
+ */
138
+ export declare function pinSlot(body: Buffer, slotId: number | null): Buffer;
139
+ /**
140
+ * Wipe one llama-server slot's retained KV state, and RESOLVE only once the
141
+ * engine has acknowledged the wipe.
142
+ *
143
+ * This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
144
+ * never clears a released slot's prompt, so a slot handed to a different chat
145
+ * would keep the previous chat's KV and bleed its topics into the new chat's
146
+ * thinking. The router erases the slot the moment the scheduler hands it off;
147
+ * the engine's task queue runs in arrival order, so resolving on the
148
+ * acknowledgment is what guarantees the clean state sits in the queue ahead of
149
+ * the completion the scheduler posts right after.
150
+ *
151
+ * The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
152
+ * action. It answers 200 `{id, id_slot, n_erased}` on success and a
153
+ * `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
154
+ * either way this resolves (never rejects), because an erase that cannot be
155
+ * performed degrades to the old behavior rather than failing the completion.
156
+ *
157
+ * NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
158
+ * body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
159
+ * which is built only from query + path params (b10441 tools/server/server-http.cpp,
160
+ * `server_http_req::params` = "path_params + query_params"; the body is a
161
+ * separate field the handler never parses for this route). A body-only request
162
+ * reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
163
+ * silently no-ops and the bleed survives. The body must stay empty for the
164
+ * same reason `handle_slots_erase` ignores it entirely.
165
+ */
166
+ export declare function eraseSlot(host: string, port: number, slotId: number): Promise<void>;
167
+ /**
168
+ * The eraser the scheduler needs, bound to one engine endpoint. Extracted so
169
+ * the router (which builds its own scheduler) and the service (which builds a
170
+ * shared one and passes it in) hand the scheduler the SAME transport rather
171
+ * than each spelling the request.
172
+ */
173
+ export declare function createSlotEraser(host: string, port: number): (slotId: number) => Promise<void>;
108
174
  /** Which shape a completion path uses to carry its system turn. */
109
175
  export type CompletionShape = "anthropic" | "openai";
110
176
  export declare function completionShape(url: string | null | undefined): CompletionShape;
@@ -195,7 +261,9 @@ export interface RouterOptions {
195
261
  * can never disagree. Absent means this brain does not advertise events.
196
262
  */
197
263
  statusEvents?: BrainStatusPublisher | null;
264
+ /** A service shares this scheduler with host-owned model operations. */
265
+ scheduler?: Scheduler | null;
198
266
  }
199
- export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
267
+ export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, scheduler: suppliedScheduler, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
200
268
  export {};
201
269
  //# sourceMappingURL=router.d.ts.map