@otto-code/brain 0.8.2 → 0.8.3

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The brain's authoritative status event source.
3
+ *
4
+ * The brain owns its own state, so it publishes it rather than being polled for
5
+ * it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
6
+ * out to every connected Otto client, replacing a per-client status poll that
7
+ * could never show a transition sooner than its own interval.
8
+ *
9
+ * Three properties this has to keep:
10
+ *
11
+ * - **Complete snapshots, never deltas.** A reader that missed an event, or
12
+ * reconnected after one, must not have to reconstruct anything: the newest
13
+ * snapshot is the whole truth. That is what makes reconnect repair on both
14
+ * sides idempotent.
15
+ * - **Coalescing lives here, not in the reader.** A timer tick that finds
16
+ * nothing changed emits nothing, and a completion's traffic counters are not
17
+ * a state change. Otherwise "push" would just be a poll with extra steps, and
18
+ * a busy brain would broadcast to every client for every token.
19
+ * - **No work while nobody is listening.** The sampler only runs while a
20
+ * subscriber is attached, so a brain the daemon never subscribed to (an older
21
+ * daemon, or one that is not running) pays nothing.
22
+ *
23
+ * The snapshot itself is deliberately the *cheap* status: no `resources`, whose
24
+ * collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
25
+ */
26
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
27
+ if (kind === "m") throw new TypeError("Private method is not writable");
28
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
29
+ 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");
30
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
31
+ };
32
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
33
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
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
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
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;
38
+ /**
39
+ * How often the publisher resamples while at least one listener is attached.
40
+ *
41
+ * A sample is needed at all because two inputs have no event to hang off:
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.
46
+ */
47
+ const DEFAULT_SAMPLE_INTERVAL_MS = 250;
48
+ function isRecord(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+ /**
52
+ * The fields whose change is worth waking every connected client for.
53
+ *
54
+ * Everything omitted here still rides in the payload; it just cannot *trigger*
55
+ * a payload on its own. The omissions are the churning ones:
56
+ *
57
+ * - `telemetry` totals and `recent` advance on every completion.
58
+ * - `logLineCount` advances on every llama-server log line.
59
+ * - `slots.contexts` is capacity detail that changes independently of work.
60
+ *
61
+ * `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
62
+ * not notified per model token, so it gives the Overview live counts and rates
63
+ * without turning a fast model into a token-rate broadcast.
64
+ *
65
+ * `telemetry.warning` is kept: the reasoning-only advice is a state the UI
66
+ * shows, not a counter.
67
+ */
68
+ export function statusChangeKey(snapshot) {
69
+ const scheduler = isRecord(snapshot.scheduler) ? snapshot.scheduler : null;
70
+ const telemetry = isRecord(snapshot.telemetry) ? snapshot.telemetry : null;
71
+ const slots = isRecord(snapshot.slots) ? snapshot.slots : null;
72
+ return JSON.stringify({
73
+ version: snapshot.version ?? null,
74
+ apiVersion: snapshot.apiVersion ?? null,
75
+ state: snapshot.state ?? null,
76
+ model: snapshot.model ?? null,
77
+ modelId: snapshot.modelId ?? null,
78
+ pid: snapshot.pid ?? null,
79
+ vramBytes: snapshot.vramBytes ?? null,
80
+ loadSeconds: snapshot.loadSeconds ?? null,
81
+ startedAt: snapshot.startedAt ?? null,
82
+ lastError: snapshot.lastError ?? null,
83
+ upstream: snapshot.upstream ?? null,
84
+ runtime: snapshot.runtime ?? null,
85
+ capabilities: snapshot.capabilities ?? null,
86
+ activity: snapshot.activity ?? null,
87
+ reasoning: snapshot.reasoning ?? null,
88
+ inference: snapshot.inference ?? null,
89
+ queued: snapshot.queued ?? null,
90
+ schedulerWaiting: scheduler?.waiting ?? null,
91
+ schedulerLastTurn: scheduler?.lastTurn ?? null,
92
+ warning: telemetry?.warning ?? null,
93
+ slots: slots
94
+ ? {
95
+ total: slots.total ?? null,
96
+ busy: slots.busy ?? null,
97
+ idle: slots.idle ?? null,
98
+ prefill: slots.prefill ?? null,
99
+ decode: slots.decode ?? null,
100
+ threads: slots.threads ?? null,
101
+ }
102
+ : null,
103
+ });
104
+ }
105
+ /**
106
+ * Owns the current snapshot and decides when it changed.
107
+ *
108
+ * Constructed before the router (so `serve.ts` can hand the same instance to
109
+ * both the router and the management API) and inert until the router installs
110
+ * the snapshot source with {@link setSource}. `/__host/capabilities` advertises
111
+ * `events` only once it is {@link ready}, so a brain can never claim a stream it
112
+ * cannot actually serve.
113
+ */
114
+ export class BrainStatusPublisher {
115
+ constructor(options = {}) {
116
+ _BrainStatusPublisher_instances.add(this);
117
+ _BrainStatusPublisher_listeners.set(this, new Set());
118
+ /** Per-subscription teardown, so `close()` can end the responses it feeds. */
119
+ _BrainStatusPublisher_closers.set(this, new Set());
120
+ _BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
121
+ _BrainStatusPublisher_source.set(this, null);
122
+ _BrainStatusPublisher_timer.set(this, null);
123
+ /** The last snapshot that was actually emitted, replayed to a late subscriber. */
124
+ _BrainStatusPublisher_last.set(this, null);
125
+ _BrainStatusPublisher_lastKey.set(this, null);
126
+ /** True while a sample is in flight, so overlapping triggers collapse into one. */
127
+ _BrainStatusPublisher_sampling.set(this, false);
128
+ /** A trigger that arrived mid-sample; runs exactly one more sample afterwards. */
129
+ _BrainStatusPublisher_resample.set(this, false);
130
+ _BrainStatusPublisher_closed.set(this, false);
131
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
132
+ }
133
+ /** Whether a snapshot source has been installed - i.e. events can be served. */
134
+ get ready() {
135
+ return __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f") !== null && !__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f");
136
+ }
137
+ get listenerCount() {
138
+ return __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size;
139
+ }
140
+ /** Install the snapshot builder. Called once, by the router. */
141
+ setSource(source) {
142
+ __classPrivateFieldSet(this, _BrainStatusPublisher_source, source, "f");
143
+ }
144
+ /**
145
+ * Attach a listener. It receives the current snapshot immediately when one is
146
+ * known, and a fresh sample is taken right away so a subscriber that arrives
147
+ * during a transition is not handed a stale first frame.
148
+ */
149
+ subscribe(listener, onClose) {
150
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f")) {
151
+ onClose?.();
152
+ return () => { };
153
+ }
154
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").add(listener);
155
+ if (onClose)
156
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
157
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
158
+ listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
159
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_startTimer).call(this);
160
+ this.notify();
161
+ return () => {
162
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
163
+ if (onClose)
164
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").delete(onClose);
165
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
166
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
167
+ };
168
+ }
169
+ /**
170
+ * Something authoritative changed - resample now rather than at the next tick.
171
+ * Cheap to over-call: an unchanged snapshot emits nothing.
172
+ */
173
+ notify() {
174
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
175
+ return;
176
+ void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
177
+ }
178
+ /**
179
+ * Drop every listener and end the streams behind them.
180
+ *
181
+ * Ending them is not optional at shutdown: an open SSE response is an open
182
+ * connection, and `server.close()` waits for those, so a brain that only
183
+ * unsubscribed would hang on stop until its daemon happened to disconnect.
184
+ */
185
+ close() {
186
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
187
+ return;
188
+ __classPrivateFieldSet(this, _BrainStatusPublisher_closed, true, "f");
189
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
190
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").clear();
191
+ const closers = [...__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f")];
192
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").clear();
193
+ for (const onClose of closers) {
194
+ try {
195
+ onClose();
196
+ }
197
+ catch {
198
+ // Shutdown must not be blocked by one stream failing to end.
199
+ }
200
+ }
201
+ }
202
+ }
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() {
204
+ const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
205
+ if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
206
+ return;
207
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_sampling, "f")) {
208
+ __classPrivateFieldSet(this, _BrainStatusPublisher_resample, true, "f");
209
+ return;
210
+ }
211
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampling, true, "f");
212
+ try {
213
+ const snapshot = await source();
214
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
215
+ return;
216
+ const key = statusChangeKey(snapshot);
217
+ // Always keep the newest body for replay, even when nothing significant
218
+ // changed: a late subscriber should not get last minute's counters.
219
+ __classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
220
+ if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
221
+ return;
222
+ __classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
223
+ // Set iteration tolerates a listener unsubscribing mid-emit, which the
224
+ // SSE teardown does when a daemon disconnects during a snapshot.
225
+ for (const listener of __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f")) {
226
+ try {
227
+ listener(snapshot);
228
+ }
229
+ catch {
230
+ // A broken listener is that listener's problem; the others still get it.
231
+ }
232
+ }
233
+ }
234
+ catch {
235
+ // A failed sample is not a state change. The next tick tries again.
236
+ }
237
+ finally {
238
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampling, false, "f");
239
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_resample, "f")) {
240
+ __classPrivateFieldSet(this, _BrainStatusPublisher_resample, false, "f");
241
+ void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
242
+ }
243
+ }
244
+ }, _BrainStatusPublisher_startTimer = function _BrainStatusPublisher_startTimer() {
245
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
246
+ return;
247
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
248
+ // Never hold the process open for status reporting alone.
249
+ __classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
250
+ }, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
251
+ if (!__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"))
252
+ return;
253
+ clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
254
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
255
+ };
256
+ //# sourceMappingURL=status-events.js.map
@@ -15,7 +15,7 @@ import type { Profile } from "../config/schema.js";
15
15
  export declare const DEFAULT_INTERNAL_PORT = 20800;
16
16
  export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
17
17
  export interface SupervisorOptions {
18
- runtime: Runtime;
18
+ runtime: Runtime | null;
19
19
  internalPort?: number;
20
20
  host?: string;
21
21
  readyTimeoutMs?: number;
@@ -40,7 +40,7 @@ export interface SupervisorStatus {
40
40
  */
41
41
  export declare class Supervisor extends EventEmitter {
42
42
  #private;
43
- runtime: Runtime;
43
+ runtime: Runtime | null;
44
44
  internalPort: number;
45
45
  host: string;
46
46
  readyTimeoutMs: number;
@@ -54,6 +54,12 @@ export class Supervisor extends EventEmitter {
54
54
  /** Start (or restart) the server for a model + profile. */
55
55
  async start(model, profile) {
56
56
  await this.stop();
57
+ if (!this.runtime) {
58
+ this.lastError = "no llama.cpp runtime available";
59
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
60
+ throw new Error(this.lastError);
61
+ }
62
+ const runtime = this.runtime;
57
63
  this.model = model;
58
64
  this.profile = profile;
59
65
  this.lastError = null;
@@ -62,12 +68,12 @@ export class Supervisor extends EventEmitter {
62
68
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
63
69
  const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
64
70
  this.args = args;
65
- this.command = formatCommand(this.runtime, args);
71
+ this.command = formatCommand(runtime, args);
66
72
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
67
73
  const started = Date.now();
68
- this.child = spawn(this.runtime.exe, args, {
69
- cwd: this.runtime.dir,
70
- env: buildEnv(this.runtime),
74
+ this.child = spawn(runtime.exe, args, {
75
+ cwd: runtime.dir,
76
+ env: buildEnv(runtime),
71
77
  windowsHide: true,
72
78
  stdio: ["ignore", "pipe", "pipe"],
73
79
  });
@@ -194,7 +200,7 @@ export class Supervisor extends EventEmitter {
194
200
  startedAt: this.startedAt ? this.startedAt.toISOString() : null,
195
201
  lastError: this.lastError,
196
202
  upstream: this.upstreamBase,
197
- runtime: `${this.runtime.label} v${this.runtime.version}`,
203
+ runtime: this.runtime ? `${this.runtime.label} v${this.runtime.version}` : "not installed",
198
204
  };
199
205
  }
200
206
  }
package/dist/sysmon.d.ts CHANGED
@@ -34,7 +34,7 @@ export interface SlotInfo {
34
34
  threads?: Array<{
35
35
  slot: number;
36
36
  phase: "prefill" | "decode";
37
- promptTokens: number;
37
+ promptTokens: number | null;
38
38
  generatedTokens: number;
39
39
  promptTokensPerSecond: number | null;
40
40
  tokensPerSecond: number | null;
package/dist/sysmon.js CHANGED
@@ -72,6 +72,22 @@ function decodedTokens(rec) {
72
72
  if (typeof value === "number" && Number.isFinite(value))
73
73
  return value;
74
74
  }
75
+ // Current llama.cpp nests the counter in `next_token`; some speculative
76
+ // builds expose an array there. Older Otto builds only read the top level,
77
+ // which made every current slot look like decode with a made-up count of 1.
78
+ const nextToken = rec.next_token;
79
+ const records = Array.isArray(nextToken) ? nextToken : [nextToken];
80
+ let nested = null;
81
+ for (const value of records) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value))
83
+ continue;
84
+ const count = value.n_decoded;
85
+ if (typeof count === "number" && Number.isFinite(count)) {
86
+ nested = nested === null ? count : Math.max(nested, count);
87
+ }
88
+ }
89
+ if (nested !== null)
90
+ return nested;
75
91
  // No counter at all: report a non-zero so the slot lands in decode rather than
76
92
  // claiming a prefill that may never have been happening.
77
93
  return 1;
@@ -110,21 +126,40 @@ export class SlotActivityTracker {
110
126
  _SlotActivityTracker_previous.set(this, new Map());
111
127
  }
112
128
  sample(rows, now = Date.now()) {
113
- const threads = rows.flatMap((row, slot) => {
129
+ const threads = rows.flatMap((row, index) => {
114
130
  const record = row;
131
+ const id = record.id;
132
+ const slot = typeof id === "number" && Number.isFinite(id) ? id : index;
115
133
  if (!isProcessing(record)) {
116
134
  __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").delete(slot);
117
135
  return [];
118
136
  }
119
- const promptTokens = counter(record, ["n_past", "n_prompt_tokens_processed"]);
137
+ const promptTokens = optionalCounter(record, ["n_past", "n_prompt_tokens_processed"]);
120
138
  const generatedTokens = decodedTokens(record);
121
- const phase = generatedTokens === 0 ? "prefill" : "decode";
122
- const previous = __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").get(slot);
139
+ const task = typeof record.id_task === "string" || typeof record.id_task === "number"
140
+ ? record.id_task
141
+ : null;
142
+ // A reused slot starts a new measurement window. Comparing the new
143
+ // request's counters with the old request is how negative or absurd TPS
144
+ // flashes appeared at action boundaries.
145
+ const candidate = __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").get(slot);
146
+ const previous = candidate?.task === task ? candidate : undefined;
147
+ // llama-server may leave the previous request's decoded counter visible
148
+ // during the first snapshot of a newly assigned task. The task boundary
149
+ // is authoritative in that case; otherwise a prompt is shown as decode
150
+ // and prompt throughput is incorrectly reported as zero.
151
+ const phase = candidate !== undefined && candidate.task !== task
152
+ ? "prefill"
153
+ : previous?.phase === "prefill" && generatedTokens <= previous.generatedTokens
154
+ ? "prefill"
155
+ : generatedTokens === 0
156
+ ? "prefill"
157
+ : "decode";
123
158
  const elapsedSeconds = previous ? (now - previous.at) / 1000 : 0;
124
- const rate = (current, before) => elapsedSeconds > 0 && before !== undefined && current >= before
159
+ const rate = (current, before) => current !== null && elapsedSeconds > 0 && before != null && current >= before
125
160
  ? (current - before) / elapsedSeconds
126
161
  : null;
127
- __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, promptTokens, generatedTokens });
162
+ __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, task, promptTokens, generatedTokens, phase });
128
163
  return [
129
164
  {
130
165
  slot,
@@ -140,13 +175,13 @@ export class SlotActivityTracker {
140
175
  }
141
176
  }
142
177
  _SlotActivityTracker_previous = new WeakMap();
143
- function counter(record, keys) {
178
+ function optionalCounter(record, keys) {
144
179
  for (const key of keys) {
145
180
  const value = record[key];
146
181
  if (typeof value === "number" && Number.isFinite(value))
147
182
  return value;
148
183
  }
149
- return 0;
184
+ return null;
150
185
  }
151
186
  const slotActivityTracker = new SlotActivityTracker();
152
187
  /** Slot occupancy from the running server. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {