local-lemonade 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -58,9 +58,11 @@ how pi itself works:
58
58
  in `lemonade.json`. Moving the server, changing ports, or tracking upstream
59
59
  API changes is config, not code.
60
60
  - **Live state where it matters.** Pulls stream real progress from
61
- server-owned download jobs with Esc-to-cancel; status shows host resources
62
- and last-request performance; logs stream over websocket. Destructive
63
- actions gate behind confirm dialogs and a typed phrase.
61
+ server-owned download jobs with Esc-to-cancel; the status panel streams the
62
+ full state of the box every metric from five endpoints, grouped by source,
63
+ re-fetched live while open, cyclable across instances with `n`/`p`; logs
64
+ stream over websocket. Destructive actions gate behind confirm dialogs and
65
+ a typed phrase.
64
66
  - **Failures speak.** Missing models produce errors naming what to pull — the
65
67
  agent relays and recovers. Never a silent failure.
66
68
  - **One entry point.** A single command — `/lemonade-setup` — every capability
@@ -220,7 +222,7 @@ A navigable TUI (↑↓ / enter / esc):
220
222
 
221
223
  | Menu | What you can do |
222
224
  |---|---|
223
- | **Server status** | Live health: version, loaded models with type/device/pinned/context, per-type slot limits, websocket port plus host resources (CPU/RAM/GPU/VRAM) and last-request performance (tok/s, time-to-first-token) |
225
+ | **Server status** | The complete state of the box, grouped by the endpoint each metric came from — every server metric flat on one **live** panel that re-fetches every `statusPollMs` (default 2 s) while open, with each snapshot timestamped in the header; a transient fetch failure keeps the last good snapshot. Scrollable (wrap-aware `↑↓/j/k/pgup/pgdn/home/end` scrolling + mouse wheel, `q`/`esc`/`enter` return; position indicator in the footer). With multiple instances configured, `n`/`p` cycles between boxes without leaving the view — the title names the instance shown, and the menu's active instance follows it on exit. Sections: `/v1/health` (loaded models with busy/streaming/backend-health/pid/slot-pool, per-type slot limits, pinned counts, telemetry state), `/v1/system-stats` (CPU/RAM used-total-%, GPU, VRAM, NPU), `/v1/stats` (last-request tok/s, TTFT, in/out tokens, prefix-cache hit rate, lifetime token/request counters, routing decisions), `/v1/system-info` (CPU cores/threads, OS, GPU/NPU inventory incl. NPU power mode and TOPS, model-storage drive usage), and `/metrics` (llama.cpp backend: request queue depth, busy slots per decode, peak sequence length). Each section degrades to an "(unreachable)" line independently — one dead endpoint never blanks the panel |
224
226
  | **Server settings** | Discover servers (UDP beacon + HTTP fallback), edit base URL, edit API key, test connection |
225
227
  | **Model management** | List catalog (including not-yet-downloaded registry entries), load / unload, pull (download) with live progress + esc-to-cancel, install from Hugging Face (search → pick variant → install as `user.*`), delete, change context size (unload → reload with `ctx_size`, saved), toggle chat-only filter, refresh provider |
226
228
  | **Transcription settings** | Default Whisper model, endpoint path, smoke-test a file |
@@ -327,7 +329,8 @@ The full shape (values shown are the built-in defaults):
327
329
  "loadTimeoutMs": 300000,
328
330
  "pullTimeoutMs": 1800000,
329
331
  "transcriptionTimeoutMs": 300000,
330
- "generationTimeoutMs": 600000
332
+ "generationTimeoutMs": 600000,
333
+ "statusPollMs": 2000
331
334
  }
332
335
  ```
333
336
 
@@ -340,6 +343,7 @@ The full shape (values shown are the built-in defaults):
340
343
  | `defaultTranscriptionModel` / `defaultImageModel` / `defaultUpscaleModel` / `defaultClassifierModel` | Fixed model ids for the tools. Empty = auto-pick from the live catalog (by label, or by `onnxruntime` recipe for classifiers). |
341
344
  | `outputDir` | Where generated files (images, audio, meshes) are saved. Empty = agent's working directory. |
342
345
  | `*TimeoutMs` | Per-operation timeouts. `pullTimeoutMs` only caps the *blocking* pull fallback — normal pulls use download jobs with live progress. |
346
+ | `statusPollMs` | How often the setup-menu status panel re-fetches while open (live refresh). `0` = static snapshot. |
343
347
 
344
348
  ## Offline & unreachable networks
345
349
 
@@ -433,10 +437,11 @@ rename — the cost of a perfectly uniform namespace.
433
437
  instances by name and URL every session, so virgin chats know the fleet
434
438
  without hunting.
435
439
  - **Setup menu:** a *Switch instance* item (appears when extras exist) picks
436
- which box Status, Model management, and Live logs act on; Server settings
437
- *Manage instances* lists (with live reachability), adds, and removes
438
- instances; discovered servers can be registered as default or as a named
439
- instance.
440
+ which box Status, Model management, and Live logs act on and the status
441
+ panel itself cycles instances in-place with `n`/`p`, syncing the menu's
442
+ active instance on exit; Server settings *Manage instances* lists (with
443
+ live reachability), adds, and removes instances; discovered servers can be
444
+ registered as default or as a named instance.
440
445
 
441
446
  **The one-place rule:** instance semantics are defined exactly once —
442
447
  `instanceView()` returns a *config view* (same settings, swapped
@@ -484,6 +489,18 @@ Empirically confirmed against lemonade 11.7.0 (cross-checked with the
484
489
  chat model.
485
490
  - **change-ctx.** Implemented as unload → reload with `ctx_size` +
486
491
  `save_options: true`, which persists the choice for future loads.
492
+ - **Status panel sources.** All five metric endpoints are live-confirmed:
493
+ `/v1/health` (incl. per-model `is_busy`/`is_streaming`/`backend_health`/
494
+ `slot_pool`/`residency_class`/`last_use`/`pid` and `pinned_models` counters),
495
+ `/v1/system-stats`, `/v1/stats` (cumulative `*_total` counters + per-request
496
+ prefix-cache hits via `cache_tokens`), `/v1/system-info` (hardware inventory
497
+ + `model_storage` drive usage), and root-level `/metrics` (Prometheus text;
498
+ `lemonade_llamacpp_*` backend gauges incl. `requests_processing`/
499
+ `requests_deferred` queue depth and `n_tokens_max` peak sequence length).
500
+ Caveats seen in the field: the Linux `amd_gpu` device `name` can come back as
501
+ a raw device id (the panel falls back to `family`), and `/metrics` requires
502
+ the bearer key when `LEMONADE_API_KEY` is set. Power draw in watts is *not*
503
+ exposed by any lemonade endpoint — utilization only (`*_percent`).
487
504
 
488
505
  ## Troubleshooting
489
506
 
@@ -496,6 +513,8 @@ Empirically confirmed against lemonade 11.7.0 (cross-checked with the
496
513
  | Discovery finds nothing | Expected inside WSL2 (UDP broadcasts don't cross the NAT); set the URL manually |
497
514
  | Server unreachable at startup | The instance registers nothing — no lemonade models in `/model` until it's reachable; fix the URL and Refresh |
498
515
  | Pull seems to hang | It's downloading — the blocking pull path sends no progress. Large models take minutes; the `pullTimeoutMs` cap guarantees control returns |
516
+ | Status panel shows "(unreachable)" for some sections | That one endpoint failed for this box — `/metrics` in particular requires the API key when the server sets `LEMONADE_API_KEY`. The rest of the panel stays live |
517
+ | Status panel never refreshes | `statusPollMs` is `0` in the config — that disables live refresh. Set it to a poll interval in ms (default 2000) |
499
518
 
500
519
  ## MCP Gateway (lemonade as an MCP server)
501
520
 
package/index.ts CHANGED
@@ -41,7 +41,7 @@ import dgram from "node:dgram";
41
41
  import { spawn } from "node:child_process";
42
42
  import { Type } from "typebox";
43
43
  import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
44
- import { Container, Key, matchesKey, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
44
+ import { Container, Key, matchesKey, type SelectItem, SelectList, Text, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
45
45
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
46
46
 
47
47
  // ─── Configuration ──────────────────────────────────────────────────────────
@@ -79,6 +79,7 @@ interface LemonadeConfig {
79
79
  defaultUpscaleModel: string;
80
80
  defaultClassifierModel: string; // "" = auto-pick first classification model
81
81
  outputDir: string; // "" = process cwd
82
+ statusPollMs: number; // status-panel live-refresh interval while viewing
82
83
  discoveryTimeoutMs: number;
83
84
  beaconTimeoutMs: number;
84
85
  loadTimeoutMs: number;
@@ -132,6 +133,7 @@ const DEFAULT_CONFIG: LemonadeConfig = {
132
133
  defaultUpscaleModel: "RealESRGAN-x4plus",
133
134
  defaultClassifierModel: "",
134
135
  outputDir: "",
136
+ statusPollMs: 2000,
135
137
  discoveryTimeoutMs: 5000,
136
138
  beaconTimeoutMs: 3000,
137
139
  loadTimeoutMs: 300_000,
@@ -290,6 +292,17 @@ interface LoadedModelInfo {
290
292
  device?: string;
291
293
  pinned?: boolean;
292
294
  max_context_window?: number;
295
+ checkpoint?: string;
296
+ recipe?: string;
297
+ is_busy?: boolean;
298
+ is_streaming?: boolean;
299
+ backend_health?: string;
300
+ backend_alive?: boolean;
301
+ pid?: number;
302
+ last_use?: number;
303
+ slot_pool?: string;
304
+ residency_class?: string;
305
+ watchdog_reset?: boolean;
293
306
  }
294
307
 
295
308
  interface LemonadeHealth {
@@ -299,6 +312,9 @@ interface LemonadeHealth {
299
312
  all_models_loaded?: LoadedModelInfo[];
300
313
  websocket_port?: number;
301
314
  max_models?: Record<string, number>;
315
+ pinned_models?: Record<string, number>;
316
+ pinned_helper_models?: Record<string, number>;
317
+ telemetry?: { enabled?: boolean; captures?: string[] };
302
318
  }
303
319
 
304
320
  interface SystemStats {
@@ -314,7 +330,40 @@ interface PerfStats {
314
330
  tokens_per_second?: number;
315
331
  input_tokens?: number;
316
332
  output_tokens?: number;
333
+ prompt_tokens?: number | null;
334
+ cache_tokens?: number | null;
317
335
  request_count_total?: number;
336
+ input_tokens_total?: number;
337
+ output_tokens_total?: number;
338
+ prompt_tokens_total?: number;
339
+ cache_tokens_total?: number;
340
+ routing_decisions_total?: number;
341
+ routing_switches_total?: number;
342
+ }
343
+
344
+ /** GPU device entry — shared shape (amd_gpu carries virtual_mem_gb, nvidia_gpu carries vram_gb). */
345
+ interface GpuInfo {
346
+ name?: string;
347
+ family?: string;
348
+ integrated?: boolean;
349
+ virtual_mem_gb?: number;
350
+ vram_gb?: number;
351
+ available?: boolean;
352
+ }
353
+
354
+ /** Hardware inventory from GET /v1/system-info (fields are platform-dependent). */
355
+ interface SystemInfo {
356
+ "OS Version"?: string;
357
+ Processor?: string;
358
+ "Physical Memory"?: string;
359
+ "Windows Power Setting"?: string;
360
+ model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number };
361
+ devices?: {
362
+ cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
363
+ amd_gpu?: GpuInfo[];
364
+ nvidia_gpu?: GpuInfo[];
365
+ amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
366
+ };
318
367
  }
319
368
 
320
369
  interface DiscoveredServer {
@@ -401,6 +450,31 @@ async function fetchJsonOrNull<T>(config: LemonadeConfig, p: string): Promise<T
401
450
  }
402
451
  }
403
452
 
453
+ /** Fetch a plain-text endpoint (e.g. Prometheus /metrics); null when unreachable. */
454
+ async function fetchTextOrNull(config: LemonadeConfig, p: string): Promise<string | null> {
455
+ try {
456
+ const res = await fetch(url(config, p), {
457
+ headers: authHeaders(config),
458
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
459
+ });
460
+ return res.ok ? await res.text() : null;
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
465
+
466
+ /** Parse Prometheus text exposition into a name → value map (last sample wins). */
467
+ function parsePrometheus(body: string): Map<string, number> {
468
+ const values = new Map<string, number>();
469
+ for (const raw of body.split("\n")) {
470
+ const line = raw.trim();
471
+ if (!line || line.startsWith("#")) continue;
472
+ const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(-?[\d.]+(?:[eE][+-]?\d+)?)/);
473
+ if (m) values.set(m[1], parseFloat(m[2]));
474
+ }
475
+ return values;
476
+ }
477
+
404
478
  async function fetchHealth(config: LemonadeConfig): Promise<LemonadeHealth | null> {
405
479
  return fetchJsonOrNull<LemonadeHealth>(config, config.healthPath);
406
480
  }
@@ -1299,19 +1373,226 @@ function menu<T extends string>(ctx: UiCtx, title: string, items: SelectItem<T>[
1299
1373
  });
1300
1374
  }
1301
1375
 
1302
- /** Full-screen framed text view (status output); any key returns. */
1303
- async function textView(ctx: UiCtx, title: string, lines: string[]): Promise<void> {
1376
+ /** Live-refresh wiring for the scrollable textView (status panel). */
1377
+ interface TextViewLive {
1378
+ /** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
1379
+ pollMs?: number;
1380
+ refetch?: () => Promise<string[]>;
1381
+ /** Extra footer key hint (e.g. "n/p instance"), shown when multiple targets exist. */
1382
+ extraHint?: string;
1383
+ /** Hook for keys beyond scroll/quit; return true when the key was consumed. */
1384
+ onKey?: (data: string, api: { setTitle: (t: string) => void }) => boolean;
1385
+ }
1386
+
1387
+ /** Full-screen framed text view; any key returns. `scrollable` adds keyboard
1388
+ * paging; `live` adds polling + a key hook (scrollable mode only). */
1389
+ async function textView(
1390
+ ctx: UiCtx,
1391
+ title: string,
1392
+ lines: string[],
1393
+ scrollable = false,
1394
+ live?: TextViewLive
1395
+ ): Promise<void> {
1304
1396
  await ctx.ui.custom<null>((tui, theme, _kb, done) => {
1305
1397
  const container = new Container();
1306
1398
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1307
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
1308
- for (const line of lines) container.addChild(new Text(line, 0, 0));
1309
- container.addChild(new Text(theme.fg("dim", "press any key to return"), 1, 0));
1399
+ const titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
1400
+ container.addChild(titleText);
1401
+ if (!scrollable) {
1402
+ for (const line of lines) container.addChild(new Text(line, 0, 0));
1403
+ const footer = new Text(theme.fg("dim", "press any key to return"), 1, 0);
1404
+ container.addChild(footer);
1405
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1406
+ return {
1407
+ render: (w) => container.render(w),
1408
+ invalidate: () => container.invalidate(),
1409
+ handleInput: () => { done(null); tui.requestRender(); },
1410
+ };
1411
+ }
1412
+
1413
+ // Scrollable: render only a terminal-height window of the lines, with a
1414
+ // live position indicator. The main screen has no layout engine for this
1415
+ // custom view, so ScrollView is inert here — the window is sliced by hand.
1416
+ // The slice is wrap-aware: lines longer than the terminal width occupy
1417
+ // more than one rendered row, so the window is filled by *rows*, not by
1418
+ // line count (otherwise long lines push the footer off-screen on narrow
1419
+ // terminals and scrolling can stop short of the real end).
1420
+ const body = new Text("", 0, 0);
1421
+ container.addChild(body);
1422
+ const footer = new Text("", 1, 0);
1423
+ container.addChild(footer);
1310
1424
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1425
+
1426
+ let cur = lines; // swapped in place by live refetches
1427
+
1428
+ // top border + title + footer + bottom border = 4 lines; keep one spare
1429
+ // row so word-wrap loss (wrap estimates are a lower bound) never pushes
1430
+ // the bottom border off-screen.
1431
+ const overhead = 5;
1432
+ const viewport = () => Math.max(3, tui.terminal.rows - overhead);
1433
+ const rowsOf = (l: string) => Math.max(1, Math.ceil(l.length / Math.max(10, tui.terminal.columns)));
1434
+
1435
+ // Row offset of each line's first rendered row; offsets[cur.length] = total rows.
1436
+ const rowOffsets = (): number[] => {
1437
+ const off: number[] = [];
1438
+ let acc = 0;
1439
+ for (const l of cur) {
1440
+ off.push(acc);
1441
+ acc += rowsOf(l);
1442
+ }
1443
+ off.push(acc);
1444
+ return off;
1445
+ };
1446
+
1447
+ let top = 0;
1448
+ /** Largest line index that still leaves a full viewport of rows below. */
1449
+ const maxTop = () => {
1450
+ const off = rowOffsets();
1451
+ const vh = viewport();
1452
+ let i = cur.length - 1;
1453
+ while (i > 0 && off[cur.length] - off[i] < vh) i--;
1454
+ return i;
1455
+ };
1456
+ /** Line index `byRows` rendered rows down/up from `top`. */
1457
+ const advance = (byRows: number): number => {
1458
+ const off = rowOffsets();
1459
+ const target = off[top] + byRows;
1460
+ let i = top;
1461
+ while (i < cur.length - 1 && off[i] < target) i++;
1462
+ return i;
1463
+ };
1464
+ const retreat = (byRows: number): number => {
1465
+ const off = rowOffsets();
1466
+ const target = Math.max(0, off[top] - byRows);
1467
+ let i = top;
1468
+ while (i > 0 && off[i] > target) i--;
1469
+ return i;
1470
+ };
1471
+
1472
+ const refresh = () => {
1473
+ const off = rowOffsets();
1474
+ const vh = viewport();
1475
+ top = Math.min(top, maxTop());
1476
+ const slice: string[] = [];
1477
+ let used = 0;
1478
+ let j = top;
1479
+ while (j < cur.length) {
1480
+ const r = rowsOf(cur[j]);
1481
+ if (used + r > vh && slice.length) break; // always show at least the top line
1482
+ used += r;
1483
+ slice.push(cur[j]);
1484
+ j++;
1485
+ }
1486
+ body.setText(slice.join("\n"));
1487
+ // Hint ladder: the full hint (scroll keys + instance keys + quit keys +
1488
+ // position) is ~80 chars and wraps on 80-col terminals, so progressively
1489
+ // shorter variants are tried until one fits the usable width. Keys are
1490
+ // unaffected — the hint is best-effort documentation.
1491
+ const base = [...(live?.extraHint ? [live.extraHint] : []), "q/esc/enter return"].join(" · ");
1492
+ const pos = `lines ${top + 1}–${top + slice.length} of ${cur.length}`;
1493
+ const scrollableNow = off[cur.length] > vh;
1494
+ const candidates = scrollableNow
1495
+ ? [
1496
+ `↑↓/jk/pgup·pgdn/home/end · ${base} · ${pos}`,
1497
+ `↑↓/jk/pgup·pgdn · ${base} · ${pos}`,
1498
+ `↑↓/jk · ${base}`,
1499
+ base,
1500
+ ]
1501
+ : [base, "q/esc return"];
1502
+ const budget = Math.max(10, tui.terminal.columns) - 2; // Text paddingX 1 each side
1503
+ const hint = candidates.find((c) => c.length <= budget) ?? candidates[candidates.length - 1];
1504
+ footer.setText(theme.fg("dim", hint));
1505
+ };
1506
+ refresh();
1507
+
1508
+ // Live polling: swap in fresh lines on each tick. Transient fetch failures
1509
+ // keep the last good snapshot on screen; the timer dies with the view.
1510
+ // Ticks coalesce with a trailing edge: a tick requested while one is in
1511
+ // flight (fast n/p cycling) runs once after it, with the latest target.
1512
+ let inFlight = false;
1513
+ let pending = false;
1514
+ const tick = async (): Promise<void> => {
1515
+ if (!live?.refetch) return;
1516
+ if (inFlight) {
1517
+ pending = true;
1518
+ return;
1519
+ }
1520
+ inFlight = true;
1521
+ try {
1522
+ const next = await live.refetch();
1523
+ if (next && next.length) {
1524
+ cur = next;
1525
+ refresh();
1526
+ tui.requestRender();
1527
+ }
1528
+ } catch {
1529
+ /* keep showing the last good snapshot */
1530
+ } finally {
1531
+ inFlight = false;
1532
+ if (pending) {
1533
+ pending = false;
1534
+ void tick();
1535
+ }
1536
+ }
1537
+ };
1538
+ let timer: ReturnType<typeof setInterval> | undefined;
1539
+ if (live?.refetch) timer = setInterval(() => void tick(), Math.max(250, live.pollMs ?? 2000));
1540
+ const cleanup = () => {
1541
+ if (timer !== undefined) {
1542
+ clearInterval(timer);
1543
+ timer = undefined;
1544
+ }
1545
+ };
1546
+
1311
1547
  return {
1312
1548
  render: (w) => container.render(w),
1313
1549
  invalidate: () => container.invalidate(),
1314
- handleInput: () => { done(null); tui.requestRender(); },
1550
+ handleInput: (data) => {
1551
+ // Letters have no Key.* members (Key only covers special/symbol keys);
1552
+ // "j"/"k"/"q" are valid bare KeyIds. Never let an input error become an
1553
+ // uncaught exception — it would take down the whole pi process.
1554
+ try {
1555
+ const page = () => Math.max(1, viewport() - 1);
1556
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) top = retreat(1);
1557
+ else if (matchesKey(data, Key.down) || matchesKey(data, "j")) top = Math.min(maxTop(), advance(1));
1558
+ else if (matchesKey(data, Key.pageUp)) top = retreat(page());
1559
+ else if (matchesKey(data, Key.pageDown)) top = Math.min(maxTop(), advance(page()));
1560
+ else if (matchesKey(data, Key.home)) top = 0;
1561
+ else if (matchesKey(data, Key.end)) top = maxTop();
1562
+ else if (
1563
+ matchesKey(data, Key.escape) ||
1564
+ matchesKey(data, Key.enter) ||
1565
+ matchesKey(data, "q")
1566
+ ) {
1567
+ cleanup();
1568
+ done(null);
1569
+ tui.requestRender();
1570
+ return;
1571
+ } else if (
1572
+ live?.onKey &&
1573
+ live.onKey(data, {
1574
+ setTitle: (t: string) => titleText.setText(theme.fg("accent", theme.bold(t))),
1575
+ })
1576
+ ) {
1577
+ void tick(); // target changed — refresh immediately, poll continues on it
1578
+ }
1579
+ refresh();
1580
+ tui.requestRender();
1581
+ } catch {
1582
+ // A failed key match must not kill the process — swallow and continue.
1583
+ }
1584
+ },
1585
+ dispose: cleanup,
1586
+ handleMouse: (event: TuiMouseEvent): TuiMouseEventResult | undefined => {
1587
+ if (event.type !== "wheel" || !event.wheelDelta) return undefined;
1588
+ top =
1589
+ event.wheelDelta > 0
1590
+ ? Math.min(maxTop(), advance(event.wheelDelta))
1591
+ : retreat(-event.wheelDelta);
1592
+ refresh();
1593
+ tui.requestRender();
1594
+ return { handled: true };
1595
+ },
1315
1596
  };
1316
1597
  });
1317
1598
  }
@@ -1481,7 +1762,49 @@ async function liveLogsView(ctx: UiCtx, config: LemonadeConfig): Promise<void> {
1481
1762
  });
1482
1763
  }
1483
1764
 
1484
- function formatHealth(config: LemonadeConfig, health: LemonadeHealth | null): string[] {
1765
+ /** Format a metric family section title, e.g. "── /v1/stats ────────────". */
1766
+ function sectionTitle(name: string): string {
1767
+ const label = ` ${name} `;
1768
+ return `──${label}${"─".repeat(Math.max(2, 44 - label.length))}`;
1769
+ }
1770
+
1771
+ /** Compact token counts: 15,231,250 → "15.2M", 243,182 stays locale-grouped. */
1772
+ function fmtTokens(n: number): string {
1773
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
1774
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1775
+ return Math.round(n).toLocaleString("en-US");
1776
+ }
1777
+
1778
+ /** Byte counts → human-readable storage sizes. */
1779
+ function fmtBytes(n: number): string {
1780
+ if (n >= 1e12) return `${(n / 1e12).toFixed(1)} TB`;
1781
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`;
1782
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;
1783
+ return `${Math.round(n / 1e3)} kB`;
1784
+ }
1785
+
1786
+ /** Parse "125.07 GB"-style totals from system-info into a GiB number. */
1787
+ function parseGbTotal(s?: string): number | null {
1788
+ if (!s) return null;
1789
+ const m = s.match(/([\d.]+)\s*GB/i);
1790
+ return m ? parseFloat(m[1]) : null;
1791
+ }
1792
+
1793
+ /**
1794
+ * Full status panel: every metric family the server exposes, grouped under
1795
+ * a title naming the endpoint it came from. Each section degrades to an
1796
+ * "(unreachable)" line when its endpoint fails — one dead endpoint never
1797
+ * blanks the rest of the panel.
1798
+ */
1799
+ function formatStatus(
1800
+ config: LemonadeConfig,
1801
+ health: LemonadeHealth | null,
1802
+ sys: SystemStats | null,
1803
+ perf: PerfStats | null,
1804
+ sysinfo: SystemInfo | null,
1805
+ metrics: Map<string, number> | null,
1806
+ updated?: Date
1807
+ ): string[] {
1485
1808
  if (!health) {
1486
1809
  return [
1487
1810
  `Unreachable: ${url(config, config.healthPath)}`,
@@ -1490,55 +1813,199 @@ function formatHealth(config: LemonadeConfig, health: LemonadeHealth | null): st
1490
1813
  "under Server settings (or run Discover servers).",
1491
1814
  ];
1492
1815
  }
1493
- const lines = [
1494
- `Status: ${health.status ?? "?"} Version: ${health.version ?? "?"}`,
1816
+
1817
+ const lines: string[] = [];
1818
+ const telemetry = health.telemetry?.enabled
1819
+ ? `on${health.telemetry.captures?.length ? ` (${health.telemetry.captures.join(", ")})` : ""}`
1820
+ : "off";
1821
+
1822
+ // Header
1823
+ lines.push(
1824
+ `Status: ${health.status ?? "?"} Lemonade v${health.version ?? "?"} Telemetry: ${telemetry}` +
1825
+ (updated ? ` · updated ${updated.toTimeString().slice(0, 8)}` : ""),
1495
1826
  `Endpoint: ${config.baseUrl} WebSocket port: ${health.websocket_port ?? "?"}`,
1496
- "",
1497
- `Loaded models (${health.all_models_loaded?.length ?? 0}):`,
1498
- ];
1827
+ ""
1828
+ );
1829
+
1830
+ // ── /v1/health ───────────────────────────────────────────────────────
1831
+ lines.push(sectionTitle(config.healthPath));
1832
+ lines.push(`Loaded models (${health.all_models_loaded?.length ?? 0}):`);
1499
1833
  for (const m of health.all_models_loaded ?? []) {
1500
1834
  lines.push(
1501
1835
  ` • ${m.model_name} — ${m.type}, ${m.status}, ${m.device ?? "?"}${m.pinned ? ", pinned" : ""}` +
1502
1836
  `, ctx ${m.max_context_window ?? "?"}`
1503
1837
  );
1838
+ const live: string[] = [];
1839
+ live.push(m.is_busy ? "busy" : "idle");
1840
+ if (m.is_streaming) live.push("streaming");
1841
+ live.push(
1842
+ `backend ${m.backend_health ?? "?"}${m.backend_alive === false ? " (backend not responding!)" : ""}`
1843
+ );
1844
+ if (m.watchdog_reset) live.push("watchdog reset");
1845
+ lines.push(` ${live.join(" ")}`);
1846
+ const meta: string[] = [];
1847
+ if (m.recipe) meta.push(m.recipe);
1848
+ if (m.checkpoint) meta.push(m.checkpoint);
1849
+ if (m.slot_pool) meta.push(`pool ${m.slot_pool}`);
1850
+ if (m.residency_class) meta.push(m.residency_class);
1851
+ if (typeof m.pid === "number") meta.push(`pid ${m.pid}`);
1852
+ if (typeof m.last_use === "number") meta.push(`last use ${m.last_use}`);
1853
+ if (meta.length) lines.push(` ${meta.join(" ")}`);
1504
1854
  }
1505
1855
  if (!health.all_models_loaded?.length) lines.push(" (none)");
1506
1856
  const slots = Object.entries(health.max_models ?? {})
1507
1857
  .map(([k, v]) => `${k}:${v}`)
1508
1858
  .join(" ");
1509
- if (slots) lines.push("", `Slots: ${slots}`);
1510
- return lines;
1511
- }
1859
+ if (slots) lines.push(`Slot limits: ${slots}`);
1860
+ const pinned = Object.entries(health.pinned_models ?? {})
1861
+ .filter(([, v]) => v > 0)
1862
+ .map(([k, v]) => `${k}:${v}`)
1863
+ .join(" ");
1864
+ const helpers = Object.values(health.pinned_helper_models ?? {}).reduce((a, b) => a + b, 0);
1865
+ if (pinned) lines.push(`Pinned: ${pinned}${helpers ? ` (+${helpers} pinned helper${helpers > 1 ? "s" : ""})` : ""}`);
1866
+ lines.push("");
1512
1867
 
1513
- function formatStatus(
1514
- config: LemonadeConfig,
1515
- health: LemonadeHealth | null,
1516
- sys: SystemStats | null,
1517
- perf: PerfStats | null
1518
- ): string[] {
1519
- const lines = formatHealth(config, health);
1868
+ // ── /v1/system-stats ─────────────────────────────────────────────────
1869
+ lines.push(sectionTitle("/v1/system-stats"));
1520
1870
  if (sys) {
1521
1871
  const pct = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(0)}%` : "?");
1522
1872
  const gb = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(1)} GB` : "?");
1873
+ const totalRam = parseGbTotal(sysinfo?.["Physical Memory"]);
1874
+ const ramPart =
1875
+ typeof sys.memory_gb === "number"
1876
+ ? totalRam
1877
+ ? `RAM ${sys.memory_gb.toFixed(1)}/${totalRam.toFixed(1)} GB (${((sys.memory_gb / totalRam) * 100).toFixed(0)}%)`
1878
+ : `RAM ${sys.memory_gb.toFixed(1)} GB`
1879
+ : "RAM ?";
1523
1880
  lines.push(
1524
- "",
1525
- `Host: CPU ${pct(sys.cpu_percent)} RAM ${gb(sys.memory_gb)} GPU ${pct(sys.gpu_percent)} VRAM ${gb(sys.vram_gb)}` +
1526
- (sys.npu_percent !== undefined && sys.npu_percent !== null ? ` NPU ${pct(sys.npu_percent)}` : "")
1881
+ `CPU ${pct(sys.cpu_percent)} ${ramPart} GPU ${pct(sys.gpu_percent)} VRAM ${gb(sys.vram_gb)}` +
1882
+ (sys.npu_percent !== undefined && sys.npu_percent !== null ? ` NPU ${pct(sys.npu_percent)}` : "")
1527
1883
  );
1884
+ } else {
1885
+ lines.push(" (unreachable)");
1528
1886
  }
1529
- if (perf && typeof perf.tokens_per_second === "number") {
1530
- lines.push(
1531
- "",
1532
- `Last request: ${perf.tokens_per_second.toFixed(1)} tok/s, TTFT ${
1533
- typeof perf.time_to_first_token === "number" ? perf.time_to_first_token.toFixed(2) : "?"
1534
- }s` +
1535
- ` (in ${perf.input_tokens ?? "?"} / out ${perf.output_tokens ?? "?"} tokens)` +
1536
- (typeof perf.request_count_total === "number" ? ` lifetime requests: ${perf.request_count_total}` : "")
1537
- );
1887
+ lines.push("");
1888
+
1889
+ // ── /v1/stats ─────────────────────────────────────────────────────────
1890
+ lines.push(sectionTitle("/v1/stats"));
1891
+ if (perf) {
1892
+ if (typeof perf.tokens_per_second === "number") {
1893
+ const num = (v: number | null | undefined, digits = 0) =>
1894
+ typeof v === "number" ? v.toLocaleString("en-US", { maximumFractionDigits: digits }) : "?";
1895
+ lines.push(
1896
+ `Last request: ${perf.tokens_per_second.toFixed(1)} tok/s, TTFT ${
1897
+ typeof perf.time_to_first_token === "number" ? perf.time_to_first_token.toFixed(2) : "?"
1898
+ }s (in ${num(perf.input_tokens)} / out ${num(perf.output_tokens)} tokens)`
1899
+ );
1900
+ if (typeof perf.prompt_tokens === "number") {
1901
+ const cachePart =
1902
+ typeof perf.cache_tokens === "number"
1903
+ ? perf.prompt_tokens > 0
1904
+ ? `, ${num(perf.cache_tokens)} from prefix cache (${((perf.cache_tokens / perf.prompt_tokens) * 100).toFixed(1)}%)`
1905
+ : `, ${num(perf.cache_tokens)} from prefix cache`
1906
+ : "";
1907
+ lines.push(` prompt ${num(perf.prompt_tokens)} tokens${cachePart}`);
1908
+ }
1909
+ const lifetime: string[] = [];
1910
+ if (typeof perf.request_count_total === "number") lifetime.push(`${num(perf.request_count_total)} requests`);
1911
+ if (typeof perf.input_tokens_total === "number") lifetime.push(`in ${fmtTokens(perf.input_tokens_total)}`);
1912
+ if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
1913
+ if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
1914
+ if (typeof perf.cache_tokens_total === "number") lifetime.push(`cache ${fmtTokens(perf.cache_tokens_total)}`);
1915
+ if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
1916
+ if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
1917
+ lines.push(
1918
+ `Routing: ${num(perf.routing_decisions_total)} decisions, ${num(perf.routing_switches_total)} switches`
1919
+ );
1920
+ }
1921
+ } else {
1922
+ lines.push("No requests served since server start.");
1923
+ }
1924
+ } else {
1925
+ lines.push(" (unreachable)");
1538
1926
  }
1927
+ lines.push("");
1928
+
1929
+ // ── /v1/system-info ──────────────────────────────────────────────────
1930
+ lines.push(sectionTitle("/v1/system-info"));
1931
+ if (sysinfo) {
1932
+ const cpu = sysinfo.devices?.cpu;
1933
+ if (sysinfo.Processor || cpu?.cores) {
1934
+ const coresPart = cpu?.cores ? ` (${cpu.cores}c/${cpu.threads ?? "?"}t)` : "";
1935
+ lines.push(`CPU: ${sysinfo.Processor ?? cpu?.name ?? "?"}${coresPart}`);
1936
+ }
1937
+ if (sysinfo["OS Version"]) lines.push(`OS: ${sysinfo["OS Version"]}`);
1938
+ const gpu = sysinfo.devices?.amd_gpu?.find((g) => g.available !== false) ?? sysinfo.devices?.nvidia_gpu?.find((g) => g.available !== false);
1939
+ if (gpu) {
1940
+ // On Linux the amd_gpu "name" can be a raw device id (e.g. "110501");
1941
+ // fall back to the family in that case.
1942
+ const gpuName = gpu.name && !/^\d+$/.test(gpu.name) ? gpu.name : gpu.family || "?";
1943
+ const gpuParts: string[] = [];
1944
+ if (typeof gpu.integrated === "boolean") gpuParts.push(gpu.integrated ? "integrated" : "discrete");
1945
+ if (gpu.family && gpuName !== gpu.family) gpuParts.push(gpu.family);
1946
+ if (typeof gpu.virtual_mem_gb === "number") gpuParts.push(`virtual mem ${gpu.virtual_mem_gb.toFixed(1)} GB`);
1947
+ else if (typeof gpu.vram_gb === "number" && gpu.vram_gb > 0) gpuParts.push(`VRAM ${gpu.vram_gb.toFixed(1)} GB`);
1948
+ lines.push(`GPU: ${gpuName}${gpuParts.length ? ` — ${gpuParts.join(", ")}` : ""}`);
1949
+ }
1950
+ const npu = sysinfo.devices?.amd_npu;
1951
+ if (npu && npu.available !== false) {
1952
+ const npuParts: string[] = [];
1953
+ if (npu.power_mode) npuParts.push(`power mode ${npu.power_mode}`);
1954
+ if (typeof npu.tops_max_int === "number") npuParts.push(`${npu.tops_max_int} TOPS`);
1955
+ if (npuParts.length || npu.name || npu.family) {
1956
+ lines.push(`NPU: ${npu.name || "?"}${npu.family ? ` (${npu.family})` : ""}${npuParts.length ? ` — ${npuParts.join(", ")}` : ""}`);
1957
+ }
1958
+ }
1959
+ if (sysinfo["Windows Power Setting"]) lines.push(`Power plan: ${sysinfo["Windows Power Setting"]}`);
1960
+ const store = sysinfo.model_storage;
1961
+ if (store && typeof store.total_bytes === "number") {
1962
+ const free = typeof store.free_bytes === "number" ? ` (${fmtBytes(store.free_bytes)} free)` : "";
1963
+ lines.push(
1964
+ `Model storage: ${fmtBytes(store.used_bytes ?? 0)} used / ${fmtBytes(store.total_bytes)} total${free}`
1965
+ );
1966
+ if (store.path) lines.push(` ${store.path}`);
1967
+ }
1968
+ } else {
1969
+ lines.push(" (unreachable)");
1970
+ }
1971
+ lines.push("");
1972
+
1973
+ // ── /metrics ──────────────────────────────────────────────────────────
1974
+ lines.push(sectionTitle("/metrics (llama.cpp backend)"));
1975
+ if (metrics && metrics.size) {
1976
+ const proc = metrics.get("lemonade_llamacpp_requests_processing");
1977
+ const deferred = metrics.get("lemonade_llamacpp_requests_deferred");
1978
+ const busy = metrics.get("lemonade_llamacpp_n_busy_slots_per_decode");
1979
+ const peak = metrics.get("lemonade_llamacpp_n_tokens_max");
1980
+ if (proc === undefined && deferred === undefined && peak === undefined) {
1981
+ lines.push(" (no llamacpp backend metrics — no llama.cpp model loaded)");
1982
+ } else {
1983
+ lines.push(
1984
+ `Queue: ${proc ?? 0} processing, ${deferred ?? 0} deferred` +
1985
+ (busy !== undefined ? ` Busy slots/decode: ${busy.toFixed(2)}` : "")
1986
+ );
1987
+ if (peak !== undefined) lines.push(`Peak sequence: ${fmtTokens(peak)} tokens (largest prompt+generation observed)`);
1988
+ }
1989
+ } else {
1990
+ lines.push(" (unreachable)");
1991
+ }
1992
+
1539
1993
  return lines;
1540
1994
  }
1541
1995
 
1996
+ /** Fetch all five status endpoints and format the panel, timestamped "now". */
1997
+ async function fetchStatusLines(config: LemonadeConfig): Promise<string[]> {
1998
+ const [h, sys, perf, info, metricsBody] = await Promise.all([
1999
+ fetchHealth(config),
2000
+ fetchJsonOrNull<SystemStats>(config, "/v1/system-stats"),
2001
+ fetchJsonOrNull<PerfStats>(config, "/v1/stats"),
2002
+ fetchJsonOrNull<SystemInfo>(config, "/v1/system-info"),
2003
+ fetchTextOrNull(config, "/metrics"),
2004
+ ]);
2005
+ const metrics = metricsBody ? parsePrometheus(metricsBody) : null;
2006
+ return formatStatus(config, h, sys, perf, info, metrics, new Date());
2007
+ }
2008
+
1542
2009
  /** Run an async op with a status indicator; returns [ok, result]. */
1543
2010
  async function withStatus<T>(
1544
2011
  ctx: UiCtx,
@@ -1776,19 +2243,43 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
1776
2243
  }
1777
2244
 
1778
2245
  // ── Status ────────────────────────────────────────────────────────
2246
+ // Live panel: polls every statusPollMs while open, and n/p cycles
2247
+ // through every configured instance without leaving the view.
1779
2248
  if (choice === "status") {
2249
+ const instances = config.servers;
2250
+ let idx = Math.max(0, instances.findIndex((s) => s.name === activeInstance));
2251
+ const cfgFor = (): LemonadeConfig => {
2252
+ const view = instanceView(configRef.current, instances[idx]?.name ?? activeInstance);
2253
+ return typeof view === "string" ? iconfig : view; // stale name → keep the menu's view
2254
+ };
1780
2255
  ctx.ui.setStatus("lemonade-setup", "Contacting server…");
1781
- const [h, sys, perf] = await Promise.all([
1782
- fetchHealth(iconfig),
1783
- fetchJsonOrNull<SystemStats>(iconfig, "/v1/system-stats"),
1784
- fetchJsonOrNull<PerfStats>(iconfig, "/v1/stats"),
1785
- ]);
2256
+ const first = await fetchStatusLines(cfgFor());
1786
2257
  ctx.ui.setStatus("lemonade-setup", undefined);
1787
2258
  await textView(
1788
2259
  ctx,
1789
- `Server Status — ${activeInstance}`,
1790
- formatStatus(iconfig, h, sys, perf)
2260
+ `Server Status — ${instances[idx]?.name ?? activeInstance}`,
2261
+ first,
2262
+ true,
2263
+ {
2264
+ pollMs: config.statusPollMs,
2265
+ refetch: config.statusPollMs > 0 ? () => fetchStatusLines(cfgFor()) : undefined,
2266
+ extraHint: instances.length > 1 ? "n/p instance" : undefined,
2267
+ onKey:
2268
+ instances.length > 1
2269
+ ? (data, api) => {
2270
+ if (matchesKey(data, "n")) idx = (idx + 1) % instances.length;
2271
+ else if (matchesKey(data, "p")) idx = (idx - 1 + instances.length) % instances.length;
2272
+ else return false;
2273
+ const name = instances[idx]?.name ?? activeInstance;
2274
+ api.setTitle(`Server Status — ${name}`);
2275
+ return true;
2276
+ }
2277
+ : undefined,
2278
+ }
1791
2279
  );
2280
+ // The panel was the last thing the user looked at — the menu follows it.
2281
+ const lastName = instances[idx]?.name;
2282
+ if (lastName) activeInstance = lastName;
1792
2283
  continue;
1793
2284
  }
1794
2285
 
@@ -67,5 +67,9 @@
67
67
  "loadTimeoutMs": 300000, // model load (5 min)
68
68
  "pullTimeoutMs": 1800000, // blocking model pull (30 min)
69
69
  "transcriptionTimeoutMs": 300000,
70
- "generationTimeoutMs": 600000
70
+ "generationTimeoutMs": 600000,
71
+
72
+ // How often (ms) the /lemonade-setup status panel re-fetches while it is
73
+ // open. 0 = static snapshot (no live refresh).
74
+ "statusPollMs": 2000
71
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lemonade",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Unified pi extension for a self-hosted Lemonade server: dynamic chat-model discovery, multimodal agent tools (transcription, image gen/edit/upscale, TTS, audio, 3D mesh), and a live /lemonade-setup management TUI.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -21,4 +21,4 @@
21
21
  "pi": {
22
22
  "extensions": ["./index.ts"]
23
23
  }
24
- }
24
+ }