@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
@@ -26,6 +26,7 @@
26
26
  /** A complete cheap `/__host/status` body. Opaque here; the router assembles it. */
27
27
  export type BrainStatusSnapshot = Record<string, unknown>;
28
28
  export type BrainStatusListener = (snapshot: BrainStatusSnapshot) => void;
29
+ export type BrainLogListener = (line: string) => void;
29
30
  /**
30
31
  * The fields whose change is worth waking every connected client for.
31
32
  *
@@ -45,8 +46,10 @@ export type BrainStatusListener = (snapshot: BrainStatusSnapshot) => void;
45
46
  */
46
47
  export declare function statusChangeKey(snapshot: BrainStatusSnapshot): string;
47
48
  export interface BrainStatusPublisherOptions {
48
- /** Resample cadence while subscribed. See DEFAULT_SAMPLE_INTERVAL_MS. */
49
+ /** Resample cadence while work is in flight. See DEFAULT_SAMPLE_INTERVAL_MS. */
49
50
  sampleIntervalMs?: number;
51
+ /** Resample cadence while idle. See DEFAULT_IDLE_INTERVAL_MS. */
52
+ idleIntervalMs?: number;
50
53
  }
51
54
  /**
52
55
  * Owns the current snapshot and decides when it changed.
@@ -85,4 +88,14 @@ export declare class BrainStatusPublisher {
85
88
  */
86
89
  close(): void;
87
90
  }
91
+ /**
92
+ * Pushes every completed Brain log line immediately. Unlike status snapshots,
93
+ * log lines are an ordered append-only stream, so coalescing would lose the
94
+ * exact evidence the Logs tab exists to show.
95
+ */
96
+ export declare class BrainLogPublisher {
97
+ #private;
98
+ subscribe(listener: BrainLogListener): () => void;
99
+ publish(line: string): void;
100
+ }
88
101
  //# sourceMappingURL=status-events.d.ts.map
@@ -34,17 +34,39 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
34
34
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
35
35
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
36
36
  };
37
- var _BrainStatusPublisher_instances, _BrainStatusPublisher_listeners, _BrainStatusPublisher_closers, _BrainStatusPublisher_sampleIntervalMs, _BrainStatusPublisher_source, _BrainStatusPublisher_timer, _BrainStatusPublisher_last, _BrainStatusPublisher_lastKey, _BrainStatusPublisher_sampling, _BrainStatusPublisher_resample, _BrainStatusPublisher_closed, _BrainStatusPublisher_sample, _BrainStatusPublisher_startTimer, _BrainStatusPublisher_stopTimer;
37
+ var _BrainStatusPublisher_instances, _BrainStatusPublisher_listeners, _BrainStatusPublisher_closers, _BrainStatusPublisher_sampleIntervalMs, _BrainStatusPublisher_idleIntervalMs, _BrainStatusPublisher_source, _BrainStatusPublisher_timer, _BrainStatusPublisher_timerDelayMs, _BrainStatusPublisher_last, _BrainStatusPublisher_lastKey, _BrainStatusPublisher_sampling, _BrainStatusPublisher_resample, _BrainStatusPublisher_closed, _BrainStatusPublisher_sample, _BrainStatusPublisher_armTimer, _BrainStatusPublisher_stopTimer, _BrainLogPublisher_listeners;
38
38
  /**
39
- * How often the publisher resamples while at least one listener is attached.
39
+ * How often the publisher resamples while work is actually in flight.
40
40
  *
41
- * A sample is needed at all because two inputs have no event to hang off:
41
+ * A sample is needed at all because only two inputs have no event to hang off:
42
42
  * `activity` is a file written by a *different* process (a `calibrate` or
43
- * `pull` CLI run), and the slot phase split is a loopback read of
44
- * llama-server's `/slots`. Everything else notifies directly. Sampling is not
45
- * a heartbeat: an unchanged sample emits nothing.
43
+ * `pull` CLI run), and the per-slot token rates are a loopback read of
44
+ * llama-server's `/slots`. **Everything a user reads as state already arrives
45
+ * by push** - stage transitions call `notify()` through
46
+ * `reasoningTracker.onChange`, the scheduler through `onChange`, the supervisor
47
+ * on `state` and `crashed`. So this timer is not what makes the UI feel live;
48
+ * it only refreshes two numbers.
49
+ *
50
+ * It used to be 250ms (4 Hz), which cost a loopback GET to llama-server four
51
+ * times a second for the entire time a daemon was subscribed - including while
52
+ * the brain sat completely idle. `/slots` is served off llama-server's own task
53
+ * queue, so that traffic competes with the decoding it is reporting on, and the
54
+ * brain is already the heaviest thing on the machine. One second is well inside
55
+ * what a rounded tok/s readout can show (the rate is held between samples, see
56
+ * `SlotActivityTracker`), and it cuts the busy-path sampling by 4x.
57
+ */
58
+ const DEFAULT_SAMPLE_INTERVAL_MS = 1000;
59
+ /**
60
+ * How often to resample when nothing is running.
61
+ *
62
+ * With no request in flight, no queued job and no operation, the only thing a
63
+ * sample can discover is a `calibrate`/`pull` starting in another process -
64
+ * long jobs whose progress bar is not harmed by appearing a few seconds in.
65
+ * Everything else that could change is pushed. Polling an idle engine four
66
+ * times a second was pure waste: 14,400 loopback requests an hour to report
67
+ * that nothing happened.
46
68
  */
47
- const DEFAULT_SAMPLE_INTERVAL_MS = 250;
69
+ const DEFAULT_IDLE_INTERVAL_MS = 5000;
48
70
  function isRecord(value) {
49
71
  return typeof value === "object" && value !== null && !Array.isArray(value);
50
72
  }
@@ -75,6 +97,7 @@ export function statusChangeKey(snapshot) {
75
97
  state: snapshot.state ?? null,
76
98
  model: snapshot.model ?? null,
77
99
  modelId: snapshot.modelId ?? null,
100
+ residents: snapshot.residents ?? null,
78
101
  pid: snapshot.pid ?? null,
79
102
  vramBytes: snapshot.vramBytes ?? null,
80
103
  loadSeconds: snapshot.loadSeconds ?? null,
@@ -102,6 +125,39 @@ export function statusChangeKey(snapshot) {
102
125
  : null,
103
126
  });
104
127
  }
128
+ /**
129
+ * Whether the last snapshot showed anything worth sampling quickly for.
130
+ *
131
+ * Read from the snapshot itself rather than tracked separately, so the cadence
132
+ * can never disagree with what was last published. Busy means one of:
133
+ * a request is dispatched or queued (the per-slot rates are moving), an
134
+ * operation is running (`activity` carries a progress bar), or the engine is
135
+ * between states (`ready` is the only settled one that serves traffic).
136
+ *
137
+ * Erring towards "busy" is the safe direction - it costs a sample, where the
138
+ * opposite would freeze a moving number - so an unreadable snapshot counts as
139
+ * busy.
140
+ */
141
+ function isBusySnapshot(snapshot) {
142
+ if (snapshot.activity != null)
143
+ return true;
144
+ const queued = snapshot.queued;
145
+ if (typeof queued === "number" && queued > 0)
146
+ return true;
147
+ const inference = isRecord(snapshot.inference) ? snapshot.inference : null;
148
+ if (inference) {
149
+ for (const key of ["activeRequests", "processing", "thinking", "generating"]) {
150
+ const value = inference[key];
151
+ if (typeof value === "number" && value > 0)
152
+ return true;
153
+ }
154
+ }
155
+ const state = snapshot.state;
156
+ // A settled engine that is not serving anything: "ready" idles here, and
157
+ // "stopped" has no engine to poll at all. Anything else is mid-transition
158
+ // (starting, loading) and worth watching closely.
159
+ return typeof state === "string" && state !== "ready" && state !== "stopped";
160
+ }
105
161
  /**
106
162
  * Owns the current snapshot and decides when it changed.
107
163
  *
@@ -118,8 +174,11 @@ export class BrainStatusPublisher {
118
174
  /** Per-subscription teardown, so `close()` can end the responses it feeds. */
119
175
  _BrainStatusPublisher_closers.set(this, new Set());
120
176
  _BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
177
+ _BrainStatusPublisher_idleIntervalMs.set(this, void 0);
121
178
  _BrainStatusPublisher_source.set(this, null);
122
179
  _BrainStatusPublisher_timer.set(this, null);
180
+ /** The cadence the running timer was armed at, so it is only re-armed on change. */
181
+ _BrainStatusPublisher_timerDelayMs.set(this, null);
123
182
  /** The last snapshot that was actually emitted, replayed to a late subscriber. */
124
183
  _BrainStatusPublisher_last.set(this, null);
125
184
  _BrainStatusPublisher_lastKey.set(this, null);
@@ -129,6 +188,10 @@ export class BrainStatusPublisher {
129
188
  _BrainStatusPublisher_resample.set(this, false);
130
189
  _BrainStatusPublisher_closed.set(this, false);
131
190
  __classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
191
+ // Never slower than the busy cadence: a caller that asks for a slow busy
192
+ // poll means "sample rarely", and an idle default below it would speed the
193
+ // idle path up instead.
194
+ __classPrivateFieldSet(this, _BrainStatusPublisher_idleIntervalMs, Math.max(options.idleIntervalMs ?? DEFAULT_IDLE_INTERVAL_MS, __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
132
195
  }
133
196
  /** Whether a snapshot source has been installed - i.e. events can be served. */
134
197
  get ready() {
@@ -156,7 +219,10 @@ export class BrainStatusPublisher {
156
219
  __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
157
220
  if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
158
221
  listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
159
- __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_startTimer).call(this);
222
+ // Start at the busy cadence; the sample below settles it. Guessing high for
223
+ // one tick costs a single loopback read, where guessing low could leave a
224
+ // subscriber that arrived mid-generation waiting out the idle interval.
225
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_armTimer).call(this, __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f"));
160
226
  this.notify();
161
227
  return () => {
162
228
  __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
@@ -200,7 +266,7 @@ export class BrainStatusPublisher {
200
266
  }
201
267
  }
202
268
  }
203
- _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers = new WeakMap(), _BrainStatusPublisher_sampleIntervalMs = new WeakMap(), _BrainStatusPublisher_source = new WeakMap(), _BrainStatusPublisher_timer = new WeakMap(), _BrainStatusPublisher_last = new WeakMap(), _BrainStatusPublisher_lastKey = new WeakMap(), _BrainStatusPublisher_sampling = new WeakMap(), _BrainStatusPublisher_resample = new WeakMap(), _BrainStatusPublisher_closed = new WeakMap(), _BrainStatusPublisher_instances = new WeakSet(), _BrainStatusPublisher_sample = async function _BrainStatusPublisher_sample() {
269
+ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers = new WeakMap(), _BrainStatusPublisher_sampleIntervalMs = new WeakMap(), _BrainStatusPublisher_idleIntervalMs = new WeakMap(), _BrainStatusPublisher_source = new WeakMap(), _BrainStatusPublisher_timer = new WeakMap(), _BrainStatusPublisher_timerDelayMs = new WeakMap(), _BrainStatusPublisher_last = new WeakMap(), _BrainStatusPublisher_lastKey = new WeakMap(), _BrainStatusPublisher_sampling = new WeakMap(), _BrainStatusPublisher_resample = new WeakMap(), _BrainStatusPublisher_closed = new WeakMap(), _BrainStatusPublisher_instances = new WeakSet(), _BrainStatusPublisher_sample = async function _BrainStatusPublisher_sample() {
204
270
  const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
205
271
  if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
206
272
  return;
@@ -217,6 +283,10 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
217
283
  // Always keep the newest body for replay, even when nothing significant
218
284
  // changed: a late subscriber should not get last minute's counters.
219
285
  __classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
286
+ // Re-pace from what was actually observed, before the unchanged-snapshot
287
+ // return below - an idle brain reports "nothing changed" every time, and
288
+ // that is exactly the case whose cadence needs to back off.
289
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_armTimer).call(this, isBusySnapshot(snapshot) ? __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f") : __classPrivateFieldGet(this, _BrainStatusPublisher_idleIntervalMs, "f"));
220
290
  if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
221
291
  return;
222
292
  __classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
@@ -241,10 +311,14 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
241
311
  void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
242
312
  }
243
313
  }
244
- }, _BrainStatusPublisher_startTimer = function _BrainStatusPublisher_startTimer() {
245
- if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
314
+ }, _BrainStatusPublisher_armTimer = function _BrainStatusPublisher_armTimer(delayMs) {
315
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
316
+ return;
317
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") && __classPrivateFieldGet(this, _BrainStatusPublisher_timerDelayMs, "f") === delayMs)
246
318
  return;
247
- __classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
319
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
320
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timerDelayMs, delayMs, "f");
321
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), delayMs), "f");
248
322
  // Never hold the process open for status reporting alone.
249
323
  __classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
250
324
  }, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
@@ -252,5 +326,31 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
252
326
  return;
253
327
  clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
254
328
  __classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
329
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timerDelayMs, null, "f");
255
330
  };
331
+ /**
332
+ * Pushes every completed Brain log line immediately. Unlike status snapshots,
333
+ * log lines are an ordered append-only stream, so coalescing would lose the
334
+ * exact evidence the Logs tab exists to show.
335
+ */
336
+ export class BrainLogPublisher {
337
+ constructor() {
338
+ _BrainLogPublisher_listeners.set(this, new Set());
339
+ }
340
+ subscribe(listener) {
341
+ __classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f").add(listener);
342
+ return () => __classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f").delete(listener);
343
+ }
344
+ publish(line) {
345
+ for (const listener of __classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f")) {
346
+ try {
347
+ listener(line);
348
+ }
349
+ catch {
350
+ // One dead client must not interrupt the service log.
351
+ }
352
+ }
353
+ }
354
+ }
355
+ _BrainLogPublisher_listeners = new WeakMap();
256
356
  //# sourceMappingURL=status-events.js.map
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { type BrainPaths } from "../config/paths.js";
4
4
  import type { Model, Runtime } from "../types.js";
5
5
  import type { Profile, ProfilesStore } from "../config/schema.js";
6
+ import { type BrainLogArea } from "./log-format.js";
6
7
  /**
7
8
  * Default loopback port for the private llama-server child. Deliberately clear
8
9
  * of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
@@ -19,6 +20,7 @@ export interface SupervisorOptions {
19
20
  runtime: Runtime | null;
20
21
  internalPort?: number;
21
22
  host?: string;
23
+ logVerbosity?: number;
22
24
  readyTimeoutMs?: number;
23
25
  /**
24
26
  * Long-lived hosts provide their live store so profile edits applied just
@@ -52,6 +54,7 @@ export declare class Supervisor extends EventEmitter {
52
54
  runtime: Runtime | null;
53
55
  internalPort: number;
54
56
  host: string;
57
+ logVerbosity: number;
55
58
  readyTimeoutMs: number;
56
59
  paths: BrainPaths;
57
60
  getProfilesStore: () => ProfilesStore;
@@ -72,16 +75,17 @@ export declare class Supervisor extends EventEmitter {
72
75
  * shell line is for reading, not for re-parsing.
73
76
  */
74
77
  args: string[] | null;
75
- constructor({ runtime, internalPort, host, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
78
+ constructor({ runtime, internalPort, host, logVerbosity, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
76
79
  get upstreamBase(): string;
77
80
  /**
78
- * Add a host-operation event to the same bounded tail as llama-server output.
81
+ * Add a host-operation event to the same in-process tail as llama-server output.
79
82
  *
80
83
  * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
81
84
  * than creating invisible sidecar servers. Their lifecycle markers belong in
82
- * the same log stream as the child they exercise.
85
+ * the same event stream as the child they exercise. The serving process
86
+ * copies that stream into its durable full Brain-session log.
83
87
  */
84
- recordLog(line: string): void;
88
+ recordLog(line: string, area?: BrainLogArea): void;
85
89
  /**
86
90
  * Start (or restart) the server for a model + profile.
87
91
  *
@@ -90,9 +94,7 @@ export declare class Supervisor extends EventEmitter {
90
94
  * Jinja template and router-visible system addendum mandatory for every
91
95
  * caller, including future maintenance operations that start a sidecar.
92
96
  */
93
- start(model: Model, profile: Profile, options?: {
94
- preserveLogs?: boolean;
95
- }): Promise<this>;
97
+ start(model: Model, profile: Profile): Promise<this>;
96
98
  /** Fetch /props from the running server (modalities, template caps, defaults). */
97
99
  props(): Promise<unknown>;
98
100
  stop(): Promise<void>;
@@ -5,14 +5,18 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
5
5
  };
6
6
  var _Supervisor_instances, _Supervisor_setState, _Supervisor_log, _Supervisor_health;
7
7
  import http from "node:http";
8
+ import path from "node:path";
9
+ import { mkdirSync } from "node:fs";
8
10
  import { spawn } from "node:child_process";
9
11
  import { EventEmitter } from "node:events";
10
12
  import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
11
13
  import { resolveHostingProfileForLaunch } from "../config/hosting-profiles.js";
14
+ import { getCalibrationForBudget } from "../config/profiles.js";
12
15
  import { resolveBrainPaths } from "../config/paths.js";
13
16
  import { loadProfilesStore } from "../config/store.js";
14
17
  import { usedBytes } from "../gpu.js";
15
- const LOG_LINES_KEPT = 300;
18
+ import { formatBrainLog, formatLlamaServerLog } from "./log-format.js";
19
+ const LOG_LINES_KEPT = 10000;
16
20
  /**
17
21
  * Default loopback port for the private llama-server child. Deliberately clear
18
22
  * of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
@@ -31,12 +35,13 @@ export const DEFAULT_INTERNAL_PORT = 20800;
31
35
  * stable one so switching models never asks a client to reconnect elsewhere.
32
36
  */
33
37
  export class Supervisor extends EventEmitter {
34
- constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
38
+ constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", logVerbosity = 3, readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
35
39
  super();
36
40
  _Supervisor_instances.add(this);
37
41
  this.runtime = runtime;
38
42
  this.internalPort = internalPort;
39
43
  this.host = host;
44
+ this.logVerbosity = logVerbosity;
40
45
  this.readyTimeoutMs = readyTimeoutMs;
41
46
  this.paths = paths;
42
47
  this.getProfilesStore = getProfilesStore;
@@ -57,14 +62,15 @@ export class Supervisor extends EventEmitter {
57
62
  return `http://${this.host}:${this.internalPort}`;
58
63
  }
59
64
  /**
60
- * Add a host-operation event to the same bounded tail as llama-server output.
65
+ * Add a host-operation event to the same in-process tail as llama-server output.
61
66
  *
62
67
  * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
63
68
  * than creating invisible sidecar servers. Their lifecycle markers belong in
64
- * the same log stream as the child they exercise.
69
+ * the same event stream as the child they exercise. The serving process
70
+ * copies that stream into its durable full Brain-session log.
65
71
  */
66
- recordLog(line) {
67
- __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, line);
72
+ recordLog(line, area = "model") {
73
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatBrainLog(area, line));
68
74
  }
69
75
  /**
70
76
  * Start (or restart) the server for a model + profile.
@@ -74,7 +80,7 @@ export class Supervisor extends EventEmitter {
74
80
  * Jinja template and router-visible system addendum mandatory for every
75
81
  * caller, including future maintenance operations that start a sidecar.
76
82
  */
77
- async start(model, profile, options = {}) {
83
+ async start(model, profile) {
78
84
  await this.stop();
79
85
  if (!this.runtime) {
80
86
  this.lastError = "no llama.cpp runtime available";
@@ -82,18 +88,37 @@ export class Supervisor extends EventEmitter {
82
88
  throw new Error(this.lastError);
83
89
  }
84
90
  const runtime = this.runtime;
91
+ // The engine's slot save/erase directory, under the brain's home so it
92
+ // survives across model relaunches (the dir is persistent; the engine only
93
+ // ever uses it for the `action=erase` the scheduler issues on a handoff,
94
+ // which never writes a file). Created before the args are built because
95
+ // llama.cpp validates it exists at launch and throws otherwise.
96
+ const slotSavePath = path.join(this.paths.root, "slot-saves");
97
+ try {
98
+ mkdirSync(slotSavePath, { recursive: true });
99
+ }
100
+ catch {
101
+ /* the engine then starts without slot actions - the pre-fix behavior */
102
+ }
85
103
  const launchProfile = resolveHostingProfileForLaunch(this.paths, this.getProfilesStore(), profile, model.family);
86
104
  this.model = model;
87
105
  this.profile = launchProfile;
88
106
  this.lastError = null;
89
- if (!options.preserveLogs)
90
- this.logLines = [];
91
107
  this.vramBaselineBytes = await usedBytes();
92
108
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
93
- const args = buildArgs({ ...launchProfile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host }, model);
109
+ const args = buildArgs({ ...launchProfile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, {
110
+ port: this.internalPort,
111
+ host: this.host,
112
+ logVerbosity: this.logVerbosity,
113
+ slotSavePath,
114
+ }, model,
115
+ // The prompt-cache budget is derived from measured KV bytes/token, so the
116
+ // launch boundary is where it has to be resolved - nothing downstream of
117
+ // here can reach the calibration store.
118
+ getCalibrationForBudget(this.getProfilesStore(), model, launchProfile));
94
119
  this.args = args;
95
120
  this.command = formatCommand(runtime, args);
96
- __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
121
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatBrainLog("model", `launching: ${this.command}`));
97
122
  const started = Date.now();
98
123
  this.child = spawn(runtime.exe, args, {
99
124
  cwd: runtime.dir,
@@ -104,7 +129,7 @@ export class Supervisor extends EventEmitter {
104
129
  const onChunk = (chunk) => {
105
130
  for (const line of String(chunk).split(/\r?\n/)) {
106
131
  if (line.trim())
107
- __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, line.trim());
132
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatLlamaServerLog(line.trim()));
108
133
  }
109
134
  };
110
135
  this.child.stdout?.on("data", onChunk);
package/dist/sysmon.d.ts CHANGED
@@ -31,6 +31,21 @@ export interface SlotInfo {
31
31
  /** Slots emitting tokens. */
32
32
  decode: number;
33
33
  contexts: number[];
34
+ /**
35
+ * The engine's ids for the slots that are NOT processing, in `/slots` order.
36
+ *
37
+ * `idle` counts them; this names them, which is the difference between
38
+ * knowing there is room and being able to pin a request to a specific slot
39
+ * (`id_slot`). The scheduler hands one of these to each job it admits so the
40
+ * proxy can attribute that request's stage to the exact slot row the Overview
41
+ * panel shows. Empty when every slot is busy - never a guess, because a
42
+ * guessed id would pin a request onto work that is already running.
43
+ *
44
+ * Deliberately the idle half, not the busy half: `threads` below lists only
45
+ * the slots that ARE processing, so it is the wrong end to draw a free slot
46
+ * from.
47
+ */
48
+ idleSlots: number[];
34
49
  threads?: Array<{
35
50
  slot: number;
36
51
  phase: "prefill" | "decode";
package/dist/sysmon.js CHANGED
@@ -65,6 +65,17 @@ function isProcessing(rec) {
65
65
  return rec.state !== 0;
66
66
  return false;
67
67
  }
68
+ /**
69
+ * The engine's id for a slot row, falling back to its position in `/slots`.
70
+ *
71
+ * Shared by the summary and the per-slot sampler so a slot cannot be called `2`
72
+ * in one and `1` in the other - the ids are used to join a request to a row, and
73
+ * a disagreement here would attribute a stage to the wrong slot.
74
+ */
75
+ function slotIdOf(rec, index) {
76
+ const id = rec.id;
77
+ return typeof id === "number" && Number.isFinite(id) ? id : index;
78
+ }
68
79
  /** How many tokens this slot has emitted for the request it is on. */
69
80
  function decodedTokens(rec) {
70
81
  for (const key of ["n_decoded", "n_decoded_tokens", "tokens_predicted"]) {
@@ -112,6 +123,9 @@ export function summariseSlots(rows) {
112
123
  idle: rows.length - busyRows.length,
113
124
  prefill,
114
125
  decode: busyRows.length - prefill,
126
+ idleSlots: rows.flatMap((s, index) => isProcessing(s)
127
+ ? []
128
+ : [slotIdOf(s, index)]),
115
129
  contexts: rows.map((s) => {
116
130
  const rec = s;
117
131
  const nCtx = typeof rec.n_ctx === "number" ? rec.n_ctx : undefined;
@@ -128,8 +142,7 @@ export class SlotActivityTracker {
128
142
  sample(rows, now = Date.now()) {
129
143
  const threads = rows.flatMap((row, index) => {
130
144
  const record = row;
131
- const id = record.id;
132
- const slot = typeof id === "number" && Number.isFinite(id) ? id : index;
145
+ const slot = slotIdOf(record, index);
133
146
  if (!isProcessing(record)) {
134
147
  __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").delete(slot);
135
148
  return [];
@@ -155,19 +168,53 @@ export class SlotActivityTracker {
155
168
  : generatedTokens === 0
156
169
  ? "prefill"
157
170
  : "decode";
158
- const elapsedSeconds = previous ? (now - previous.at) / 1000 : 0;
159
- const rate = (current, before) => current !== null && elapsedSeconds > 0 && before != null && current >= before
160
- ? (current - before) / elapsedSeconds
161
- : null;
162
- __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, task, promptTokens, generatedTokens, phase });
171
+ // Rate is measured against the last window in which this counter actually
172
+ // MOVED, not against the last poll. `/slots` is read at 4 Hz while both
173
+ // counters advance in chunks (prefill a whole batch at a time), so a chunk
174
+ // landing after several flat polls covers all of them: dividing it by the
175
+ // final 250 ms window alone would report several times the real speed.
176
+ // Holding the baseline until movement makes the number an average over the
177
+ // interval it was actually earned. A counter that has not moved yet still
178
+ // yields null - "no measurement", never a fabricated 0 tok/s.
179
+ const advance = (current, base) => {
180
+ if (current === null)
181
+ return { rate: null, base: undefined };
182
+ if (!base)
183
+ return { rate: null, base: { at: now, tokens: current } };
184
+ const seconds = (now - base.at) / 1000;
185
+ if (current <= base.tokens || seconds <= 0)
186
+ return { rate: null, base };
187
+ return { rate: (current - base.tokens) / seconds, base: { at: now, tokens: current } };
188
+ };
189
+ const promptStep = advance(promptTokens, previous?.promptBase);
190
+ const decodeStep = advance(generatedTokens, previous?.decodeBase);
191
+ // A flat window is the counter not having moved *yet*, not throughput
192
+ // falling to nothing, so the last rate measured for THIS task carries
193
+ // forward instead of blanking the field. At any real speed most windows
194
+ // are flat, which is why the number used to visibly blink out and back on
195
+ // every poll. It cannot go stale across requests: `previous` is already
196
+ // gated on an unchanged `id_task`, and the row disappears entirely once
197
+ // the slot stops processing.
198
+ const promptRate = promptStep.rate ?? previous?.promptRate ?? null;
199
+ const decodeRate = decodeStep.rate ?? previous?.decodeRate ?? null;
200
+ __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, {
201
+ task,
202
+ promptTokens,
203
+ generatedTokens,
204
+ phase,
205
+ promptBase: promptStep.base,
206
+ decodeBase: decodeStep.base,
207
+ promptRate,
208
+ decodeRate,
209
+ });
163
210
  return [
164
211
  {
165
212
  slot,
166
213
  phase,
167
214
  promptTokens,
168
215
  generatedTokens,
169
- promptTokensPerSecond: phase === "prefill" ? rate(promptTokens, previous?.promptTokens) : null,
170
- tokensPerSecond: phase === "decode" ? rate(generatedTokens, previous?.generatedTokens) : null,
216
+ promptTokensPerSecond: phase === "prefill" ? promptRate : null,
217
+ tokensPerSecond: phase === "decode" ? decodeRate : null,
171
218
  },
172
219
  ];
173
220
  });
package/dist/tui/app.d.ts CHANGED
@@ -52,7 +52,13 @@ interface Field {
52
52
  step?: number;
53
53
  min?: number;
54
54
  max?: number | ((ctx: FieldContext) => number);
55
- values?: Array<number | string>;
55
+ /**
56
+ * Cycle options as the values actually stored, not their labels: `adjust`
57
+ * writes the selected entry straight into the profile, so a list of display
58
+ * strings would persist "on" where the schema wants `true`. `format` is the
59
+ * one place that turns a stored value into something readable.
60
+ */
61
+ values?: Array<number | string | boolean | null>;
56
62
  note?: (profile: Profile) => string | null;
57
63
  enabled?: (ctx: FieldContext) => boolean;
58
64
  }
@@ -170,7 +176,7 @@ export declare class App {
170
176
  /** Read a profile field by key, without narrowing to a single value type. */
171
177
  readField(key: keyof Profile): unknown;
172
178
  /** Write a profile field by key; a no-op when no model is selected. */
173
- writeField(key: keyof Profile, value: number | string | boolean): void;
179
+ writeField(key: keyof Profile, value: number | string | boolean | null): void;
174
180
  adjust(direction: number): void;
175
181
  activateField(): void;
176
182
  applyMaxContext(): void;