pi-legion 2.0.0 → 2.0.2

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.
@@ -6,17 +6,29 @@
6
6
  *
7
7
  * numerator = Δ cumulative output tokens over the trailing window
8
8
  * denominator = Δ cumulative generationMs over the same window
9
- * (falls back to wall-clock window only when generationMs
10
- * is unavailable / zero for that node)
9
+ * (falls back to wall-clock window when generationMs is
10
+ * unavailable / zero, or when in-window ΔgenerationMs is
11
+ * below MIN_RATE_GENERATION_MS — same 1s floor as tree t/s)
11
12
  *
12
13
  * generationMs comes from session JSONL: sum of (assistant_ts − prior
13
14
  * user/toolResult_ts). Idle and tool gaps are excluded. Approximate —
14
15
  * usage lands when each assistant message completes, not mid-stream.
16
+ *
17
+ * Header / snap "now" (`nowRate` / `totalRate` with live node ids) only sums
18
+ * agents that are still `running`. Settled/retired series keep contributing to
19
+ * chart buckets/avg so the history window stays accurate; once nothing is live,
20
+ * "now" is forced to 0 even if the 60s window still holds Δtokens.
15
21
  */
16
22
 
17
23
  import type { VizNode } from "./model.ts";
18
24
 
19
25
  export const RATE_WINDOW_MS = 60_000;
26
+ /**
27
+ * Min in-window ΔgenerationMs before trusting gen as the tok/s denominator.
28
+ * Matches tree-row t/s gate (`generationMs >= 1000` in status.ts). Below this,
29
+ * fall back to wall-clock window length so a 1ms gen flush cannot spike to M.
30
+ */
31
+ export const MIN_RATE_GENERATION_MS = 1_000;
20
32
  const DEFAULT_RETENTION_MS = 6 * 60 * 60 * 1000;
21
33
  const MAX_SAMPLES_PER_NODE = 4096;
22
34
 
@@ -125,24 +137,39 @@ export class RateSampler {
125
137
 
126
138
  /**
127
139
  * Output tok/s over the trailing window: ΔtokensOut / (ΔgenerationMs/1000).
128
- * When the node has no generationMs samples in the window, falls back to
129
- * wall-clock window length so a ledger-only path still yields a number.
140
+ * When in-window ΔgenerationMs is 0 or below MIN_RATE_GENERATION_MS, falls
141
+ * back to wall-clock window length so a ledger-only path still yields a
142
+ * number and a sub-second gen flush cannot report M-level TPS.
130
143
  */
131
144
  nodeRate(nodeId: string, now: number, windowMs = RATE_WINDOW_MS): number {
132
145
  if (windowMs <= 0) return 0;
133
146
  const dOut = Math.max(0, this.valueAt(nodeId, now) - this.valueAt(nodeId, now - windowMs));
134
147
  if (dOut <= 0) return 0;
135
148
  const dGen = Math.max(0, this.genAt(nodeId, now) - this.genAt(nodeId, now - windowMs));
136
- const seconds = dGen > 0 ? dGen / 1000 : windowMs / 1000;
149
+ const seconds = dGen >= MIN_RATE_GENERATION_MS ? dGen / 1000 : windowMs / 1000;
137
150
  return dOut / seconds;
138
151
  }
139
152
 
140
- totalRate(now: number, windowMs = RATE_WINDOW_MS): number {
153
+ /**
154
+ * Sum of node rates. When `onlyNodeIds` is set (including empty), only those
155
+ * series contribute — used for header "now". Omit it for chart buckets/avg
156
+ * so settled agents still paint their trailing-window history.
157
+ */
158
+ totalRate(now: number, windowMs = RATE_WINDOW_MS, onlyNodeIds?: Iterable<string>): number {
159
+ const filter = onlyNodeIds === undefined ? undefined : new Set(onlyNodeIds);
141
160
  let sum = 0;
142
- for (const id of this.series.keys()) sum += this.nodeRate(id, now, windowMs);
161
+ for (const id of this.series.keys()) {
162
+ if (filter && !filter.has(id)) continue;
163
+ sum += this.nodeRate(id, now, windowMs);
164
+ }
143
165
  return sum;
144
166
  }
145
167
 
168
+ /** Header / snap "now" tok/s: live (running) agents only; none live → 0. */
169
+ nowRate(now: number, nodes: readonly VizNode[], windowMs = RATE_WINDOW_MS): number {
170
+ return this.totalRate(now, windowMs, liveRateNodeIds(nodes));
171
+ }
172
+
146
173
  nodeIds(): string[] {
147
174
  return [...this.series.keys()];
148
175
  }
@@ -150,8 +177,8 @@ export class RateSampler {
150
177
  /**
151
178
  * Bucketed tok/s for [from, to], stacked by role. Each bucket is the
152
179
  * trailing-window average ending at the bucket edge (window = max(bucket
153
- * span, RATE_WINDOW_MS)), so the last bucket equals the header "now" and
154
- * bursty per-turn token flushes do not render as spikes.
180
+ * span, RATE_WINDOW_MS)), including settled series so history stays painted.
181
+ * Header "now" may be lower (live-only); bursty flushes stay smoothed.
155
182
  */
156
183
  buckets(from: number, to: number, count: number, windowMs = RATE_WINDOW_MS): RateSeries {
157
184
  const n = Math.max(1, Math.floor(count));
@@ -191,6 +218,11 @@ export class RateSampler {
191
218
  }
192
219
  }
193
220
 
221
+ /** Node ids that contribute to header "now" tok/s (running agents; root excluded). */
222
+ export function liveRateNodeIds(nodes: readonly VizNode[]): string[] {
223
+ return nodes.filter((n) => n.id !== "root" && n.status === "running").map((n) => n.id);
224
+ }
225
+
194
226
  function interpolate(ts: number[], value: number[], t: number): number {
195
227
  if (ts.length === 0) return 0;
196
228
  if (t <= ts[0]!) return value[0]!;
@@ -1,7 +1,7 @@
1
1
  /** /legion full view: galaxy + tok/s chart + git-log history with linked selection. */
2
2
 
3
3
  import { Key, matchesKey } from "@earendil-works/pi-tui";
4
- import { Canvas, ellipsize } from "./canvas.ts";
4
+ import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
5
5
  import { computeLayout, type LayoutKind } from "./layout.ts";
6
6
  import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
7
7
  import { roleColor, type VizTheme } from "./palette.ts";
@@ -22,6 +22,9 @@ export function initialViewState(): LegionViewState {
22
22
 
23
23
  const PANES: Pane[] = ["galaxy", "chart", "log"];
24
24
 
25
+ /** Min grapheme cells before painting a selection badge. Shared by portrait + landscape so a truncated stub never paints (#128). Was portrait `>4` vs landscape `>0`. */
26
+ export const MIN_SELECTION_BADGE_W = 5;
27
+
25
28
  export function renderLegionView(data: ViewData, state: LegionViewState, cols: number, rows: number): { canvas: Canvas; layout: LayoutKind } {
26
29
  const W = Math.max(1, Math.floor(cols));
27
30
  const H = Math.max(6, Math.floor(rows));
@@ -44,21 +47,25 @@ export function renderLegionView(data: ViewData, state: LegionViewState, cols: n
44
47
  `${agents.length} agents`,
45
48
  ...(failed ? [`${failed} failed`] : []),
46
49
  ];
47
- const badge = selected ? ` ${selected.name} (${selected.role} · ${statusLabel(selected)}) ` : "";
50
+ const badgeRaw = selected ? ` ${selected.name} (${selected.role} · ${statusLabel(selected)}) ` : "";
51
+ // Cap the selection badge so a long agent name never overwrites "⚔ /legion" / the run name.
52
+ const badgeCap = Math.max(0, Math.min(cellWidth(badgeRaw), Math.floor(W * 0.45), W - 14));
53
+ const badge = badgeRaw ? ellipsize(badgeRaw, badgeCap) : "";
54
+ const badgeW = cellWidth(badge);
55
+ const showBadge = badgeW >= MIN_SELECTION_BADGE_W;
48
56
  if (portrait) {
49
57
  cv.text(0, 0, "⚔ /legion ", "accent", { bold: true });
50
- cv.text(10, 0, ellipsize(root?.name ?? "", Math.max(0, W - 10)), "text", { bold: true });
58
+ const rootRoom = Math.max(0, W - 10 - (showBadge ? badgeW + 1 : 0));
59
+ cv.text(10, 0, ellipsize(root?.name ?? "", rootRoom), "text", { bold: true });
51
60
  cv.text(0, 1, ellipsize(parts.join(" · "), W), "dim");
52
- if (badge) {
53
- const b = ellipsize(badge, Math.max(0, W - 12 - Array.from(root?.name ?? "").length));
54
- if (b.length > 4) cv.text(W - Array.from(b).length, 0, b, roleColor(selected!.role), { inverse: true });
55
- }
61
+ if (showBadge) cv.text(W - badgeW, 0, badge, roleColor(selected!.role), { inverse: true });
56
62
  } else {
57
63
  let x = cv.text(0, 0, "⚔ /legion ", "accent", { bold: true });
58
- x = cv.text(x, 0, `${root?.name ?? "legion"} `, "text", { bold: true });
59
- const badgeW = Array.from(badge).length;
60
- cv.text(x, 0, ellipsize(parts.join(" · "), Math.max(0, W - x - badgeW - 1)), "dim");
61
- if (badge) cv.text(Math.max(0, W - badgeW), 0, ellipsize(badge, W), roleColor(selected!.role), { inverse: true });
64
+ const reservedBadge = showBadge ? badgeW : 0;
65
+ const rootRoom = Math.max(0, W - x - reservedBadge - 1);
66
+ x = cv.text(x, 0, ellipsize(`${root?.name ?? "legion"} `, rootRoom), "text", { bold: true });
67
+ cv.text(x, 0, ellipsize(parts.join(" · "), Math.max(0, W - x - reservedBadge - 1)), "dim");
68
+ if (showBadge) cv.text(W - badgeW, 0, badge, roleColor(selected!.role), { inverse: true });
62
69
  }
63
70
 
64
71
  const galaxyInner = frame(cv, layout.galaxy, portrait ? "galaxy" : "galaxy · structure", state.focus === "galaxy");