@jmcombs/pi-steward 0.0.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
package/core/types.ts ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Domain types shared by every layer of Steward.
3
+ *
4
+ * The server produces these, the browser consumes them, and the data source
5
+ * (mock today, a live `llama-server` later) is the only thing that knows how
6
+ * they were obtained. Keep this module free of Node and DOM APIs: it is
7
+ * type-checked by both the Node project and the browser project.
8
+ */
9
+
10
+ import type { DriftState } from "./drift.js";
11
+
12
+ /** Log severities Steward renders. `DEBUG` is parsed but rarely emitted. */
13
+ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
14
+
15
+ /**
16
+ * Lifecycle of a model as shown on its card.
17
+ *
18
+ * `active` and `resident` both mean loaded — `active` when at least one of the
19
+ * model's parallel slots is generating, `resident` when it is idle. `loading`
20
+ * and `downloading` are the two ways a load is still in flight: spawning the
21
+ * child, or fetching the weights for the first time. A model llama.cpp reports
22
+ * as `sleeping` folds into `resident` — from the operator's seat it is loaded
23
+ * and ready, which is all the card says.
24
+ */
25
+ export type ModelStatus = "active" | "resident" | "loading" | "downloading" | "unloaded";
26
+
27
+ /**
28
+ * Whether a parallel slot is currently generating.
29
+ *
30
+ * `unknown` is a real answer, not a placeholder: occupancy is established from
31
+ * the server's own log events, and a slot Steward has not yet seen an event for
32
+ * — a child that spawned a moment ago, a stream that was interrupted, a
33
+ * `release` that fell out of the buffer — is a slot whose state was never
34
+ * measured. Reporting it as `idle` would be a guess, and a slot silently stuck
35
+ * on `processing` because its `release` was missed is the exact failure this
36
+ * value exists to prevent.
37
+ */
38
+ export type SlotState = "processing" | "idle" | "unknown";
39
+
40
+ /** Actions the operator can take on the service as a whole. */
41
+ export type ServiceAction = "start" | "stop" | "restart";
42
+
43
+ /** Actions the operator can take on a single model. */
44
+ export type ModelAction = "load" | "unload";
45
+
46
+ /** The `llama-server` process Steward is pointed at. */
47
+ export interface ServiceInfo {
48
+ running: boolean;
49
+ /** Epoch ms the current run began, or `null` when stopped. */
50
+ startedAt: number | null;
51
+ pid: number | null;
52
+ host: string;
53
+ port: number;
54
+ /** llama.cpp build tag, e.g. `b6122`. */
55
+ build: string;
56
+ /**
57
+ * The actions this machine can actually perform: one entry per action that
58
+ * `steward.json` declares a command for AND that the operator has consented
59
+ * to. Config, not a reading — a machine with no control configured reports an
60
+ * empty list, and the block shows a single setup affordance rather than
61
+ * buttons that could not work.
62
+ */
63
+ controls: ServiceAction[];
64
+ }
65
+
66
+ /** One model known to the router, resident or not. */
67
+ export interface ModelInfo {
68
+ id: string;
69
+ /** Display name — the id with its repo prefix and quant suffix trimmed. */
70
+ short: string;
71
+ /**
72
+ * An embedding model — one whose `architecture.output_modalities` does not
73
+ * include `text`. Drives the card color (embedders get a reserved hue) and
74
+ * the detail line; it is not a guess at what the operator uses it for.
75
+ */
76
+ embedding: boolean;
77
+ /** Quantisation label, e.g. `Q4_0`. Best-effort from the id when unloaded. */
78
+ quant: string;
79
+ /**
80
+ * Size on disk, or `null` when the model is not loaded: llama.cpp only
81
+ * reports a `meta` block (where the byte size lives) for resident models.
82
+ */
83
+ sizeGB: number | null;
84
+ /**
85
+ * Per-slot context length. Loaded: `meta.n_ctx`. Unloaded preset: the pinned
86
+ * `--ctx-size ÷ --parallel`, so it matches the loaded per-slot figure. `null`
87
+ * when neither is known.
88
+ */
89
+ ctx: number | null;
90
+ /**
91
+ * The model's native (training) context window, `meta.n_ctx_train`. Loaded
92
+ * only — llama.cpp ships no `meta` until then — so `null` when unloaded. It is
93
+ * the ceiling the per-slot {@link ctx} is carved out of.
94
+ */
95
+ nativeCtx: number | null;
96
+ /**
97
+ * Layers offloaded to the GPU when `--n-gpu-layers` is pinned in the model's
98
+ * launch args, else `null` — the server's own default is not reported back.
99
+ */
100
+ gpuLayers: number | null;
101
+ /** Trailing detail for the card's meta line, e.g. `embedding`; else `null`. */
102
+ detail: string | null;
103
+ /**
104
+ * Parallel decode slots this model has (`--parallel`). Per-model in routed
105
+ * mode: the router runs one `llama-server` per model, each with its own slot
106
+ * count. Once loaded the slot array is the authority; an unloaded preset model
107
+ * gets it from its pinned `--parallel` arg, and it is `null` when neither is
108
+ * known.
109
+ */
110
+ parallel: number | null;
111
+ /**
112
+ * Flash-attention setting (`--flash-attn`). `auto` is the server default and
113
+ * resolves to on or off at load time; the launch args can pin it either way.
114
+ */
115
+ flashAttn: "on" | "off" | "auto";
116
+ /** KV-cache types as `key/value`, e.g. `q8_0/q8_0` (`--cache-type-k/-v`). */
117
+ kvCache: string;
118
+ status: ModelStatus;
119
+ /** Generation rate while active, else `null`. */
120
+ tokensPerSecond: number | null;
121
+ }
122
+
123
+ /**
124
+ * One parallel slot of one loaded model. Slots are numbered per model, from 0,
125
+ * because in routed mode each model runs its own `llama-server` with its own
126
+ * slot pool — a flat, server-wide slot strip does not exist.
127
+ */
128
+ export interface SlotInfo {
129
+ /** Per-model slot index, from 0. */
130
+ id: number;
131
+ /** The model this slot belongs to; always known (slots are fetched per model). */
132
+ modelId: string;
133
+ /**
134
+ * Tokens the slot's context currently holds, or `null` when nothing has
135
+ * reported it. The log states this at the end of a request (`release`'s
136
+ * `n_tokens`) and, for a long prefill, while it runs — so a slot that has not
137
+ * served a request since Steward started watching genuinely has no figure, and
138
+ * `0` would claim an empty context we never measured.
139
+ */
140
+ promptTokens: number | null;
141
+ /**
142
+ * The slot's context length in tokens, or `null` when the model's launch
143
+ * configuration does not state it. Structural, not a reading: it comes from
144
+ * the model's `--ctx-size`/`meta.n_ctx`, which is fixed for the life of the
145
+ * child process.
146
+ */
147
+ ctxTotal: number | null;
148
+ /**
149
+ * Tokens generated so far this turn, or `null` while unmeasured. llama.cpp
150
+ * only prints a running `n_decoded` for a generation long enough to cross its
151
+ * ~3 s reporting interval, so a short request has no count until it finishes.
152
+ */
153
+ decoded: number | null;
154
+ state: SlotState;
155
+ }
156
+
157
+ /**
158
+ * How the machine's memory is laid out, a static property of the hardware — not
159
+ * a per-sample reading (per M5: a per-line topology would thrash the gauge set).
160
+ * `discrete` machines have separate VRAM and RAM; `unified` machines (e.g. Apple
161
+ * Silicon) share one pool and expose no readable VRAM total, so they show a
162
+ * single Unified RAM gauge instead of the VRAM+RAM pair.
163
+ */
164
+ export type MemoryTopology = "unified" | "discrete";
165
+
166
+ /** Host sensors. Temperatures are `null` where the platform cannot supply them. */
167
+ export interface HostMetrics {
168
+ vramUsedGB: number;
169
+ vramTotalGB: number;
170
+ ramUsedGB: number;
171
+ ramTotalGB: number;
172
+ /** GPU utilisation, 0–1. */
173
+ gpuUtil: number;
174
+ /** CPU utilisation, 0–1. */
175
+ cpuUtil: number;
176
+ gpuTempC: number | null;
177
+ cpuTempC: number | null;
178
+ }
179
+
180
+ /** A read-only key/value row in the rail's config block. */
181
+ export interface ConfigEntry {
182
+ key: string;
183
+ value: string;
184
+ }
185
+
186
+ /**
187
+ * What kind of record a line is, decided by the producer while it parses.
188
+ *
189
+ * `proxy` and `args` exist because the console suppresses or folds them by
190
+ * default and has to be able to say honestly what it is not showing; `rate`
191
+ * exists so a future suppression needs no parser change. Nothing is ever
192
+ * dropped for its kind — classification is tagging, so every filter the console
193
+ * builds on it stays reversible. Absent means `event`: an ordinary line, shown
194
+ * as-is.
195
+ *
196
+ * - `proxy` — the router's `proxy_reques` lines. 86.9% of a real log, and most
197
+ * of them are Steward's own `/slots` + `/metrics` polls watching itself.
198
+ * - `args` — the contiguous continuation run under `spawning … with args:`.
199
+ * Individually meaningless (`--ctx-size`, then `131072`), collectively the
200
+ * exact launch command line.
201
+ * - `rate` — the ~3 s in-flight `n_decoded … tg = N t/s` generation line.
202
+ */
203
+ export type LogKind = "event" | "proxy" | "args" | "rate";
204
+
205
+ /**
206
+ * Which process wrote the line: `child` when the router prefixed it with
207
+ * `[port]`, `router` otherwise. It is the only way to tell a router-wide line
208
+ * (no model involved — render `router`) from a child line whose port has not
209
+ * been mapped yet (model genuinely unknown — render `—`). Absent means the
210
+ * source predates the field, and a null `modelId` is treated as router-wide.
211
+ */
212
+ export type LogOrigin = "router" | "child";
213
+
214
+ /**
215
+ * Operator-facing grouping of records — the console's `kind` chips.
216
+ *
217
+ * Deliberately four values, not the research's six: proxied requests already
218
+ * have a purpose-built toggle and the launch-args block already has a fold, so
219
+ * neither earns a chip. Absent means `other`.
220
+ *
221
+ * `other` is the drift alarm and needs no alarm built: a llama-server message
222
+ * shape Steward has never seen lands there and stays visible, and a count that
223
+ * starts climbing is the signal.
224
+ */
225
+ export type LogFamily = "requests" | "models" | "startup" | "other";
226
+
227
+ /**
228
+ * The `id %2d | task %d | ` frame that every `SLT_*` macro emits.
229
+ *
230
+ * One shared literal in llama.cpp's `server-common.h`, byte-identical from
231
+ * b4500 (Jan 2025) through b10090 (Jul 2026) across two file moves — the
232
+ * strongest structural guarantee the log offers, and the only one worth
233
+ * relocating out of the message.
234
+ */
235
+ export interface LogFrame {
236
+ /** `(slot).id`, 0 .. n_parallel-1. Always 0 on a `--parallel 1` server. */
237
+ slot: number;
238
+ /** `(slot).task->id`, or -1 where no task is attached yet (`get_available_slot`). */
239
+ task: number;
240
+ /**
241
+ * The frame exactly as the file wrote it, INCLUDING the `slot print_timing: `
242
+ * head it sits behind, so `raw + message` re-forms the line byte for byte.
243
+ * That is the guarantee that makes the relocation a relocation rather than a
244
+ * rewrite: Copy and Download reproduce the file, not a reassembly of it.
245
+ */
246
+ raw: string;
247
+ }
248
+
249
+ /** One line of the server log. */
250
+ export interface LogLine {
251
+ /** Monotonic per-source sequence number; also the render key. */
252
+ seq: number;
253
+ /**
254
+ * Which log SOURCE produced this line — bumped whenever the server starts
255
+ * reading a different one, and absent from a source that can never change.
256
+ *
257
+ * {@link seq} is monotonic per source and NOTHING else: two sources number
258
+ * their lines independently, and the file tailer reads a backlog window the
259
+ * moment it opens, so a replacement's counter is already in the thousands
260
+ * before it delivers anything. Comparing numbers across that boundary is
261
+ * meaningless in both directions — a higher one reads as "newer" and gets
262
+ * appended, so a console would silently concatenate two different logs under
263
+ * one buffer; a lower one reads as a restart. This field is what makes the
264
+ * boundary legible: a batch that carries a different generation than the
265
+ * buffer replaces it, whatever the sequence numbers say.
266
+ */
267
+ gen?: number;
268
+ /** Epoch ms. Formatted as `HH:MM:SS.mmm` at render time. */
269
+ ts: number;
270
+ level: LogLevel;
271
+ /** Model the line was attributed to, or `null` when it is not slot traffic. */
272
+ modelId: string | null;
273
+ /**
274
+ * Everything after {@link LogFrame.raw} when the line was framed, and the
275
+ * whole text after the level letter when it was not. Always a verbatim SUFFIX
276
+ * of the line as the file wrote it — nothing is paraphrased, reordered or
277
+ * dropped.
278
+ */
279
+ message: string;
280
+ /** What class of record this is; absent means {@link LogKind} `event`. */
281
+ kind?: LogKind;
282
+ /** Which process wrote it; absent means the source does not report it. */
283
+ origin?: LogOrigin;
284
+ /**
285
+ * The `[port]` prefix's port. HALF THE TRACE KEY: task ids are a per-process
286
+ * counter from 0, so task `0` appears under 8 different ports in a single
287
+ * measured corpus. A trace keyed on the id alone would be a real,
288
+ * user-visible bug — it would show one request's lines mixed with another
289
+ * model's.
290
+ */
291
+ port?: number;
292
+ /**
293
+ * The pipe frame, when the line carried one. When absent, {@link message} is
294
+ * the whole text after the level letter, exactly as before this existed, and
295
+ * the task cell is empty — which is the whole degradation path.
296
+ */
297
+ frame?: LogFrame;
298
+ /** Which chip the line answers to; absent means {@link LogFamily} `other`. */
299
+ family?: LogFamily;
300
+ /**
301
+ * `truncated = 1` on a release line: this request's reply was written from a
302
+ * context a shift had already cut the front off. Absent means the line did
303
+ * not say so — never that it said no.
304
+ */
305
+ contextLost?: boolean;
306
+ /** `sim_best` — the fraction of the prompt already in KV cache, 0–1. */
307
+ cacheHit?: number;
308
+ }
309
+
310
+ /**
311
+ * Whether a log source is connected, and — when it is not — which way it
312
+ * failed. `unavailable` means no source was ever discovered (nothing to watch);
313
+ * `missing` means a path IS being watched and the file is not there right now,
314
+ * which on macOS is routinely `com.apple.tmp_cleaner` unlinking a `/tmp` log
315
+ * that went three days untouched. The second is self-healing and must not be
316
+ * reported as the first.
317
+ */
318
+ export type LogSourceState = "ok" | "unavailable" | "missing";
319
+
320
+ /** The health of the log source itself, alongside the line stream. */
321
+ export interface LogStreamStatus {
322
+ source: LogSourceState;
323
+ /** The path being watched, so the console can name the file it is missing. */
324
+ path: string | null;
325
+ /** A readable reason when the source is not `ok`, else `null`. */
326
+ detail: string | null;
327
+ }
328
+
329
+ /**
330
+ * Everything the dashboard needs for one repaint, other than the log stream.
331
+ * The browser polls this; nothing in it is incremental.
332
+ */
333
+ export interface Snapshot {
334
+ /** Epoch ms the snapshot was taken, so the client need not trust its clock. */
335
+ now: number;
336
+ service: ServiceInfo;
337
+ models: ModelInfo[];
338
+ slots: SlotInfo[];
339
+ metrics: HostMetrics;
340
+ /**
341
+ * Which gauge SET the HOST block lays out (VRAM+RAM vs a single Unified
342
+ * Memory). Static machine config, not a reading — it lives here at the top
343
+ * level, never inside {@link HostMetrics}. In a later phase this is read from
344
+ * the `steward.json` config artifact; today the mock reports `discrete`.
345
+ */
346
+ memoryTopology: MemoryTopology;
347
+ /**
348
+ * Whether this machine still matches what `steward.json` says about it: the
349
+ * live launch argv against the recorded one, and any command declared but not
350
+ * approved. Always present, because "we did not check" ({@link
351
+ * import("./drift.js").LaunchDrift} `unknown`) is a distinct answer from "we
352
+ * checked and it matches" — and only the first of those is honest when the
353
+ * check could not run.
354
+ */
355
+ drift: DriftState;
356
+ /**
357
+ * Tokens generated per second of wall clock over {@link
358
+ * throughputWindowSeconds}, across all slots — or `null` when no window has
359
+ * been measured.
360
+ *
361
+ * This is throughput in the ordinary sense: what the server produced, divided
362
+ * by the time it had to produce it. It is NOT the speed of whatever request is
363
+ * running at this instant, and on a real box the two are nowhere near each
364
+ * other, because most of the wall clock has no request in it at all. A model's
365
+ * own live speed is still reported per model, on the model that is generating
366
+ * — see {@link ModelInfo.tokensPerSecond} — because "how fast is this request
367
+ * going" is a real question; it is just not one a box-wide tile can answer for
368
+ * a box that is idle nine seconds in ten.
369
+ *
370
+ * Reporting the instant instead was tried and is what this replaced. llama.cpp
371
+ * prints a live rate only once a generation passes 100 tokens AND ~3 s, and on
372
+ * a measured 16,517-request corpus 99.4% of requests never reached that, so
373
+ * the only rate they ever produced was the one on the `eval time` line their
374
+ * `release` follows 17 microseconds later. Sampled every 1.6 s, that tile read
375
+ * `0` while idle and `—` while busy and never once read the truth.
376
+ *
377
+ * `0` is a measurement — the window elapsed and nothing was generated in it.
378
+ * `null` is the absence of one: no window has closed yet, or the event stream
379
+ * broke and the span that window would cover cannot be vouched for.
380
+ */
381
+ throughputTps: number | null;
382
+ /**
383
+ * The wall clock {@link throughputTps} and {@link throughputHistory} cover, in
384
+ * seconds — the span the strip is showing, which grows to ~2 minutes and then
385
+ * rolls.
386
+ *
387
+ * `null` when the figure is not a window measurement at all: a Steward with no
388
+ * log source cannot count tokens and samples llama.cpp's own rate gauge
389
+ * instead, and the mock invents a series outright. The tile reads this to know
390
+ * which claim it is allowed to print beside the number.
391
+ */
392
+ throughputWindowSeconds: number | null;
393
+ /**
394
+ * Requests being processed across all slots right now, or `null` when any
395
+ * slot's occupancy is {@link SlotState} `unknown` and the true count can only
396
+ * be bounded below. llama.cpp exposes no request-rate metric, so the requests
397
+ * tile reports this live count (and {@link requestsQueued}) rather than a
398
+ * per-minute rate it cannot measure.
399
+ */
400
+ requestsInFlight: number | null;
401
+ /**
402
+ * Rolling tok/s samples, oldest first. 42 samples ≈ 2 minutes.
403
+ *
404
+ * Each sample is its own span's tokens over its own span's wall clock, so a
405
+ * sample of `0` says the server generated nothing in those ~3 seconds — a
406
+ * measurement, and the shape of intermittent traffic is exactly what makes the
407
+ * strip worth drawing. An empty array means no span can be vouched for yet,
408
+ * which is what the strip shows after a restart or a break in the log.
409
+ *
410
+ * On a Steward with no log source the samples are gauge readings instead, and
411
+ * only measured ones are appended: a tick that could not be read contributes
412
+ * no sample rather than a fabricated `0`. {@link throughputWindowSeconds} is
413
+ * `null` there, and the axis is the only thing claiming a span.
414
+ */
415
+ throughputHistory: number[];
416
+ /**
417
+ * Requests accepted but waiting for a free slot, or `null` when nothing can
418
+ * report it. The server's log says when a slot is taken and released but never
419
+ * mentions the queue behind it, so a Steward reading occupancy from the log —
420
+ * which is every Steward with a log source — has no honest figure here. It is
421
+ * `null` there, and a number only when the `/metrics` scrape supplied one.
422
+ */
423
+ requestsQueued: number | null;
424
+ config: ConfigEntry[];
425
+ }
426
+
427
+ /** Number of samples in {@link Snapshot.throughputHistory}. */
428
+ export const THROUGHPUT_HISTORY_SIZE = 42;
429
+
430
+ /** Seconds between throughput samples, matching the metrics poll. */
431
+ export const THROUGHPUT_SAMPLE_SECONDS = 3;