@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
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Steward's above-editor status widget.
3
+ *
4
+ * Built the same way `@jmcombs/pi-headroom`'s is, and for the same reason: Pi's
5
+ * TUI wraps each widget line in a `Text` component that renders ANSI escapes, so
6
+ * raw 24-bit colour plus Nerd-Font Powerline separators display correctly. The
7
+ * palette is Blue PSL 10K / Catppuccin Latte, and the brand block is `#3465a4` —
8
+ * Path Blue, the same blue as Steward's logo tile.
9
+ *
10
+ * **The subject is Steward, not llama.cpp.** An earlier version reported the
11
+ * inference server's state, so `/steward_stop` left a widget still reading
12
+ * "running :8091" — describing something the operator had not stopped. The state
13
+ * block now says whether the dashboard is up; llama.cpp rides along as detail,
14
+ * which is where it belongs on a widget carrying Steward's name.
15
+ *
16
+ * Exactly three state colours, by design: green started, red stopped, orange
17
+ * something is wrong and needs a person.
18
+ *
19
+ * Pure string building. No I/O, never throws.
20
+ *
21
+ * Keep this module free of Node and DOM APIs — see `./types.ts`.
22
+ */
23
+
24
+ import type { Snapshot } from "./types.js";
25
+
26
+ const ESC = "\x1b";
27
+ /** Powerline solid right-pointing separator (Nerd Font). */
28
+ const ARROW_RIGHT = "\u{E0B0}";
29
+ /**
30
+ * Brand mark: `nf-md-room-service` — the service bell, a dome over a base with
31
+ * the tap-button on top, echoing the logo's arc over rounded bars.
32
+ * `STEWARD_GLYPH` replaces it for an operator who has patched their own mark in;
33
+ * an empty value drops it.
34
+ */
35
+ export const STEWARD_GLYPH = "\u{F088D}";
36
+
37
+ const WIDGET_COLORS = {
38
+ fg: "#eff1f5",
39
+ /**
40
+ * Dark ink for the light backgrounds. Near-white on orange is 2.64:1 — the
41
+ * state that most needs reading was the hardest to read — and on green 2.96:1.
42
+ * Dark lifts them to 5.50 and 4.91. Powerline convention agrees: dark text on
43
+ * warm accents.
44
+ */
45
+ ink: "#1e1e2e",
46
+ /** Neither green, blue nor orange: llama has not been read yet. */
47
+ unknown: "#9ca0b0",
48
+ brand: "#3465a4", // Path Blue — the logo tile, always the first block
49
+ // Darkened from Catppuccin Latte's #40a02b so light ink clears 4.5:1 (2.96 →
50
+ // 4.56). White on green reads as "running" the way the palette intends; the
51
+ // fix is the swatch, not the ink.
52
+ started: "#2f7d20", // green
53
+ stopped: "#d20f39", // red
54
+ error: "#fe640b", // orange — up, but something needs a person
55
+ detail: "#1e66f5", // blue — llama.cpp detail, never a state
56
+ } as const;
57
+
58
+ function hexToRgb(hex: string): [number, number, number] {
59
+ const n = Number.parseInt(hex.replace("#", ""), 16);
60
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
61
+ }
62
+
63
+ const fgCode = (hex: string): string => {
64
+ const [r, g, b] = hexToRgb(hex);
65
+ return `${ESC}[38;2;${r};${g};${b}m`;
66
+ };
67
+
68
+ const bgCode = (hex: string): string => {
69
+ const [r, g, b] = hexToRgb(hex);
70
+ return `${ESC}[48;2;${r};${g};${b}m`;
71
+ };
72
+
73
+ const RESET = `${ESC}[0m`;
74
+
75
+ interface WidgetSegment {
76
+ text: string;
77
+ bg: string;
78
+ /** Ink for this block. Defaults to the light foreground. */
79
+ fg?: string;
80
+ }
81
+
82
+ /** Backgrounds too light to carry near-white text. */
83
+ const DARK_INK_ON = new Set<string>([WIDGET_COLORS.error]);
84
+
85
+ /** Join segments into a left-aligned Powerline string. */
86
+ function buildPowerline(segments: readonly WidgetSegment[]): string {
87
+ let out = "";
88
+ for (let i = 0; i < segments.length; i++) {
89
+ const seg = segments[i];
90
+ if (seg === undefined) continue;
91
+ const ink = seg.fg ?? (DARK_INK_ON.has(seg.bg) ? WIDGET_COLORS.ink : WIDGET_COLORS.fg);
92
+ out += `${bgCode(seg.bg)}${fgCode(ink)} ${seg.text} `;
93
+ const next = segments[i + 1];
94
+ out +=
95
+ next !== undefined
96
+ ? `${fgCode(seg.bg)}${bgCode(next.bg)}${ARROW_RIGHT}`
97
+ : `${RESET}${fgCode(seg.bg)}${ARROW_RIGHT}${RESET}`;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** Resolves the brand mark, honouring an operator override. */
103
+ export function resolveGlyph(env: Record<string, string | undefined>): string {
104
+ const override = env.STEWARD_GLYPH;
105
+ return override === undefined ? STEWARD_GLYPH : override.trim();
106
+ }
107
+
108
+ /** Everything the widget needs. All of it is already known to the extension. */
109
+ export interface WidgetState {
110
+ /** The dashboard's URL when it is serving, else `null`. Steward's own state. */
111
+ portalUrl: string | null;
112
+ /** The machine as last read, or `null` when it has not been read yet. */
113
+ snapshot: Snapshot | null;
114
+ /**
115
+ * Where Pi's llama.cpp provider is pointing **right now**, in memory. When
116
+ * this differs from what Steward watches, chat fails with "Connection error"
117
+ * while the dashboard reports a perfectly healthy server — the failure the
118
+ * orange state exists for.
119
+ */
120
+ providerBaseUrl: string | null;
121
+ stewardBaseUrl: string | null;
122
+ /**
123
+ * Whether Pi's provider config on disk already agrees with Steward. It can:
124
+ * setup edits the file, but Pi read it once at startup and keeps using the old
125
+ * value, so the running session dials the old address until it is restarted.
126
+ *
127
+ * That distinction is the whole difference between "restart Pi" and "Pi is
128
+ * configured wrong" — advice the operator cannot act on if the widget only
129
+ * prints two ports and a `≠`.
130
+ */
131
+ providerFileAgrees: boolean;
132
+ }
133
+
134
+ /** Hosts that mean "this machine", where naming the host says nothing. */
135
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
136
+
137
+ /**
138
+ * `:port` on loopback, `host:port` anywhere else — the same rule for Steward and
139
+ * for llama.cpp, which is what actually makes the two blocks consistent. They
140
+ * used to format addresses by two different rules.
141
+ *
142
+ * The host is not dropped, only elided when it carries no information. A bare
143
+ * `:8788` for a remote host would be a plausible-looking wrong value; a host
144
+ * appearing at all is the signal that this is not the usual machine.
145
+ */
146
+ function formatAddress(host: string, port: number | string): string {
147
+ return LOOPBACK.has(host) ? `:${port}` : `${host}:${port}`;
148
+ }
149
+
150
+ /** The same, from a URL. Falls back to the raw string when it will not parse. */
151
+ function formatUrl(url: string): string {
152
+ try {
153
+ const parsed = new URL(url);
154
+ return formatAddress(parsed.hostname, parsed.port);
155
+ } catch {
156
+ return url;
157
+ }
158
+ }
159
+
160
+ export function formatStatusWidget(state: WidgetState, glyph: string): string {
161
+ const brand = glyph === "" ? "Steward" : `${glyph} Steward`;
162
+ const segments: WidgetSegment[] = [{ text: brand, bg: WIDGET_COLORS.brand }];
163
+
164
+ // Steward itself first: the question this widget's name promises to answer.
165
+ // It needs no probe — the extension holds the server.
166
+ if (state.portalUrl === null) {
167
+ segments.push({ text: "stopped", bg: WIDGET_COLORS.stopped });
168
+ return buildPowerline(segments);
169
+ }
170
+
171
+ const here = formatUrl(state.portalUrl);
172
+
173
+ // A router Pi cannot reach is an error even when everything Steward owns is
174
+ // healthy: the dashboard goes green while chat fails.
175
+ //
176
+ // ONE block, not two. Two adjacent blocks of the same colour drew their
177
+ // separator as orange-on-orange — an invisible arrow and an unexplained gap
178
+ // where the two fused into a single wide band.
179
+ if (
180
+ state.providerBaseUrl !== null &&
181
+ state.stewardBaseUrl !== null &&
182
+ state.providerBaseUrl !== state.stewardBaseUrl
183
+ ) {
184
+ const pi = formatUrl(state.providerBaseUrl);
185
+ const llama = formatUrl(state.stewardBaseUrl);
186
+ // Name the symptom the operator actually hit — chat failing — then the fix.
187
+ segments.push({
188
+ text: state.providerFileAgrees
189
+ ? `chat still on ${pi} \u{B7} restart pi to use ${llama}`
190
+ : `chat is set to ${pi}, llama is on ${llama} \u{B7} /steward_initialize`,
191
+ bg: WIDGET_COLORS.error,
192
+ });
193
+ return buildPowerline(segments);
194
+ }
195
+
196
+ // Steward keeps its own block and its own colour: the dashboard is up, and
197
+ // saying otherwise because llama is down would report the wrong subject. The
198
+ // two blocks differ in colour here, so the separator draws — which is why this
199
+ // one is not merged the way the mismatch block is.
200
+ segments.push({ text: here, bg: WIDGET_COLORS.started });
201
+ if (state.snapshot !== null && !state.snapshot.service.running) {
202
+ segments.push({ text: "llama: stopped", bg: WIDGET_COLORS.stopped });
203
+ return buildPowerline(segments);
204
+ }
205
+
206
+ // Not yet read is its own state. Ending the bar here would look exactly like a
207
+ // healthy one with its tail cut off, which the eye reads as "fine".
208
+ if (state.snapshot === null) {
209
+ segments.push({ text: "llama \u{2014}", bg: WIDGET_COLORS.unknown });
210
+ return buildPowerline(segments);
211
+ }
212
+
213
+ // `active` is serving a request, `resident` is loaded and idle. Both hold
214
+ // weights in memory, which is what the operator is counting.
215
+ const models = state.snapshot.models;
216
+ const loaded = models.filter((m) => m.status === "active" || m.status === "resident").length;
217
+ const where = formatAddress(state.snapshot.service.host, state.snapshot.service.port);
218
+ const detail =
219
+ models.length === 0 ? `llama ${where}` : `llama ${where} \u{B7} ${loaded}/${models.length}`;
220
+ segments.push({ text: detail, bg: WIDGET_COLORS.detail });
221
+ return buildPowerline(segments);
222
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Which unit temperatures are *displayed* in, and how that is decided.
3
+ *
4
+ * Only the DISPLAYED STRING ever changes unit. Every threshold, comparison and
5
+ * bar scale in `./format.ts` is Celsius and stays Celsius — converting a reading
6
+ * before comparing it would turn a 79 °C warning into a 174 °F reading against a
7
+ * 75 threshold and paint every gauge critical. So the unit travels as far as
8
+ * `formatTemperature` and no further.
9
+ *
10
+ * The unit is derived from the operator's REGION, not from a temperature API:
11
+ * `Intl.Locale.prototype.getTemperatureUnit()` and the `measurementSystem`
12
+ * proposal are not standard, are absent in the browsers this dashboard runs in,
13
+ * and would silently answer "metric" for everyone. A region is a fact every
14
+ * browser reports.
15
+ *
16
+ * Keep this module free of Node and DOM APIs — see `./types.ts`. `Intl` is
17
+ * neither: it is part of the language, present in both projects, and every call
18
+ * here is guarded, so a runtime that lacks it degrades to Celsius rather than
19
+ * throwing.
20
+ */
21
+
22
+ /** The unit a temperature is rendered in. There is no third option. */
23
+ export type TemperatureUnit = "celsius" | "fahrenheit";
24
+
25
+ /**
26
+ * The operator's stored choice. `auto` — the default — means "follow the
27
+ * browser", and is resolved to a {@link TemperatureUnit} at apply time by
28
+ * {@link resolveTemperatureUnit}; the other two pin the unit regardless of
29
+ * locale.
30
+ *
31
+ * Modelled now, with no control to set it: the preference is what a later
32
+ * override toggle would store, and having the shape settled means adding that
33
+ * control is a control, not a migration.
34
+ */
35
+ export type TemperaturePreference = "auto" | TemperatureUnit;
36
+
37
+ /**
38
+ * The regions that read temperatures in Fahrenheit: the three countries that
39
+ * use it officially (the United States, Liberia and Myanmar), the five inhabited
40
+ * US territories, and the handful of Caribbean and Pacific states whose everyday
41
+ * weather reporting follows the US.
42
+ *
43
+ * Everything not in this set is Celsius. That asymmetry is deliberate — the
44
+ * default has to be the one that is right for the overwhelming majority of the
45
+ * world's regions, so an unrecognised region is Celsius rather than a guess.
46
+ */
47
+ export const FAHRENHEIT_REGIONS: ReadonlySet<string> = new Set([
48
+ "US", // United States
49
+ "LR", // Liberia
50
+ "MM", // Myanmar
51
+ "PR", // Puerto Rico
52
+ "GU", // Guam
53
+ "VI", // US Virgin Islands
54
+ "AS", // American Samoa
55
+ "MP", // Northern Mariana Islands
56
+ "PW", // Palau
57
+ "FM", // Micronesia
58
+ "MH", // Marshall Islands
59
+ "KY", // Cayman Islands
60
+ "BS", // Bahamas
61
+ "BZ", // Belize
62
+ ]);
63
+
64
+ /**
65
+ * A BCP-47 tag's region subtag, upper-cased, or `null` when the tag carries
66
+ * none or is not a tag at all.
67
+ *
68
+ * `Intl.Locale` does the parsing where it exists, because it also canonicalises
69
+ * (`en-us` → `US`) and understands the tag forms a regex would have to enumerate.
70
+ * It THROWS on a malformed tag, so the call is guarded and a hand-rolled match on
71
+ * the region subtag stands in for a runtime without it. Either way this function
72
+ * cannot throw: an unparseable or absent tag is `null`, which reads as Celsius.
73
+ *
74
+ * The tag is deliberately NOT maximised. `en` alone stays region-less rather
75
+ * than being expanded to `en-Latn-US`: a language with no region attached is not
76
+ * evidence of a country, and inventing one would hand `en` to Fahrenheit on the
77
+ * strength of CLDR's likely-subtags table.
78
+ */
79
+ export function regionFromLocale(locale: string | null | undefined): string | null {
80
+ if (typeof locale !== "string" || locale.trim() === "") return null;
81
+ try {
82
+ const region = new Intl.Locale(locale).region;
83
+ if (typeof region === "string" && region !== "") return region.toUpperCase();
84
+ return null;
85
+ } catch {
86
+ // Malformed for `Intl.Locale`, or no `Intl.Locale` at all: fall through to
87
+ // the subtag match, which is the same rule with none of the canonicalising.
88
+ }
89
+ const match = /^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?-([A-Za-z]{2}|\d{3})(?:-|$)/.exec(locale.trim());
90
+ return match?.[1] === undefined ? null : match[1].toUpperCase();
91
+ }
92
+
93
+ /** The unit a region reads temperatures in. An unknown region is Celsius. */
94
+ export function unitForRegion(region: string | null | undefined): TemperatureUnit {
95
+ if (typeof region !== "string") return "celsius";
96
+ return FAHRENHEIT_REGIONS.has(region.toUpperCase()) ? "fahrenheit" : "celsius";
97
+ }
98
+
99
+ /**
100
+ * The unit one browser locale implies — `en-US` → Fahrenheit, `en-GB`, `de-DE`
101
+ * and a tag with no region at all → Celsius.
102
+ */
103
+ export function temperatureUnitForLocale(locale: string | null | undefined): TemperatureUnit {
104
+ return unitForRegion(regionFromLocale(locale));
105
+ }
106
+
107
+ /**
108
+ * The unit implied by a browser's locale list, in preference order.
109
+ *
110
+ * The FIRST tag that names a region decides, so a `["en", "en-GB"]` browser
111
+ * reads `GB` instead of stopping at a region-less `en` and defaulting. A list
112
+ * with no region anywhere in it is Celsius.
113
+ */
114
+ export function temperatureUnitForLocales(
115
+ locales: readonly (string | null | undefined)[],
116
+ ): TemperatureUnit {
117
+ for (const locale of locales) {
118
+ const region = regionFromLocale(locale);
119
+ if (region !== null) return unitForRegion(region);
120
+ }
121
+ return "celsius";
122
+ }
123
+
124
+ /**
125
+ * The preference applied: `auto` becomes whatever the browser was detected as,
126
+ * and an explicit choice wins over detection.
127
+ */
128
+ export function resolveTemperatureUnit(
129
+ preference: TemperaturePreference,
130
+ detected: TemperatureUnit,
131
+ ): TemperatureUnit {
132
+ return preference === "auto" ? detected : preference;
133
+ }
134
+
135
+ /** A stored preference, or `auto` for anything that is not one of the three. */
136
+ export function parseTemperaturePreference(
137
+ value: string | null | undefined,
138
+ ): TemperaturePreference {
139
+ return value === "celsius" || value === "fahrenheit" || value === "auto" ? value : "auto";
140
+ }
141
+
142
+ /**
143
+ * Celsius to Fahrenheit. Exported so the conversion is stated once and asserted
144
+ * on directly; note that rounding happens AFTER this, in `formatTemperature`,
145
+ * so a 64.4 °C reading rounds from 147.92 °F rather than from a pre-rounded 64.
146
+ */
147
+ export function celsiusToFahrenheit(celsius: number): number {
148
+ return celsius * (9 / 5) + 32;
149
+ }