@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.
- package/dist/commands/calibrate.js +9 -0
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +280 -29
- package/dist/config/profiles.js +16 -0
- package/dist/config/schema.d.ts +608 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +57 -11
- package/dist/ops/results.js +75 -10
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +25 -4
- package/dist/service/host-api.js +82 -16
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/router.d.ts +70 -2
- package/dist/service/router.js +219 -21
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +227 -24
- package/dist/service/scheduler.js +395 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +302 -117
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +111 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +65 -17
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- 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
|
|
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,
|
|
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
|
|
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
|
|
44
|
-
* llama-server's `/slots`. Everything
|
|
45
|
-
*
|
|
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
|
|
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
|
}
|
|
@@ -102,6 +124,39 @@ export function statusChangeKey(snapshot) {
|
|
|
102
124
|
: null,
|
|
103
125
|
});
|
|
104
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether the last snapshot showed anything worth sampling quickly for.
|
|
129
|
+
*
|
|
130
|
+
* Read from the snapshot itself rather than tracked separately, so the cadence
|
|
131
|
+
* can never disagree with what was last published. Busy means one of:
|
|
132
|
+
* a request is dispatched or queued (the per-slot rates are moving), an
|
|
133
|
+
* operation is running (`activity` carries a progress bar), or the engine is
|
|
134
|
+
* between states (`ready` is the only settled one that serves traffic).
|
|
135
|
+
*
|
|
136
|
+
* Erring towards "busy" is the safe direction - it costs a sample, where the
|
|
137
|
+
* opposite would freeze a moving number - so an unreadable snapshot counts as
|
|
138
|
+
* busy.
|
|
139
|
+
*/
|
|
140
|
+
function isBusySnapshot(snapshot) {
|
|
141
|
+
if (snapshot.activity != null)
|
|
142
|
+
return true;
|
|
143
|
+
const queued = snapshot.queued;
|
|
144
|
+
if (typeof queued === "number" && queued > 0)
|
|
145
|
+
return true;
|
|
146
|
+
const inference = isRecord(snapshot.inference) ? snapshot.inference : null;
|
|
147
|
+
if (inference) {
|
|
148
|
+
for (const key of ["activeRequests", "processing", "thinking", "generating"]) {
|
|
149
|
+
const value = inference[key];
|
|
150
|
+
if (typeof value === "number" && value > 0)
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const state = snapshot.state;
|
|
155
|
+
// A settled engine that is not serving anything: "ready" idles here, and
|
|
156
|
+
// "stopped" has no engine to poll at all. Anything else is mid-transition
|
|
157
|
+
// (starting, loading) and worth watching closely.
|
|
158
|
+
return typeof state === "string" && state !== "ready" && state !== "stopped";
|
|
159
|
+
}
|
|
105
160
|
/**
|
|
106
161
|
* Owns the current snapshot and decides when it changed.
|
|
107
162
|
*
|
|
@@ -118,8 +173,11 @@ export class BrainStatusPublisher {
|
|
|
118
173
|
/** Per-subscription teardown, so `close()` can end the responses it feeds. */
|
|
119
174
|
_BrainStatusPublisher_closers.set(this, new Set());
|
|
120
175
|
_BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
|
|
176
|
+
_BrainStatusPublisher_idleIntervalMs.set(this, void 0);
|
|
121
177
|
_BrainStatusPublisher_source.set(this, null);
|
|
122
178
|
_BrainStatusPublisher_timer.set(this, null);
|
|
179
|
+
/** The cadence the running timer was armed at, so it is only re-armed on change. */
|
|
180
|
+
_BrainStatusPublisher_timerDelayMs.set(this, null);
|
|
123
181
|
/** The last snapshot that was actually emitted, replayed to a late subscriber. */
|
|
124
182
|
_BrainStatusPublisher_last.set(this, null);
|
|
125
183
|
_BrainStatusPublisher_lastKey.set(this, null);
|
|
@@ -129,6 +187,10 @@ export class BrainStatusPublisher {
|
|
|
129
187
|
_BrainStatusPublisher_resample.set(this, false);
|
|
130
188
|
_BrainStatusPublisher_closed.set(this, false);
|
|
131
189
|
__classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
|
|
190
|
+
// Never slower than the busy cadence: a caller that asks for a slow busy
|
|
191
|
+
// poll means "sample rarely", and an idle default below it would speed the
|
|
192
|
+
// idle path up instead.
|
|
193
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_idleIntervalMs, Math.max(options.idleIntervalMs ?? DEFAULT_IDLE_INTERVAL_MS, __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
|
|
132
194
|
}
|
|
133
195
|
/** Whether a snapshot source has been installed - i.e. events can be served. */
|
|
134
196
|
get ready() {
|
|
@@ -156,7 +218,10 @@ export class BrainStatusPublisher {
|
|
|
156
218
|
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
|
|
157
219
|
if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
|
|
158
220
|
listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
|
|
159
|
-
|
|
221
|
+
// Start at the busy cadence; the sample below settles it. Guessing high for
|
|
222
|
+
// one tick costs a single loopback read, where guessing low could leave a
|
|
223
|
+
// subscriber that arrived mid-generation waiting out the idle interval.
|
|
224
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_armTimer).call(this, __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f"));
|
|
160
225
|
this.notify();
|
|
161
226
|
return () => {
|
|
162
227
|
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
|
|
@@ -200,7 +265,7 @@ export class BrainStatusPublisher {
|
|
|
200
265
|
}
|
|
201
266
|
}
|
|
202
267
|
}
|
|
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() {
|
|
268
|
+
_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
269
|
const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
|
|
205
270
|
if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
206
271
|
return;
|
|
@@ -217,6 +282,10 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
|
|
|
217
282
|
// Always keep the newest body for replay, even when nothing significant
|
|
218
283
|
// changed: a late subscriber should not get last minute's counters.
|
|
219
284
|
__classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
|
|
285
|
+
// Re-pace from what was actually observed, before the unchanged-snapshot
|
|
286
|
+
// return below - an idle brain reports "nothing changed" every time, and
|
|
287
|
+
// that is exactly the case whose cadence needs to back off.
|
|
288
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_armTimer).call(this, isBusySnapshot(snapshot) ? __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f") : __classPrivateFieldGet(this, _BrainStatusPublisher_idleIntervalMs, "f"));
|
|
220
289
|
if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
|
|
221
290
|
return;
|
|
222
291
|
__classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
|
|
@@ -241,10 +310,14 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
|
|
|
241
310
|
void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
|
|
242
311
|
}
|
|
243
312
|
}
|
|
244
|
-
},
|
|
245
|
-
if (__classPrivateFieldGet(this,
|
|
313
|
+
}, _BrainStatusPublisher_armTimer = function _BrainStatusPublisher_armTimer(delayMs) {
|
|
314
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
|
|
315
|
+
return;
|
|
316
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") && __classPrivateFieldGet(this, _BrainStatusPublisher_timerDelayMs, "f") === delayMs)
|
|
246
317
|
return;
|
|
247
|
-
|
|
318
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
|
|
319
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timerDelayMs, delayMs, "f");
|
|
320
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), delayMs), "f");
|
|
248
321
|
// Never hold the process open for status reporting alone.
|
|
249
322
|
__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
|
|
250
323
|
}, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
|
|
@@ -252,5 +325,31 @@ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers =
|
|
|
252
325
|
return;
|
|
253
326
|
clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
|
|
254
327
|
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
|
|
328
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timerDelayMs, null, "f");
|
|
255
329
|
};
|
|
330
|
+
/**
|
|
331
|
+
* Pushes every completed Brain log line immediately. Unlike status snapshots,
|
|
332
|
+
* log lines are an ordered append-only stream, so coalescing would lose the
|
|
333
|
+
* exact evidence the Logs tab exists to show.
|
|
334
|
+
*/
|
|
335
|
+
export class BrainLogPublisher {
|
|
336
|
+
constructor() {
|
|
337
|
+
_BrainLogPublisher_listeners.set(this, new Set());
|
|
338
|
+
}
|
|
339
|
+
subscribe(listener) {
|
|
340
|
+
__classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f").add(listener);
|
|
341
|
+
return () => __classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f").delete(listener);
|
|
342
|
+
}
|
|
343
|
+
publish(line) {
|
|
344
|
+
for (const listener of __classPrivateFieldGet(this, _BrainLogPublisher_listeners, "f")) {
|
|
345
|
+
try {
|
|
346
|
+
listener(line);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// One dead client must not interrupt the service log.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
_BrainLogPublisher_listeners = new WeakMap();
|
|
256
355
|
//# 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
|
|
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
|
|
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
|
|
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 { getCalibration } 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 }, {
|
|
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
|
+
getCalibration(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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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" ?
|
|
170
|
-
tokensPerSecond: phase === "decode" ?
|
|
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
|
-
|
|
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;
|