diff --git a/docs/superpowers/plans/2026-08-14-usage-stats-gpu-combos.md b/docs/superpowers/plans/2026-08-14-usage-stats-gpu-combos.md new file mode 100644 index 0000000..69d6577 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-usage-stats-gpu-combos.md @@ -0,0 +1,1121 @@ +# Usage Stats + GPU Combinations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Usage-stats display mode to the In-Flight Monitor action (request/token totals + gen-speed P95 from `/api/metrics/stats`) and GPU combination support to the GPU Graph action. + +**Architecture:** A shared runtime-owned `StatsCache` polls llama-swap's `/api/metrics/stats` for registered model keys (single interval, `activity` SSE events trigger throttled refreshes) and feeds a new `renderUsage` view in the In-Flight action. GPU combinations are aggregated in the GPU action via a pure `combineSeries` helper over the poller's existing per-GPU rings. + +**Tech Stack:** TypeScript ESM, `node:test`+`tsx`, `@elgato/streamdeck@2.1.1`. Tests run with `npm test` (i.e. `tsx --test`). Build: `npm run build` (rollup). Typecheck: `npx tsc --noEmit`. + +## Global Constraints + +- No code comments (project convention). +- Work committed on `main` (user-approved workflow; no remote). +- `feed.ts`'s `FeedEvent` type lives in `src/lib/inflight-tracker.ts`. +- Default base URL `http://localhost:9292`; llama-swap `/api/metrics/stats` returns `{ total_requests, total_input_tokens, total_output_tokens, total_cache_tokens, prompt_histogram, gen_histogram }`; `gen_histogram.p95` is a **tokens/sec** percentile (not latency). +- Key images are SVG strings via `svgDataUrl`; dark background `#10131a`. +- Multi-key safety: per-key state lives in a `Map` keyed on `ev.action.id`. +- `npm test` runs ALL test files via `tsx --test`; `AggregateError` style failures indicate a failing test file. + +--- + +### Task 1: Stats types, parsing, and fetching + +**Files:** +- Create: `src/lib/stats.ts` +- Test: `tests/stats.test.ts` + +**Interfaces:** +- Produces: + - `interface UsageStats { totalRequests: number; totalInputTokens: number; totalOutputTokens: number; genP95: number }` + - `parseStats(json: unknown): UsageStats | null` + - `fetchStats(cfg: LlamaSwapConfig, modelId: string): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `tests/stats.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fetchStats, parseStats, type UsageStats } from "../src/lib/stats"; + +test("parseStats extracts totals and gen p95", () => { + const stats = parseStats({ + total_requests: 1985, + total_input_tokens: 312327705, + total_output_tokens: 932710, + total_cache_tokens: 308144640, + gen_histogram: { p50: 341, p95: 378.86, p99: 392 }, + }); + assert.deepEqual(stats, { totalRequests: 1985, totalInputTokens: 312327705, totalOutputTokens: 932710, genP95: 378.86 }); +}); + +test("parseStats defaults missing fields and rejects malformed input", () => { + assert.deepEqual(parseStats({}), { totalRequests: 0, totalInputTokens: 0, totalOutputTokens: 0, genP95: 0 }); + assert.equal(parseStats({ total_requests: "nope" })!.totalRequests, 0); + assert.equal(parseStats(null), null); + assert.equal(parseStats("x"), null); +}); + +test("fetchStats calls /api/metrics/stats with a model query", async () => { + const calls: string[] = []; + const orig = globalThis.fetch; + globalThis.fetch = (async (url: RequestInfo | URL) => { + calls.push(String(url)); + return { ok: true, json: async () => ({ total_requests: 7 }) } as unknown as Response; + }) as typeof fetch; + try { + await fetchStats({ baseUrl: "http://x" }, "DeepSeek-V4-Flash-0731"); + assert.equal(calls[0], "http://x/api/metrics/stats?model=DeepSeek-V4-Flash-0731"); + } finally { + globalThis.fetch = orig; + } +}); + +test("fetchStats omits the model query for 'all' and returns null on HTTP error", async () => { + const calls: string[] = []; + const orig = globalThis.fetch; + globalThis.fetch = (async (url: RequestInfo | URL) => { + calls.push(String(url)); + return { ok: false, status: 500 } as unknown as Response; + }) as typeof fetch; + try { + const stats = await fetchStats({ baseUrl: "http://x" }, "all"); + assert.equal(stats, null); + assert.equal(calls[0], "http://x/api/metrics/stats"); + } finally { + globalThis.fetch = orig; + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/stats.test.ts` +Expected: FAIL — `Cannot find module '../src/lib/stats'`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/stats.ts`: + +```ts +import { type LlamaSwapConfig } from "./util"; + +export interface UsageStats { + totalRequests: number; + totalInputTokens: number; + totalOutputTokens: number; + genP95: number; +} + +export function parseStats(json: unknown): UsageStats | null { + if (!json || typeof json !== "object") return null; + const obj = json as Record; + const genHist = (obj.gen_histogram ?? {}) as Record; + const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + return { + totalRequests: num(obj.total_requests), + totalInputTokens: num(obj.total_input_tokens), + totalOutputTokens: num(obj.total_output_tokens), + genP95: num(genHist.p95), + }; +} + +export async function fetchStats(cfg: LlamaSwapConfig, modelId: string): Promise { + const query = modelId === "all" ? "" : `?model=${encodeURIComponent(modelId)}`; + const headers: Record = {}; + if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`; + const res = await fetch(`${cfg.baseUrl}/api/metrics/stats${query}`, { + headers, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`stats HTTP ${res.status}`); + return parseStats(await res.json()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/stats.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/stats.ts tests/stats.test.ts +git commit -m "feat: add usage stats parsing and fetching from /api/metrics/stats" +``` + +--- + +### Task 2: StatsCache + +**Files:** +- Create: `src/lib/stats-cache.ts` +- Test: `tests/stats-cache.test.ts` + +**Interfaces:** +- Consumes: `fetchStats`, `UsageStats` from `./stats`; `LlamaSwapConfig` from `./util`. +- Produces: + - `class StatsCache` + - `constructor(cfg: LlamaSwapConfig, fetchFn?: FetchFn, pollMs?: number, throttleMs?: number)` where `FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise` + - `register(key: string): void` + - `unregister(key: string): void` + - `get(key: string): UsageStats | undefined` + - `scheduleRefresh(): void` — throttled (trailing, min `throttleMs` between polls) + - `refresh(): Promise` + - `setConfig(cfg: LlamaSwapConfig): void` + - `onChange(listener: () => void): () => void` + +- [ ] **Step 1: Write the failing test** + +Create `tests/stats-cache.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { StatsCache, type FetchFn } from "../src/lib/stats-cache"; +import { type LlamaSwapConfig } from "../src/lib/util"; + +const cfg: LlamaSwapConfig = { baseUrl: "http://x" }; + +function stubFetch(result: unknown, counter: { count: number }): FetchFn { + return async () => { + counter.count++; + return result as never; + }; +} + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +test("register polls once immediately and caches the value", async () => { + const calls = { count: 0 }; + const cache = new StatsCache(cfg, stubFetch({ totalRequests: 3, totalInputTokens: 1, totalOutputTokens: 2, genP95: 4 }, calls), 1000, 30); + cache.register("all"); + await flush(); + assert.equal(calls.count, 1); + assert.equal(cache.get("all")!.totalRequests, 3); +}); + +test("multiple keys are each polled", async () => { + const keys: string[] = []; + const cache = new StatsCache( + cfg, + async (_c, key) => { + keys.push(key); + return { totalRequests: 1, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }; + }, + 1000, + 30, + ); + cache.register("a"); + cache.register("b"); + await flush(); + assert.deepEqual(keys.sort(), ["a", "b"]); +}); + +test("unregister stops the interval and clears the value", async () => { + const calls = { count: 0 }; + const cache = new StatsCache(cfg, stubFetch({ totalRequests: 1, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, calls), 10, 30); + cache.register("a"); + await flush(); + assert.equal(calls.count, 1); + cache.unregister("a"); + await new Promise((r) => setTimeout(r, 40)); + assert.equal(cache.get("a"), undefined); + assert.equal(calls.count, 1); +}); + +test("scheduleRefresh throttles to one poll per window", async () => { + const calls = { count: 0 }; + const cache = new StatsCache(cfg, stubFetch({ totalRequests: 1, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, calls), 10000, 30); + cache.register("a"); + await flush(); + const before = calls.count; + cache.scheduleRefresh(); + cache.scheduleRefresh(); + cache.scheduleRefresh(); + await flush(); + assert.equal(calls.count, before); + await new Promise((r) => setTimeout(r, 60)); + assert.equal(calls.count, before + 1); + cache.unregister("a"); +}); + +test("fetch failure keeps the last-known value", async () => { + const fail: FetchFn = async () => { + throw new Error("boom"); + }; + const cache = new StatsCache(cfg, fail, 10000, 30); + cache.register("a"); + await flush(); + assert.equal(cache.get("a"), undefined); + cache.unregister("a"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/stats-cache.test.ts` +Expected: FAIL — `Cannot find module '../src/lib/stats-cache'`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/stats-cache.ts`: + +```ts +import { fetchStats, type UsageStats } from "./stats"; +import { type LlamaSwapConfig } from "./util"; + +const POLL_MS = 5000; +const ACTIVITY_THROTTLE_MS = 2000; + +export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise; + +export class StatsCache { + private keys = new Set(); + private values = new Map(); + private listeners = new Set<() => void>(); + private timer?: ReturnType; + private throttleTimer?: ReturnType; + private lastRefresh = 0; + + constructor( + private cfg: LlamaSwapConfig, + private fetchFn: FetchFn = fetchStats, + private pollMs = POLL_MS, + private throttleMs = ACTIVITY_THROTTLE_MS, + ) {} + + setConfig(cfg: LlamaSwapConfig): void { + this.cfg = cfg; + this.values.clear(); + } + + register(key: string): void { + if (this.keys.has(key)) return; + this.keys.add(key); + if (!this.timer) { + void this.refresh(); + this.timer = setInterval(() => void this.refresh(), this.pollMs); + } + } + + unregister(key: string): void { + this.keys.delete(key); + this.values.delete(key); + if (this.keys.size === 0 && this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + get(key: string): UsageStats | undefined { + return this.values.get(key); + } + + scheduleRefresh(): void { + const now = Date.now(); + const wait = Math.max(0, this.throttleMs - (now - this.lastRefresh)); + if (this.throttleTimer) clearTimeout(this.throttleTimer); + this.throttleTimer = setTimeout(() => { + this.throttleTimer = undefined; + void this.refresh(); + }, wait); + } + + async refresh(): Promise { + this.lastRefresh = Date.now(); + for (const key of this.keys) { + try { + const stats = await this.fetchFn(this.cfg, key); + if (stats) this.values.set(key, stats); + } catch { + } + } + this.emit(); + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(): void { + for (const listener of this.listeners) listener(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/stats-cache.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/stats-cache.ts tests/stats-cache.test.ts +git commit -m "feat: add StatsCache for shared per-model usage polling" +``` + +--- + +### Task 3: Decode `activity` SSE events + tracker total + +**Files:** +- Modify: `src/lib/inflight-tracker.ts`, `src/lib/event-feed.ts` +- Test: `tests/event-feed.test.ts`, `tests/inflight-tracker.test.ts` + +**Interfaces:** +- Consumes: existing `FeedEvent` in `inflight-tracker.ts`. +- Produces: + - `FeedEvent` gains `| { type: "activity"; id: number }` + - `InflightTracker.total(): number` + - `decodeEvent` handles `{"type":"activity","data":"{\"id\":817}"}`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/event-feed.test.ts`: + +```ts +test("decodeEvent parses an activity event with an id", () => { + const msg: SseMessage = { + event: "message", + data: JSON.stringify({ type: "activity", data: JSON.stringify({ id: 817 }) }), + }; + const ev = decodeEvent(msg); + assert.deepEqual(ev, { type: "activity", id: 817 }); +}); +``` + +Append to `tests/inflight-tracker.test.ts`: + +```ts +test("total sums in-flight requests across all models", () => { + const tracker = new InflightTracker(); + assert.equal(tracker.total(), 0); + tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "1" }, { model: "B", id: "2" }] }); + tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "3" }] }); + assert.equal(tracker.total(), 3); + tracker.apply({ type: "inflight", operation: "remove", id: "1" }); + assert.equal(tracker.total(), 2); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx tsx --test tests/event-feed.test.ts tests/inflight-tracker.test.ts` +Expected: FAIL — activity event decodes to `null`; `tracker.total is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/inflight-tracker.ts`, change the `FeedEvent` union to: + +```ts +export type FeedEvent = + | { type: "inflight"; operation: "snapshot" | "add"; requests: InflightRequest[] } + | { type: "inflight"; operation: "remove"; id: string } + | { type: "activity"; id: number } + | { type: "modelStatus"; models: ModelState[] }; +``` + +and add a method to `InflightTracker` (after `count`): + +```ts + total(): number { + return this.requests.size; + } +``` + +In `src/lib/event-feed.ts`, inside `decodeEvent`, before the `if (outer.type === "inflight")` block, add: + +```ts + if (outer.type === "activity") { + const id = typeof inner.id === "number" ? inner.id : Number.NaN; + if (Number.isFinite(id)) return { type: "activity", id }; + return null; + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx tsx --test tests/event-feed.test.ts tests/inflight-tracker.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/inflight-tracker.ts src/lib/event-feed.ts tests/event-feed.test.ts tests/inflight-tracker.test.ts +git commit -m "feat: decode activity SSE events and expose tracker total" +``` + +--- + +### Task 4: Runtime integration + +**Files:** +- Modify: `src/lib/runtime.ts` + +**Interfaces:** +- Consumes: `StatsCache` from `./stats-cache`; `UsageStats` from `./stats`; `FeedEvent` (already imported via tracker). +- Produces (public API used by the In-Flight action): + - `runtime.getStats(modelId: string): UsageStats | undefined` + - `runtime.watchStats(modelId: string): () => void` + +- [ ] **Step 1: Modify `src/lib/runtime.ts`** + +Replace the whole file body after the class opening as shown: + +```ts +import { EventFeed } from "./event-feed"; +import { InflightTracker } from "./inflight-tracker"; +import { MetricsPoller } from "./metrics-poller"; +import { StatsCache } from "./stats-cache"; +import { type UsageStats } from "./stats"; +import { type LlamaSwapConfig } from "./util"; + +class Runtime { + readonly tracker = new InflightTracker(); + offline = true; + private cfg?: LlamaSwapConfig; + private feed?: EventFeed; + private poller?: MetricsPoller; + private statsCache?: StatsCache; + private statsUnsub?: () => void; + private listeners = new Set<() => void>(); + + ensureConnections(cfg: LlamaSwapConfig): void { + const changed = !this.cfg || this.cfg.baseUrl !== cfg.baseUrl || this.cfg.apiKey !== cfg.apiKey; + if (changed) { + this.feed?.stop(); + this.poller?.stop(); + this.feed = undefined; + this.poller = undefined; + this.statsUnsub?.(); + this.statsCache = undefined; + this.statsUnsub = undefined; + this.cfg = cfg; + } + if (!this.feed) { + this.feed = new EventFeed(cfg, (ev) => { + this.tracker.apply(ev); + if (ev.type === "activity") this.statsCache?.scheduleRefresh(); + this.emit(); + }); + this.feed.setStatusHandler((connected) => { + this.offline = !connected; + this.emit(); + }); + this.feed.start(); + } + if (!this.poller) { + this.poller = new MetricsPoller(cfg); + this.poller.on(() => this.emit()); + this.poller.start(); + } + if (!this.statsCache) { + this.statsCache = new StatsCache(cfg); + this.statsUnsub = this.statsCache.onChange(() => this.emit()); + } + } + + get pollerInstance(): MetricsPoller | undefined { + return this.poller; + } + + getStats(modelId: string): UsageStats | undefined { + return this.statsCache?.get(modelId); + } + + watchStats(modelId: string): () => void { + this.statsCache?.register(modelId); + return () => this.statsCache?.unregister(modelId); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(): void { + for (const listener of this.listeners) listener(); + } +} + +export const runtime = new Runtime(); +``` + +- [ ] **Step 2: Typecheck and run the full suite** + +Run: `npx tsc --noEmit && npm test` +Expected: `TYPECHECK OK` equivalent (no errors) and all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/runtime.ts +git commit -m "feat: expose usage stats via shared runtime StatsCache" +``` + +--- + +### Task 5: `renderUsage` in render.ts + +**Files:** +- Modify: `src/lib/render.ts` +- Test: `tests/render.test.ts` + +**Interfaces:** +- Consumes: `UsageStats` from `./stats`. +- Produces: + - `interface UsageRenderOptions { modelName: string; stats?: UsageStats; primaryStat: "requests" | "input_tokens" | "output_tokens" | "gen_p95"; offline: boolean }` + - `renderUsage(opts: UsageRenderOptions): string` + - `formatCompact(n: number): string` (exported for tests) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/render.test.ts`: + +```ts +import { renderGpuGraph, renderInflight, renderUsage, svgDataUrl, formatCompact } from "../src/lib/render"; +``` + +(replace the existing import line) and append: + +```ts +test("formatCompact renders integers, k, and M", () => { + assert.equal(formatCompact(0), "0"); + assert.equal(formatCompact(999), "999"); + assert.equal(formatCompact(52100), "52.1k"); + assert.equal(formatCompact(1200000), "1.2M"); +}); + +test("renderUsage shows the primary stat big and the rest small", () => { + const svg = renderUsage({ + modelName: "DeepSeek-V4-Flash-0731", + stats: { totalRequests: 1950, totalInputTokens: 312177540, totalOutputTokens: 889193, genP95: 378.86 }, + primaryStat: "gen_p95", + offline: false, + }); + assert.match(svg, />379 { + const none = renderUsage({ modelName: "A", stats: undefined, primaryStat: "requests", offline: false }); + assert.match(none, />-- { + const svg = renderUsage({ modelName: "all", stats: { totalRequests: 1, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, primaryStat: "requests", offline: false }); + assert.match(svg, /ALL MODELS/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/render.test.ts` +Expected: FAIL — `Cannot find name 'renderUsage'` / `formatCompact`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/render.ts`, add an import of `UsageStats` and append the following at the end of the file (before helper functions or after — helpers can be placed after `renderUsage`): + +```ts +export function formatCompact(n: number): string { + if (!Number.isFinite(n)) return "--"; + if (n < 1000) return `${Math.round(n)}`; + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +export interface UsageRenderOptions { + modelName: string; + stats?: UsageStats; + primaryStat: "requests" | "input_tokens" | "output_tokens" | "gen_p95"; + offline: boolean; +} + +export function renderUsage(opts: UsageRenderOptions): string { + const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName)); + + if (opts.offline) { + return frame("#10131a", [ + centerText("!!", 34, 20, "bold", "#e0e0e0"), + centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"), + centerText(name, 64, 7, "normal", "#ffffff"), + ]); + } + + const s = opts.stats; + const big = s ? formatCompact(primaryValue(s, opts.primaryStat)) : "--"; + const rows = usageRows(s, opts.primaryStat); + const parts: string[] = [ + centerText(name, 10, 7, "normal", "#ffffff"), + centerText(big, 36, 24, "bold", "#ffffff"), + ]; + for (const row of rows) { + parts.push( + `${row.label}`, + `${row.value}`, + ); + } + return frame("#10131a", parts); +} + +function primaryValue(s: UsageStats, primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95"): number { + switch (primary) { + case "requests": + return s.totalRequests; + case "input_tokens": + return s.totalInputTokens; + case "output_tokens": + return s.totalOutputTokens; + case "gen_p95": + return s.genP95; + } +} + +function usageRows( + s: UsageStats | undefined, + primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95", +): { label: string; value: string; y: number }[] { + const items: { label: string; value: string }[] = []; + if (primary !== "requests") items.push({ label: "REQ", value: s ? formatCompact(s.totalRequests) : "--" }); + if (primary !== "input_tokens") items.push({ label: "IN", value: s ? formatCompact(s.totalInputTokens) : "--" }); + if (primary !== "output_tokens") items.push({ label: "OUT", value: s ? formatCompact(s.totalOutputTokens) : "--" }); + if (primary !== "gen_p95") items.push({ label: "P95", value: s ? `${Math.round(s.genP95)} t/s` : "--" }); + return items.map((it, i) => ({ ...it, y: 52 + i * 7 })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/render.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/render.ts tests/render.test.ts +git commit -m "feat: add usage stats key renderer" +``` + +--- + +### Task 6: In-Flight action display modes + property inspector + +**Files:** +- Modify: `src/actions/inflight-monitor.ts`, `com.bryce.llamawatch.sdPlugin/ui/inflight.html`, `src/lib/datasources.ts` + +**Interfaces:** +- Consumes: `renderUsage`, `renderInflight`, `formatCompact` (render); `runtime.getStats`/`watchStats`; `InflightTracker.total()`. +- Produces: + - `InflightSettings` gains `display?: "count" | "usage"` and `primaryStat?: "requests" | "input_tokens" | "output_tokens" | "gen_p95"`. + - `InflightState` gains `unwatchStats?: () => void`. + - Models datasource gains an `all` option. + +- [ ] **Step 1: Update the property inspector** + +In `com.bryce.llamawatch.sdPlugin/ui/inflight.html`, after the API Key item, add: + +```html + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Update the models datasource** + +In `src/lib/datasources.ts`, inside the `event === "models"` branch, change the assignment to prepend the aggregate option: + +```ts + items = [{ label: "All models", value: "all" }, ...models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id }))]; +``` + +- [ ] **Step 3: Update the In-Flight action** + +In `src/actions/inflight-monitor.ts`: + +1. Add to the settings type: + +```ts +type InflightSettings = CfgSettings & { + modelId?: string; + display?: "count" | "usage"; + primaryStat?: "requests" | "input_tokens" | "output_tokens" | "gen_p95"; +}; +``` + +2. Add to `InflightState`: + +```ts + unwatchStats?: () => void; +``` + +3. In `onWillAppear`, after `state.settings = ev.payload.settings;`, replace the sampler setup block: + +```ts + state.unsubscribe = runtime.subscribe(() => this.render(state)); + if (this.displayOf(state) === "usage") { + state.unwatchStats = runtime.watchStats(this.modelKeyOf(state)); + } else { + state.sampler = setInterval(() => this.sample(state), 1000); + } + this.render(state); +``` + +4. In `onWillDisappear`, add cleanup after `state.unsubscribe?.();`: + +```ts + state.unwatchStats?.(); + state.unwatchStats = undefined; +``` + +5. In `onDidReceiveSettings`, after `state.settings = ev.payload.settings;`, add re-registration: + +```ts + state.unwatchStats?.(); + state.unwatchStats = undefined; + if (this.displayOf(state) === "usage") { + state.unwatchStats = runtime.watchStats(this.modelKeyOf(state)); + } else if (!state.sampler) { + state.sampler = setInterval(() => this.sample(state), 1000); + } +``` + +6. Replace `render` with a mode-switching version and add helpers: + +```ts + private displayOf(state: InflightState): "count" | "usage" { + return state.settings.display ?? "count"; + } + + private modelKeyOf(state: InflightState): string { + const modelId = state.settings.modelId ?? ""; + return modelId === "" ? "all" : modelId; + } + + private primaryStatOf(state: InflightState): "requests" | "input_tokens" | "output_tokens" | "gen_p95" { + return state.settings.primaryStat ?? "requests"; + } + + private render(state: InflightState): void { + const action = state.action; + if (!action) return; + if (this.displayOf(state) === "usage") { + const modelKey = this.modelKeyOf(state); + const offline = runtime.offline; + void action.setImage( + svgDataUrl( + renderUsage({ + modelName: modelKey, + stats: runtime.getStats(modelKey), + primaryStat: this.primaryStatOf(state), + offline, + }), + ), + ); + return; + } + + const modelId = state.settings.modelId ?? ""; + const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId); + const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId); + const offline = runtime.offline && modelId.length > 0; + const modelName = modelId === "" ? "unset" : modelId; + void action.setImage( + svgDataUrl( + renderInflight({ + modelName, + state: trackerState, + count, + offline, + history: state.history, + }), + ), + ); + } + + private sample(state: InflightState): void { + const modelId = state.settings.modelId ?? ""; + state.history.push(modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId)); + if (state.history.length > HISTORY_LIMIT) state.history.shift(); + this.render(state); + } +``` + +Note: for `modelId === "all"` in count mode, `trackerState` is forced to `"ready"` so the key renders the total number rather than `NO MODEL`/`OFF`. The label comes from `renderInflight`'s `name` (`ALL MODELS` won't match — see step 4). + +- [ ] **Step 4: Ensure the count-mode "all" label renders correctly** + +In `src/lib/render.ts`, `renderInflight` shortens the model name. For `modelName === "all"` pass the display name through `shorten`. Update the line in `renderInflight`: + +```ts + const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName)); +``` + +(one-line change; mirrors the `renderUsage` approach). + +- [ ] **Step 5: Typecheck, build, and run the suite** + +Run: `npx tsc --noEmit && npm run build && npm test` +Expected: no type errors, build succeeds, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/actions/inflight-monitor.ts src/lib/render.ts src/lib/datasources.ts com.bryce.llamawatch.sdPlugin/ui/inflight.html +git commit -m "feat: add usage-stats display mode to In-Flight Monitor action" +``` + +--- + +### Task 7: GPU `combineSeries` helper + +**Files:** +- Modify: `src/lib/metrics-poller.ts` +- Test: `tests/metrics-poller.test.ts` + +**Interfaces:** +- Consumes: `AGGREGATE` (already defined in `metrics-poller.ts`). +- Produces: `combineSeries(histories: number[][], kind: GpuMetricKind): { value?: number; history: number[] }` (exported). + +- [ ] **Step 1: Write the failing test** + +Append to `tests/metrics-poller.test.ts`: + +```ts +import { combineSeries, MetricsPoller } from "../src/lib/metrics-poller"; +``` + +(update the existing import) and append: + +```ts +test("combineSeries averages util and sums power", () => { + const util = combineSeries( + [ + [10, 20, 30], + [20, 40, 60], + ], + "util_percent", + ); + assert.deepEqual(util, { value: 45, history: [15, 30, 45] }); + + const power = combineSeries( + [ + [100, 200], + [150, 250], + ], + "power", + ); + assert.deepEqual(power, { value: 450, history: [250, 450] }); +}); + +test("combineSeries takes the max temperature and skips gaps", () => { + const temp = combineSeries([[50, 70], [60]], "temperature"); + assert.deepEqual(temp, { value: 70, history: [60, 70] }); + + const empty = combineSeries([[], []], "temperature"); + assert.deepEqual(empty, { value: undefined, history: [] }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test tests/metrics-poller.test.ts` +Expected: FAIL — `Cannot find name 'combineSeries'`. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/metrics-poller.ts`, after the `AGGREGATE` const, add and export: + +```ts +export function combineSeries(histories: number[][], kind: GpuMetricKind): { value?: number; history: number[] } { + const n = Math.max(...histories.map((h) => h.length), 0); + if (n === 0) return { value: undefined, history: [] }; + const history: number[] = []; + for (let i = 0; i < n; i++) { + const at = histories.map((h) => h[h.length - n + i]).filter((v): v is number => v !== undefined); + if (at.length === 0) continue; + history.push(AGGREGATE[kind](at)); + } + return { value: history.length > 0 ? history[history.length - 1] : undefined, history }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test tests/metrics-poller.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics-poller.ts tests/metrics-poller.test.ts +git commit -m "feat: add combineSeries for GPU combination aggregation" +``` + +--- + +### Task 8: GPU action combinations + property inspector + +**Files:** +- Modify: `src/actions/gpu-graph.ts`, `com.bryce.llamawatch.sdPlugin/ui/gpu.html` + +**Interfaces:** +- Consumes: `combineSeries` from `./metrics-poller`. +- Produces: + - `GpuSettings` gains `gpuCombo?: string`. + - When `gpuCombo` is a non-empty comma-separated list of ids, the key shows the combined series of those GPUs, labeled `GPU 0+2`; otherwise existing `gpuId` behavior. + +- [ ] **Step 1: Update the property inspector** + +In `com.bryce.llamawatch.sdPlugin/ui/gpu.html`, after the GPU select item, add: + +```html + + + +``` + +- [ ] **Step 2: Update the GPU action** + +In `src/actions/gpu-graph.ts`: + +1. Add to the settings type: + +```ts +type GpuSettings = CfgSettings & { + gpuId?: string; + metric?: GpuMetricKind; + gpuCombo?: string; +}; +``` + +2. Replace the `render` method and add a `parseCombo` helper: + +```ts + private render(state: GpuState): void { + const action = state.action; + if (!action) return; + const poller = runtime.pollerInstance; + const metric = state.settings.metric ?? "util_percent"; + const combo = parseCombo(state.settings.gpuCombo); + const offline = !poller || poller.isOffline(); + + let value: number | undefined; + let history: number[] = []; + let gpuName: string; + if (combo) { + const known = new Set((poller?.gpus() ?? []).map((g) => g.id)); + const ids = combo.filter((id) => known.has(id)); + const histories = ids.map((id) => poller?.getHistory(id, metric) ?? []); + const combined = combineSeries(histories, metric); + value = combined.value; + history = combined.history; + gpuName = `GPU ${ids.join("+")}`; + } else { + const gpuId = state.settings.gpuId ?? "all"; + value = poller?.getValue(gpuId, metric); + history = poller?.getHistory(gpuId, metric) ?? []; + gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId); + } + + const svg = renderGpuGraph({ gpuName, metric, value, history, offline }); + if (svg === state.lastSvg) return; + state.lastSvg = svg; + void action.setImage(svgDataUrl(svg)); + } +``` + +3. Add the helper at the bottom of the file: + +```ts +function parseCombo(raw: string | undefined): string[] | undefined { + const ids = (raw ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ids.length > 0 ? ids : undefined; +} +``` + +and import `combineSeries`: + +```ts +import { combineSeries, type GpuMetricKind } from "../lib/metrics-poller"; +``` + +- [ ] **Step 3: Typecheck, build, and run the suite** + +Run: `npx tsc --noEmit && npm run build && npm test` +Expected: no type errors, build succeeds, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/actions/gpu-graph.ts com.bryce.llamawatch.sdPlugin/ui/gpu.html +git commit -m "feat: support GPU combinations on the GPU Graph action" +``` + +--- + +### Task 9: Final verification + +- [ ] **Step 1: Full verification** + +Run: +```bash +npm test +npm run build +npx tsc --noEmit +streamdeck validate +``` +Expected: all tests pass, build succeeds, typecheck clean, validation passes. + +- [ ] **Step 2: Visual smoke test** + +Run `npx tsx -e` to render a usage SVG and a combo GPU SVG and confirm they are well-formed (no malformed attributes), e.g.: + +```bash +npx tsx -e ' +import { renderUsage } from "./src/lib/render"; +console.log(renderUsage({ modelName: "all", stats: { totalRequests: 1950, totalInputTokens: 312177540, totalOutputTokens: 889193, genP95: 378.86 }, primaryStat: "gen_p95", offline: false }).slice(0, 300)); +' +``` + +- [ ] **Step 3: Restart the plugin** + +Run: `streamdeck restart com.bryce.llamawatch` +Expected: `✔ Restarted com.bryce.llamawatch`. + +- [ ] **Step 4: Update the progress ledger** + +Append a short note to `.superpowers/sdd/progress.md` summarizing the feature and verification results (one or two lines, matching existing format).