pi-editor-footer 0.12.3 → 0.13.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 (67) hide show
  1. package/CHANGELOG.md +34 -9
  2. package/README.md +0 -1
  3. package/dist/agent-run-ledger.js +8 -72
  4. package/dist/border-renderer.js +9 -28
  5. package/dist/cache-command.js +99 -0
  6. package/dist/cache-cumulative.js +24 -0
  7. package/dist/cache-export.js +132 -0
  8. package/dist/cache-format.js +30 -0
  9. package/dist/cache-graph-view.js +257 -0
  10. package/dist/cache-math.js +44 -0
  11. package/dist/cache-scroll-dialog.js +107 -0
  12. package/dist/cache-session-data.js +44 -0
  13. package/dist/cache-stats-view.js +72 -0
  14. package/dist/cache-types.js +6 -0
  15. package/dist/chrome-state.js +2 -21
  16. package/dist/chrome-theme.js +29 -0
  17. package/dist/config.js +190 -34
  18. package/dist/detail-render.js +8 -9
  19. package/dist/footer.js +2 -6
  20. package/dist/icons.js +19 -0
  21. package/dist/layout.js +5 -0
  22. package/dist/live-border.js +77 -69
  23. package/dist/model-info.js +23 -101
  24. package/dist/path-format.js +5 -0
  25. package/dist/session-entries.js +38 -0
  26. package/dist/session-orchestrator.js +24 -27
  27. package/dist/state.js +12 -12
  28. package/dist/telemetry.js +110 -15
  29. package/dist/theme-settings.js +31 -228
  30. package/dist/transcript-timeline.js +5 -11
  31. package/dist/utils.js +2 -2
  32. package/docs/specs/04-header.md +2 -0
  33. package/package.json +1 -1
  34. package/src/agent-run-ledger.ts +7 -77
  35. package/src/border-renderer.ts +9 -37
  36. package/src/cache-command.ts +179 -0
  37. package/src/cache-cumulative.ts +36 -0
  38. package/src/cache-export.ts +170 -0
  39. package/src/cache-format.ts +41 -0
  40. package/src/cache-graph-view.ts +375 -0
  41. package/src/cache-math.ts +52 -0
  42. package/src/cache-scroll-dialog.ts +146 -0
  43. package/src/cache-session-data.ts +59 -0
  44. package/src/cache-stats-view.ts +105 -0
  45. package/src/cache-types.ts +43 -0
  46. package/src/chrome-state.ts +2 -30
  47. package/src/chrome-theme.ts +41 -0
  48. package/src/config.ts +223 -43
  49. package/src/detail-render.ts +8 -10
  50. package/src/footer.ts +2 -6
  51. package/src/icons.ts +40 -0
  52. package/src/index.ts +1 -65
  53. package/src/layout.ts +6 -0
  54. package/src/live-border.ts +101 -70
  55. package/src/model-info.ts +26 -121
  56. package/src/path-format.ts +6 -0
  57. package/src/session-entries.ts +99 -0
  58. package/src/session-orchestrator.ts +26 -28
  59. package/src/state.ts +21 -36
  60. package/src/telemetry.ts +116 -17
  61. package/src/theme-settings.ts +39 -241
  62. package/src/transcript-timeline.ts +5 -8
  63. package/src/utils.ts +8 -2
  64. package/src/chrome-composition.ts +0 -148
  65. package/src/footer-controller.ts +0 -77
  66. package/src/header.ts +0 -170
  67. package/src/utils-workspace.ts +0 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ <!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
4
+ <!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
5
+ formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
6
+ @semantic-release/changelog rewrites the title in place only while the file starts with
7
+ the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
8
+ move this title below the comments, and do not remove the title. -->
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.13.0] - 2026-09-25
13
+
14
+ ### Features
15
+
16
+ * **cache:** add /cache graph, stats and export commands (#37)
17
+ * **telemetry:** adopt streamed usage counts and provider-anchored TTFT (#33)
18
+
19
+ ### Bug Fixes
20
+
21
+ * **timeline:** honor wallTime, tokens and cost toggles in Agent-run rows (#38)
22
+ * **cache:** resolve promptTokens name clash, pin gate test, cover entry-id truncation (#38)
23
+ * **imports:** use .js extensions for NodeNext module resolution
24
+
25
+ ### Documentation
26
+
27
+ * **changelog:** attribute #38 ledger entries (#38)
28
+ * **review:** resolve architecture-review minors and fossil comments (#38)
29
+ * **agents:** point total-input single source at cache-math (#38)
30
+
31
+ ### Miscellaneous Chores
32
+
33
+ * **scaffold:** refresh git contract to current scaffold (#34)
34
+ * **scaffold:** refresh git contract gate to canonical Ledger-Waiver form (#35)
35
+ * **scaffold:** retire release-job baselines in CI workflow (#36)
36
+
3
37
  ## [0.12.3](https://github.com/Rianico/pi-editor-footer/compare/v0.12.2...v0.12.3) (2026-09-17)
4
38
 
5
39
  ### Bug Fixes
@@ -11,13 +45,6 @@
11
45
  * **agents:** mark agent-run token accounting a scope boundary ([ffbbee2](https://github.com/Rianico/pi-editor-footer/commit/ffbbee29300a40d6963e0916c7526e0a1ce49881))
12
46
  * document total input token semantics for the footer ([9480bf9](https://github.com/Rianico/pi-editor-footer/commit/9480bf9f1508930d75a5e53b94d944d00d1f6a2a))
13
47
 
14
- <!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
15
- <!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
16
- formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
17
- @semantic-release/changelog rewrites the title in place only while the file starts with
18
- the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
19
- move this title below the comments, and do not remove the title. -->
20
-
21
48
  All notable changes to this project will be documented in this file.
22
49
 
23
50
  ## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
@@ -26,8 +53,6 @@ All notable changes to this project will be documented in this file.
26
53
 
27
54
  * **context:** tier-color the context window section by usage ([#30](https://github.com/Rianico/pi-editor-footer/issues/30)) ([8152d90](https://github.com/Rianico/pi-editor-footer/commit/8152d90b3c1b4013154eff95cc885e79e7564f6f))
28
55
 
29
- ## [Unreleased]
30
-
31
56
  ## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
32
57
 
33
58
  ### Bug Fixes
package/README.md CHANGED
@@ -58,7 +58,6 @@ pi-editor-footer/
58
58
  │ ├── index.ts # extension entry, widget/header/footer install, /model-info
59
59
  │ ├── tracking-editor.ts # TrackingEditor (Editor slot, highlight, border, cursor)
60
60
  │ ├── footer.ts # renderFooter / installFooter (cwd·git • runtime • tokens·context)
61
- │ ├── header.ts # header disabled, cwd preserved in footer
62
61
  │ ├── detail-render.ts # wrap/scroll/ellipsis (tested)
63
62
  │ ├── window-presentation.ts # bordered themed box (tested)
64
63
  │ ├── model-info.ts # top border glow+label
@@ -8,7 +8,7 @@
8
8
  * occupancy, capped to contextWindow and session totals.
9
9
  *
10
10
  * Previously this logic was scattered across 3 call sites (telemetry.peekAgentLive
11
- * max-vs-sum, live-border.refreshContextBar 70L capping, index.agent_settled 60L
11
+ * max-vs-sum, the context-bar refresh since moved into live-border (then 70L capping), index.agent_settled 60L
12
12
  * baseline fallback) with divergent caps. Bugs required holding 3 sites in one head.
13
13
  *
14
14
  * Depth: small interface (setBaseline / startRun / recordTurn / getTotals / getLive
@@ -62,6 +62,9 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
62
62
  let stallCount = 0;
63
63
  let generationMs = 0;
64
64
  let ttftMs = 0;
65
+ // First observed provider TTFT across turns (run's initial request latency);
66
+ // null when no turn saw before_provider_request. Mirrors ttftMs below.
67
+ let ttftProviderMs = null;
65
68
  for (const t of turns) {
66
69
  inputTokens = Math.max(inputTokens, t.inputTokens);
67
70
  outputTokens += t.outputTokens;
@@ -72,6 +75,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
72
75
  }
73
76
  if (turns.length > 0)
74
77
  ttftMs = turns[0].ttftMs;
78
+ ttftProviderMs = turns.map((t) => t.ttftProviderMs).find((v) => v != null) ?? null;
75
79
  if (live) {
76
80
  inputTokens = Math.max(inputTokens, live.inputTokens);
77
81
  outputTokens += live.outputTokens;
@@ -81,6 +85,8 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
81
85
  generationMs += live.generationMs;
82
86
  if (ttftMs === 0)
83
87
  ttftMs = live.ttftMs;
88
+ if (ttftProviderMs === null)
89
+ ttftProviderMs = live.ttftProviderMs ?? null;
84
90
  }
85
91
  totalTokens = inputTokens + outputTokens;
86
92
  const totalMs = startMs === null ? 0 : Math.max(0, now - startMs);
@@ -101,6 +107,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
101
107
  totalTokens,
102
108
  costUsd: validCost ? costUsd : 0,
103
109
  measurementMs,
110
+ ttftProviderMs,
104
111
  estimated: live?.estimated === true,
105
112
  };
106
113
  }
@@ -246,47 +253,6 @@ export class AgentRunLedger {
246
253
  cost: snapshotTotals.cost,
247
254
  };
248
255
  }
249
- /**
250
- * Idle display totals for LiveBorder when not running.
251
- * Prefers liveAgent/liveTelemetry when available; else baseline delta, capped to idle.
252
- */
253
- getIdleDisplayTotals(snapshotTotals, contextTokens, liveAgent) {
254
- let displayInput;
255
- let displayOutput;
256
- let displayCost;
257
- if (liveAgent) {
258
- displayInput = liveAgent.inputTokens;
259
- displayOutput = liveAgent.outputTokens;
260
- displayCost = liveAgent.costUsd;
261
- }
262
- else if (this.baseline) {
263
- const d = deltaFromBaseline(snapshotTotals, this.baseline);
264
- displayInput = d.input;
265
- displayOutput = d.output;
266
- displayCost = d.cost;
267
- }
268
- else {
269
- displayInput = snapshotTotals.input;
270
- displayOutput = snapshotTotals.output;
271
- displayCost = snapshotTotals.cost;
272
- }
273
- const cappedInput = capInputForIdle(displayInput, contextTokens, snapshotTotals.input);
274
- return {
275
- tps: null,
276
- ttftMs: 0,
277
- totalMs: 0,
278
- inputTokens: cappedInput,
279
- outputTokens: displayOutput,
280
- stallMs: 0,
281
- stallCount: 0,
282
- rateUsdPerMTokens: null,
283
- generationMs: 0,
284
- totalTokens: cappedInput + displayOutput,
285
- costUsd: displayCost,
286
- measurementMs: null,
287
- estimated: false,
288
- };
289
- }
290
256
  /**
291
257
  * Idle authoritative display (hybrid Q7 b): snapshot delta capped, for use when
292
258
  * not running to show billed total without ~ after agent_end.
@@ -323,36 +289,6 @@ export class AgentRunLedger {
323
289
  estimated: false,
324
290
  };
325
291
  }
326
- /**
327
- * Live display totals when running: liveTurn input (current window) replaces
328
- * agentLive input, output/cost remain per-agent sum, capped to live context only.
329
- * @deprecated — retained for backward compat; prefer getIncrementalLiveDisplayTotals for Q1/Q9.
330
- */
331
- getLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
332
- if (!agentLive && !liveTurn)
333
- return null;
334
- let displayLive = agentLive;
335
- if (agentLive && liveTurn) {
336
- displayLive = {
337
- ...agentLive,
338
- inputTokens: liveTurn.inputTokens,
339
- totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
340
- };
341
- }
342
- else if (!agentLive && liveTurn) {
343
- // Edge: no agent yet but liveTurn exists — use liveTurn window
344
- displayLive = liveTurn;
345
- }
346
- if (!displayLive)
347
- return null;
348
- const cappedInput = capInputForLive(displayLive.inputTokens, contextTokens);
349
- return {
350
- ...displayLive,
351
- inputTokens: cappedInput,
352
- totalTokens: cappedInput + displayLive.outputTokens,
353
- estimated: true,
354
- };
355
- }
356
292
  /**
357
293
  * Incremental live display (Q1/Q6/Q9): trigger + Σ(outputs+tools) + liveDelta, capped to live.
358
294
  * Replaces max(input) with synthetic accumulation, per-agent_run independent.
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { applyModelInfo, buildLabel } from "./model-info.js";
2
+ import { buildLabel, resolveThinkingGlow } from "./model-info.js";
3
3
  function stripAnsi(s) {
4
4
  return s.replace(/\x1b\[[0-9;]*m/g, "");
5
5
  }
@@ -28,18 +28,9 @@ export class BorderRenderer {
28
28
  if (hasTop) {
29
29
  const theme = this.getLiveTheme();
30
30
  const info = this.getModelInfo();
31
- const glow = (s) => {
32
- try {
33
- const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
34
- theme.getThinkingBorderColor;
35
- if (typeof maybeGlow === "function")
36
- return maybeGlow.call(theme, info.level)(s);
37
- }
38
- catch {
39
- // SAFETY: best-effort UI, ignore recoverable error
40
- }
41
- return s;
42
- };
31
+ // C3: single glow home in model-info.ts; identity fallback explicit at this site.
32
+ const glowFor = resolveThinkingGlow(theme);
33
+ const glow = (s) => (glowFor ? glowFor(info.level, s) : s);
43
34
  // When tokens line exists, right moves to that line (one padding) — don't duplicate on top
44
35
  const topRightForTop = opts.topTokensText ? "" : opts.topRightText;
45
36
  if (opts.glowEnabled) {
@@ -53,8 +44,7 @@ export class BorderRenderer {
53
44
  out = embedTopWithLeftAndRight(out, width, leftLabel, topRightForTop, glow);
54
45
  }
55
46
  else {
56
- // Only left (model+context), no right — use applyModelInfo replacement but with combined left
57
- // Reuse embedTopWithLeftAndRight with empty right
47
+ // Only left (model+context), no right — reuse embedTopWithLeftAndRight with empty right
58
48
  out = embedTopWithLeftAndRight(out, width, leftLabel, "", glow);
59
49
  }
60
50
  }
@@ -90,18 +80,9 @@ export class BorderRenderer {
90
80
  // Bottom embedding (telemetry left/right)
91
81
  if (opts.telemetryText || opts.bottomLeftText) {
92
82
  const theme = this.getLiveTheme();
93
- const glow = (s) => {
94
- try {
95
- const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
96
- theme.getThinkingBorderColor;
97
- if (typeof maybeGlow === "function")
98
- return maybeGlow.call(theme, this.getModelInfo().level)(s);
99
- }
100
- catch {
101
- // SAFETY: best-effort UI, ignore recoverable error
102
- }
103
- return s;
104
- };
83
+ // C3: single glow home; this site re-reads the live model level per call — kept explicit.
84
+ const glowFor = resolveThinkingGlow(theme);
85
+ const glow = (s) => (glowFor ? glowFor(this.getModelInfo().level, s) : s);
105
86
  out = embedBottomBorder(out, width, opts.bottomLeftText ?? "", opts.telemetryText ?? "", glow);
106
87
  }
107
88
  return out;
@@ -190,7 +171,7 @@ function embedTopRightBorder(lines, width, rightText, getGlow) {
190
171
  if (/^─── [↑↓] \d+ more/.test(plainTop))
191
172
  return lines;
192
173
  const rightW = visibleWidth(rightText);
193
- // left label already embedded by applyModelInfo — its visible width is width - middle - right
174
+ // left label already embedded on the top border — its visible width is width - middle - right
194
175
  // We need to truncate right if too wide, preserving at least 10 chars for left
195
176
  const maxRight = Math.max(0, width - 12);
196
177
  let displayRight = rightText;
@@ -0,0 +1,99 @@
1
+ import { exportStatsCsv } from "./cache-export.js";
2
+ import { GRAPH_VIEWS, graphViewLabel, renderGraphBody, } from "./cache-graph-view.js";
3
+ import { ScrollDialog } from "./cache-scroll-dialog.js";
4
+ import { collectCacheSessionMetrics } from "./cache-session-data.js";
5
+ import { renderStatsBody } from "./cache-stats-view.js";
6
+ import { chartGlyphsForMode } from "./icons.js";
7
+ function normalizeSubcommand(args) {
8
+ return args.trim().toLowerCase();
9
+ }
10
+ function usageText() {
11
+ return "Usage: /cache graph | /cache stats | /cache export";
12
+ }
13
+ const OVERLAY_OPTIONS = {
14
+ anchor: "center",
15
+ width: "90%",
16
+ maxHeight: "90%",
17
+ margin: 1,
18
+ };
19
+ const SUBCOMMANDS = [
20
+ { value: "graph", label: "graph", description: "Show cache hit % graph over time" },
21
+ { value: "stats", label: "stats", description: "Show token/cache breakdown table" },
22
+ { value: "export", label: "export", description: "Export stats data to a CSV at project root" },
23
+ ];
24
+ export function registerCacheCommand(pi, options) {
25
+ pi.registerCommand("cache", {
26
+ description: "Show cache hit graph, token/cache statistics, or export CSV",
27
+ getArgumentCompletions(prefix) {
28
+ const filtered = SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.toLowerCase()));
29
+ return filtered.length > 0 ? filtered : SUBCOMMANDS;
30
+ },
31
+ handler: async (args, ctx) => {
32
+ const subcommand = normalizeSubcommand(args);
33
+ if (subcommand !== "graph" && subcommand !== "stats" && subcommand !== "export") {
34
+ ctx.ui.notify(usageText(), "info");
35
+ return;
36
+ }
37
+ // SAFETY: pi seam — runtime sessionManager provides getEntries/getBranch/getSessionName/getSessionFile
38
+ const sessionManager = ctx.sessionManager;
39
+ let metrics = collectCacheSessionMetrics(sessionManager);
40
+ if (subcommand === "export") {
41
+ // SAFETY: pi seam — same structural cast as above
42
+ const filePath = await exportStatsCsv(ctx.cwd, ctx.sessionManager, metrics);
43
+ ctx.ui.notify(`Exported cache stats CSV to ${filePath}`, "info");
44
+ return;
45
+ }
46
+ if (!ctx.hasUI) {
47
+ ctx.ui.notify("/cache graph and /cache stats require interactive TUI mode. Use /cache export in non-interactive mode.", "info");
48
+ return;
49
+ }
50
+ if (subcommand === "graph") {
51
+ let currentView = "per-turn";
52
+ await ctx.ui.custom((_tui, theme, _keybindings, done) => new ScrollDialog(theme, {
53
+ title: "Context Cache Graph",
54
+ getTitle: () => `Context Cache Graph — ${graphViewLabel(currentView)}`,
55
+ helpText: "1/2/3 view • r refresh • v cycle • ↑/↓ scroll • PgUp/PgDn • q/Esc close",
56
+ renderBody: (innerWidth) => renderGraphBody(theme, metrics, innerWidth, currentView, chartGlyphsForMode(options.getConfig().icons.mode)),
57
+ onKey: (data) => {
58
+ if (data === "r") {
59
+ metrics = collectCacheSessionMetrics(sessionManager);
60
+ return true;
61
+ }
62
+ if (data === "1")
63
+ currentView = "per-turn";
64
+ else if (data === "2")
65
+ currentView = "cumulative-percent";
66
+ else if (data === "3")
67
+ currentView = "cumulative-total";
68
+ else if (data === "v") {
69
+ const idx = GRAPH_VIEWS.indexOf(currentView);
70
+ currentView = GRAPH_VIEWS[(idx + 1) % GRAPH_VIEWS.length];
71
+ }
72
+ else if (data === "V") {
73
+ const idx = GRAPH_VIEWS.indexOf(currentView);
74
+ currentView = GRAPH_VIEWS[(idx + GRAPH_VIEWS.length - 1) % GRAPH_VIEWS.length];
75
+ }
76
+ else {
77
+ return false;
78
+ }
79
+ return true; // recognised key — re-render even when the view is unchanged
80
+ },
81
+ }, () => done(undefined)), { overlay: true, overlayOptions: OVERLAY_OPTIONS });
82
+ return;
83
+ }
84
+ // stats
85
+ await ctx.ui.custom((_tui, theme, _keybindings, done) => new ScrollDialog(theme, {
86
+ title: "Context Cache Stats",
87
+ helpText: "r refresh • ↑/↓ scroll • PgUp/PgDn • q/Esc close",
88
+ renderBody: (innerWidth) => renderStatsBody(theme, metrics, innerWidth),
89
+ onKey: (data) => {
90
+ if (data === "r") {
91
+ metrics = collectCacheSessionMetrics(sessionManager);
92
+ return true;
93
+ }
94
+ return false;
95
+ },
96
+ }, () => done(undefined)), { overlay: true, overlayOptions: OVERLAY_OPTIONS });
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,24 @@
1
+ import { computeCacheHitPercent } from "./cache-math.js";
2
+ /**
3
+ * Running cumulative totals across all messages in session append order.
4
+ * Pure function — no UI/theme dependency.
5
+ */
6
+ export function computeCumulativeSeries(messages) {
7
+ const cumInput = [];
8
+ const cumCacheRead = [];
9
+ const cumCacheWrite = [];
10
+ const cumHitPercent = [];
11
+ let sumInput = 0;
12
+ let sumCacheRead = 0;
13
+ let sumCacheWrite = 0;
14
+ for (const msg of messages) {
15
+ sumInput += msg.input;
16
+ sumCacheRead += msg.cacheRead;
17
+ sumCacheWrite += msg.cacheWrite;
18
+ cumInput.push(sumInput);
19
+ cumCacheRead.push(sumCacheRead);
20
+ cumCacheWrite.push(sumCacheWrite);
21
+ cumHitPercent.push(computeCacheHitPercent(sumInput, sumCacheRead, sumCacheWrite));
22
+ }
23
+ return { cumInput, cumCacheRead, cumCacheWrite, cumHitPercent };
24
+ }
@@ -0,0 +1,132 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { basename, extname, join } from "node:path";
3
+ import { summarizeHitPercent } from "./cache-format.js";
4
+ import { promptTokens } from "./cache-math.js";
5
+ export function csvEscape(value) {
6
+ const text = value == null ? "" : String(value);
7
+ if (/[",\n]/.test(text)) {
8
+ return `"${text.replaceAll('"', '""')}"`;
9
+ }
10
+ return text;
11
+ }
12
+ export function sanitizeFileName(name) {
13
+ const sanitized = name
14
+ .trim()
15
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^[-.]+|[-.]+$/g, "");
18
+ return sanitized.length > 0 ? sanitized : "session";
19
+ }
20
+ function resolveSessionBaseName(sessionManager) {
21
+ const sessionName = sessionManager.getSessionName()?.trim();
22
+ if (sessionName)
23
+ return sanitizeFileName(sessionName);
24
+ const sessionFile = sessionManager.getSessionFile();
25
+ if (sessionFile) {
26
+ return sanitizeFileName(basename(sessionFile, extname(sessionFile)));
27
+ }
28
+ return "session";
29
+ }
30
+ const headers = [
31
+ "row_type",
32
+ "scope",
33
+ "assistant_messages",
34
+ "sequence",
35
+ "active_branch_sequence",
36
+ "is_on_active_branch",
37
+ "entry_id",
38
+ "timestamp",
39
+ "time",
40
+ "provider",
41
+ "model",
42
+ "model_key",
43
+ "prompt_tokens",
44
+ "received_tokens",
45
+ "cache_hit_tokens",
46
+ "cache_write_tokens",
47
+ "total_tokens",
48
+ "cache_hit_percent",
49
+ "delta_sent_tokens",
50
+ "delta_received_tokens",
51
+ "delta_cache_hit_tokens",
52
+ "delta_cache_write_tokens",
53
+ "delta_hit_rate_percent",
54
+ "notes",
55
+ ];
56
+ function summaryRows(metrics) {
57
+ const treeHitRate = summarizeHitPercent(metrics.treeTotals);
58
+ const branchHitRate = summarizeHitPercent(metrics.activeBranchTotals);
59
+ return [
60
+ {
61
+ row_type: "summary",
62
+ scope: "active_branch",
63
+ assistant_messages: metrics.activeBranchTotals.assistantMessages,
64
+ prompt_tokens: promptTokens(metrics.activeBranchTotals.input, metrics.activeBranchTotals.cacheRead, metrics.activeBranchTotals.cacheWrite),
65
+ received_tokens: metrics.activeBranchTotals.output,
66
+ cache_hit_tokens: metrics.activeBranchTotals.cacheRead,
67
+ cache_write_tokens: metrics.activeBranchTotals.cacheWrite,
68
+ total_tokens: metrics.activeBranchTotals.totalTokens,
69
+ cache_hit_percent: branchHitRate,
70
+ notes: "Matches the active-branch cumulative totals shown in /cache stats",
71
+ },
72
+ {
73
+ row_type: "summary",
74
+ scope: "whole_tree",
75
+ assistant_messages: metrics.treeTotals.assistantMessages,
76
+ prompt_tokens: promptTokens(metrics.treeTotals.input, metrics.treeTotals.cacheRead, metrics.treeTotals.cacheWrite),
77
+ received_tokens: metrics.treeTotals.output,
78
+ cache_hit_tokens: metrics.treeTotals.cacheRead,
79
+ cache_write_tokens: metrics.treeTotals.cacheWrite,
80
+ total_tokens: metrics.treeTotals.totalTokens,
81
+ cache_hit_percent: treeHitRate,
82
+ notes: "Matches the whole-tree cumulative totals shown in /cache stats",
83
+ },
84
+ {
85
+ row_type: "summary",
86
+ scope: "delta_tree_minus_branch",
87
+ assistant_messages: metrics.treeTotals.assistantMessages - metrics.activeBranchTotals.assistantMessages,
88
+ delta_sent_tokens: metrics.treeTotals.input - metrics.activeBranchTotals.input,
89
+ delta_received_tokens: metrics.treeTotals.output - metrics.activeBranchTotals.output,
90
+ delta_cache_hit_tokens: metrics.treeTotals.cacheRead - metrics.activeBranchTotals.cacheRead,
91
+ delta_cache_write_tokens: metrics.treeTotals.cacheWrite - metrics.activeBranchTotals.cacheWrite,
92
+ delta_hit_rate_percent: treeHitRate - branchHitRate,
93
+ notes: "Matches the delta row shown in /cache stats",
94
+ },
95
+ ];
96
+ }
97
+ function messageRows(metrics) {
98
+ return metrics.allMessages.map((metric) => ({
99
+ row_type: "message",
100
+ scope: metric.isOnActiveBranch ? "active_branch" : "other_branch",
101
+ sequence: metric.sequence,
102
+ active_branch_sequence: metric.activeBranchSequence ?? "",
103
+ is_on_active_branch: metric.isOnActiveBranch,
104
+ entry_id: metric.entryId,
105
+ timestamp: metric.timestamp,
106
+ time: metric.timestamp.slice(11, 19),
107
+ provider: metric.provider,
108
+ model: metric.model,
109
+ model_key: `${metric.provider}/${metric.model}`,
110
+ prompt_tokens: promptTokens(metric.input, metric.cacheRead, metric.cacheWrite),
111
+ received_tokens: metric.output,
112
+ cache_hit_tokens: metric.cacheRead,
113
+ cache_write_tokens: metric.cacheWrite,
114
+ total_tokens: metric.totalTokens,
115
+ cache_hit_percent: metric.cacheHitPercent,
116
+ notes: "Matches a per-message row in /cache stats",
117
+ }));
118
+ }
119
+ export function buildCsv(metrics) {
120
+ const rows = [...summaryRows(metrics), ...messageRows(metrics)];
121
+ const csvRows = [headers.join(",")];
122
+ for (const row of rows) {
123
+ csvRows.push(headers.map((header) => csvEscape(row[header])).join(","));
124
+ }
125
+ return csvRows.join("\n") + "\n";
126
+ }
127
+ export async function exportStatsCsv(cwd, sessionManager, metrics) {
128
+ const fileBaseName = resolveSessionBaseName(sessionManager);
129
+ const filePath = join(cwd, `${fileBaseName}.csv`);
130
+ await writeFile(filePath, buildCsv(metrics), "utf8");
131
+ return filePath;
132
+ }
@@ -0,0 +1,30 @@
1
+ import { computeCacheHitPercent, promptTokens } from "./cache-math.js";
2
+ // formatInt keeps thousands separators: the stats table aligns columns on
3
+ // full-width numbers, so fmtTokens (k/M compaction) does not fit here.
4
+ export function formatInt(value) {
5
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Math.round(value));
6
+ }
7
+ export function formatPercent(value) {
8
+ return `${value.toFixed(1)}%`;
9
+ }
10
+ export function shortModelName(provider, model) {
11
+ return `${provider}/${model}`;
12
+ }
13
+ export function summarizeHitPercent(totals) {
14
+ return computeCacheHitPercent(totals.input, totals.cacheRead, totals.cacheWrite);
15
+ }
16
+ /** Object-shaped delegate — the formula lives in `cache-math.ts:promptTokens`. */
17
+ export function promptTokensOfTotals(totals) {
18
+ return promptTokens(totals.input, totals.cacheRead, totals.cacheWrite);
19
+ }
20
+ export function formatTotalsLine(label, totals) {
21
+ return [
22
+ `${label}:`,
23
+ `${formatInt(totals.assistantMessages)} turns`,
24
+ `prompt ${formatInt(promptTokensOfTotals(totals))}`,
25
+ `received ${formatInt(totals.output)}`,
26
+ `cache hit ${formatInt(totals.cacheRead)}`,
27
+ `cache write ${formatInt(totals.cacheWrite)}`,
28
+ `hit rate ${formatPercent(summarizeHitPercent(totals))}`,
29
+ ].join(" • ");
30
+ }