@maxiaochao/pi-toolkit 0.1.3

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.
@@ -0,0 +1,570 @@
1
+ // tau-style usage/cache dashboard renderer.
2
+ // Logic ported from huggingface/tau src/tau_coding/session_usage.py (render_usage_dashboard),
3
+ // interactivity/styles auto-extracted into ./tau-assets.mjs.
4
+
5
+ import { USAGE_STYLES, USAGE_SCRIPT } from "./tau-assets.ts";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Data aggregation from pi session entries
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function hhmmss(ts) {
12
+ const iso = String(ts || "");
13
+ if (!iso) return "";
14
+ const d = new Date(iso);
15
+ if (isNaN(d)) return "";
16
+ // render in the machine's local timezone (Asia/Shanghai here -> Beijing time)
17
+ return d.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
18
+ }
19
+
20
+ export function tzLabel() {
21
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
22
+ const offMin = -new Date().getTimezoneOffset();
23
+ const sign = offMin >= 0 ? "+" : "-";
24
+ const abs = Math.abs(offMin);
25
+ const off = `${sign}${String(Math.trunc(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
26
+ return `${tz} (UTC${off})`;
27
+ }
28
+
29
+ export function parseEntries(entries) {
30
+ const requests = [];
31
+ const events = [];
32
+ const toolCounts = {};
33
+ let compactions = 0;
34
+ let prevKey = null;
35
+ let gapTools = []; // tool results accumulated since the previous request
36
+
37
+ const addEvent = (num, kind, label, ts) =>
38
+ events.push({ requestNumber: num, timestamp: hhmmss(ts), kind, label });
39
+
40
+ for (const e of entries || []) {
41
+ const t = e?.type;
42
+ const ts = e?.timestamp;
43
+ if (t === "compaction") {
44
+ compactions += 1;
45
+ addEvent(requests.length + 1, "compaction", "Compaction", ts);
46
+ continue;
47
+ }
48
+ if (t === "model_change") {
49
+ const mid = e.modelId || e.model || "?";
50
+ const prov = e.provider ? `${e.provider}/` : "";
51
+ addEvent(requests.length + 1, "model", `Model changed to ${prov}${mid}`, ts);
52
+ continue;
53
+ }
54
+ if (t === "thinking_level_change") {
55
+ const lvl = e.thinkingLevel || e.level || "off";
56
+ addEvent(requests.length + 1, "thinking", `Thinking changed to ${lvl}`, ts);
57
+ continue;
58
+ }
59
+ if (t !== "message") continue;
60
+ const m = e.message;
61
+ if (!m || m.role !== "assistant") {
62
+ if (m && m.role === "toolResult") {
63
+ let chars = 0;
64
+ for (const blk of m.content || []) {
65
+ if (blk && typeof blk === "object" && typeof blk.text === "string") chars += blk.text.length;
66
+ }
67
+ gapTools.push({ tool: m.toolName || "?", chars });
68
+ }
69
+ continue;
70
+ }
71
+
72
+ // pi stores tool calls inside assistant content blocks
73
+ for (const blk of m.content || []) {
74
+ if (blk && typeof blk === "object" && blk.type === "toolCall") {
75
+ const name = blk.tool || blk.name || "?";
76
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
77
+ }
78
+ }
79
+
80
+ const u = m.usage;
81
+ if (!u) continue;
82
+ const fresh = u.input || 0;
83
+ const cached = u.cacheRead || 0;
84
+ const cacheWrite = u.cacheWrite || 0;
85
+ const output = u.output || 0;
86
+ if (fresh + cached + cacheWrite + output === 0) continue;
87
+
88
+ const num = requests.length + 1;
89
+ const provider = m.provider || "?";
90
+ const model = m.model || "?";
91
+ const key = `${provider}/${model}`;
92
+ if (prevKey !== null && key !== prevKey && !events.some((ev) => ev.requestNumber === num && ev.kind === "model")) {
93
+ addEvent(num, "model", `Model changed to ${provider}/${model}`, ts);
94
+ }
95
+ prevKey = key;
96
+
97
+ const cost = u.cost && typeof u.cost.total === "number" ? u.cost.total : null;
98
+ requests.push({
99
+ number: num,
100
+ isoTs: String(ts || ""),
101
+ timestamp: hhmmss(ts),
102
+ provider,
103
+ model,
104
+ responseProvider: null,
105
+ fresh,
106
+ cached,
107
+ cacheWrite,
108
+ cacheWrite1h: u.cacheWrite1h || 0,
109
+ output,
110
+ reasoning: u.reasoning || 0,
111
+ stopReason: m.stopReason || "",
112
+ estimatedCost: cost,
113
+ gapTools,
114
+ prevOutput: requests.length ? requests[requests.length - 1].output : 0,
115
+ });
116
+ gapTools = [];
117
+ }
118
+
119
+ // tau flushes trailing events onto the last request so chart tooltips can find them
120
+ if (requests.length > 0) {
121
+ for (const ev of events) ev.requestNumber = Math.min(ev.requestNumber, requests.length);
122
+ }
123
+
124
+ const toolCalls = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
125
+ return { requests, events, toolCalls, compactions };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Helpers (ported from session_usage.py)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function esc(s, quote = false) {
133
+ let out = String(s ?? "")
134
+ .replaceAll("&", "&")
135
+ .replaceAll("<", "&lt;")
136
+ .replaceAll(">", "&gt;");
137
+ if (quote) out = out.replaceAll('"', "&quot;");
138
+ return out;
139
+ }
140
+
141
+ const SERIES_COLORS = {
142
+ // [dark, light] - derived from tau's built-in themes
143
+ cached: ["#a7f3f0", "#0f766e"],
144
+ "cache writes": ["#9cffb1", "#166534"],
145
+ fresh: ["#ff4f4f", "#b91c1c"],
146
+ request: ["#ff4f4f", "#b91c1c"],
147
+ cumulative: ["#93c5fd", "#2563eb"],
148
+ output: ["#c084fc", "#9333ea"],
149
+ reasoning: ["#9cffb1", "#166534"],
150
+ event: ["#a7f3f0", "#0f766e"],
151
+ };
152
+ const seriesColor = (name) => SERIES_COLORS[name] || ["#93c5fd", "#2563eb"];
153
+
154
+ function compactNumber(v) {
155
+ if (v >= 1e6) return (v / 1e6).toFixed(1) + "m";
156
+ if (v >= 1e3) return (v / 1e3).toFixed(0) + "k";
157
+ return v.toFixed(0);
158
+ }
159
+
160
+ function fmtCost(v) {
161
+ if (v === null || v === undefined) return "$N/A";
162
+ if (v > 0 && v < 0.01) return `$${v.toFixed(3)}`;
163
+ return `$${v.toFixed(2)}`;
164
+ }
165
+
166
+ const fmtInt = (n) => n.toLocaleString("en-US");
167
+ const fmtPct1 = (x) => (x * 100).toFixed(1) + "%";
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // _line_chart port
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function lineChart(title, series, opts = {}) {
174
+ const { yMax = null, percent = false, timestamps = null, events = [] } = opts;
175
+ const width = 900, height = 330, left = 68, right = 20, top = 42, bottom = 52;
176
+ const plotWidth = width - left - right, plotHeight = height - top - bottom;
177
+ const count = Math.max(0, ...series.map(([, v]) => v.length));
178
+ const observedMax = Math.max(0, ...series.map(([, v]) => Math.max(0, ...v)));
179
+ let maximum = yMax || Math.max(observedMax, 1);
180
+ if (!percent) {
181
+ const magnitude = 10 ** Math.max(0, String(Math.trunc(maximum)).length - 1);
182
+ maximum = Math.ceil(maximum / magnitude) * magnitude;
183
+ }
184
+
185
+ const point = (index, value) => [
186
+ left + (index / Math.max(count - 1, 1)) * plotWidth,
187
+ top + plotHeight - (value / maximum) * plotHeight,
188
+ ];
189
+
190
+ const tsAttr = timestamps ? ` data-timestamps="${esc(timestamps.join("|"), true)}"` : "";
191
+ const parts = [
192
+ `<svg class="usage-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(title, true)}" data-left="${left}" data-right="${right}" data-count="${count}"${tsAttr}>`,
193
+ `<text class="chart-title" x="${left}" y="24">${esc(title)}</text>`,
194
+ ];
195
+ for (let tick = 0; tick < 6; tick++) {
196
+ const value = (maximum * tick) / 5;
197
+ const y = top + plotHeight - (tick * plotHeight) / 5;
198
+ const label = percent ? `${(value * 100).toFixed(0)}%` : compactNumber(value);
199
+ parts.push(
200
+ `<line class="grid" x1="${left}" y1="${y.toFixed(1)}" x2="${width - right}" y2="${y.toFixed(1)}"/>` +
201
+ `<text class="tick" x="${left - 9}" y="${(y + 4).toFixed(1)}" text-anchor="end">${label}</text>`,
202
+ );
203
+ }
204
+ for (let index = 0; index < count; index++) {
205
+ if (count <= 20 || index % Math.max(1, Math.trunc(count / 12)) === 0 || index === count - 1) {
206
+ const [x] = point(index, 0);
207
+ parts.push(`<text class="tick" x="${x.toFixed(1)}" y="${height - 22}" text-anchor="middle">${index + 1}</text>`);
208
+ }
209
+ }
210
+ parts.push(
211
+ `<line class="hover-line" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotHeight}" visibility="hidden"/>`,
212
+ );
213
+ const [evDark, evLight] = seriesColor("event");
214
+ events.forEach((event, eventIndex) => {
215
+ const [x] = point(Math.max(0, Math.min(count - 1, event.requestNumber - 1)), 0);
216
+ const markerY = top + 7 + (eventIndex % 3) * 9;
217
+ const description = `${event.label} before request ${event.requestNumber} at ${event.timestamp}`;
218
+ parts.push(
219
+ `<g class="usage-event usage-event-${esc(event.kind, true)}" data-request="${event.requestNumber}" ` +
220
+ `data-event-info="${esc(description, true)}" role="img" aria-label="${esc(description, true)}">` +
221
+ `<title>${esc(description)}</title>` +
222
+ `<line class="event-line" data-dark="${evDark}" data-light="${evLight}" x1="${x.toFixed(1)}" y1="${top}" x2="${x.toFixed(1)}" y2="${top + plotHeight}" stroke="${evDark}"/>` +
223
+ `<circle class="event-marker" data-dark="${evDark}" data-light="${evLight}" cx="${x.toFixed(1)}" cy="${markerY.toFixed(1)}" r="4" fill="${evDark}"/></g>`,
224
+ );
225
+ });
226
+ series.forEach(([name, values], seriesIndex) => {
227
+ const [dark, light] = seriesColor(name);
228
+ const points = values.map((value, i) => point(i, value).map((c) => c.toFixed(1)).join(",")).join(" ");
229
+ const labels = values.map((v) => (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
230
+ parts.push(
231
+ `<g class="series" data-series-id="${seriesIndex}" data-name="${esc(name, true)}" data-labels="${esc(labels, true)}">` +
232
+ `<polyline class="series-line" data-dark="${dark}" data-light="${light}" points="${points}" fill="none" stroke="${dark}" stroke-width="3"/>` +
233
+ `<circle class="hover-point" data-dark="${dark}" data-light="${light}" r="5" fill="${dark}" visibility="hidden"/></g>`,
234
+ );
235
+ });
236
+ let legendX = width - right;
237
+ [...series].reverse().forEach(([name], i) => {
238
+ const seriesIndex = series.length - 1 - i;
239
+ const [dark, light] = seriesColor(name);
240
+ legendX -= 18 + name.length * 8;
241
+ parts.push(
242
+ `<g class="series-toggle" data-series-id="${seriesIndex}" role="button" tabindex="0" aria-pressed="true">` +
243
+ `<circle class="series-swatch" data-dark="${dark}" data-light="${light}" cx="${legendX.toFixed(1)}" cy="20" r="4" fill="${dark}"/>` +
244
+ `<text class="legend" x="${(legendX + 8).toFixed(1)}" y="24">${esc(name)}</text></g>`,
245
+ );
246
+ });
247
+ parts.push(
248
+ `<text class="axis-label" x="${(left + plotWidth / 2).toFixed(1)}" y="${height - 4}" text-anchor="middle">Model request</text></svg>`,
249
+ );
250
+ return parts.join("");
251
+ }
252
+
253
+ export function lineChartForTest(title, series, opts) {
254
+ return lineChart(title, series, opts);
255
+ }
256
+
257
+ const figure = (chart) =>
258
+ `<figure class="usage-figure">${chart}<button type="button" class="png-button" title="Download this chart as a PNG image">PNG</button></figure>`;
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Rule-based (non-LLM) cache-miss attribution
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const MISS_THRESHOLD = 0.5; // hit rate below this gets an explanation row
265
+ const TTL_MINUTES = 5; // Anthropic-style default prefix retention
266
+
267
+ export function analyzeMisses(requests, events) {
268
+ const evByReq = new Map();
269
+ for (const ev of events) {
270
+ if (!evByReq.has(ev.requestNumber)) evByReq.set(ev.requestNumber, []);
271
+ evByReq.get(ev.requestNumber).push(ev);
272
+ }
273
+
274
+ const noCacheReporting = requests.every((r) => r.cached === 0 && r.cacheWrite === 0);
275
+ const misses = [];
276
+ const summary = new Map();
277
+
278
+ for (let i = 0; i < requests.length; i++) {
279
+ const r = requests[i];
280
+ const prompt = r.fresh + r.cached + r.cacheWrite;
281
+ if (prompt <= 0) continue;
282
+ const hit = r.cached / prompt;
283
+ if (hit >= MISS_THRESHOLD) continue;
284
+
285
+ const causes = [];
286
+ const prev = i > 0 ? requests[i - 1] : null;
287
+
288
+ if (i === 0) {
289
+ causes.push(["first request", "cold session: nothing cached yet, first write builds the prefix"]);
290
+ }
291
+ if (prev && (prev.provider !== r.provider || prev.model !== r.model)) {
292
+ causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: prefix invalidated by provider/model change`]);
293
+ }
294
+ for (const ev of evByReq.get(r.number) || []) {
295
+ if (ev.kind === "compaction") {
296
+ causes.push(["compaction", "context was rewritten by compaction; cache rebuilds on the new prefix"]);
297
+ } else if (ev.kind === "model" && r.number > 1 && !causes.some(([c]) => c === "model switch")) {
298
+ causes.push(["model switch", `${ev.label}: prefix invalidated by provider/model change`]);
299
+ } else if (ev.kind === "thinking" && r.number > 1) {
300
+ causes.push(["thinking change", `${ev.label}: request parameters changed`]);
301
+ }
302
+ }
303
+ if (prev && r.isoTs && prev.isoTs) {
304
+ const gapMin = (Date.parse(r.isoTs) - Date.parse(prev.isoTs)) / 60000;
305
+ if (gapMin > TTL_MINUTES && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
306
+ causes.push(["TTL expiry", `gap ${gapMin.toFixed(0)}min > ${TTL_MINUTES}min retention: cached prefix likely expired`]);
307
+ }
308
+ }
309
+ if (r.cacheWrite > 0 && r.fresh > r.cached && !causes.length) {
310
+ causes.push(["cache rebuild", "fresh prefix written this turn (write-heavy); next turn should read it back"]);
311
+ }
312
+ if (prev && !causes.length) {
313
+ const pPrompt = prev.fresh + prev.cached + prev.cacheWrite;
314
+ const pHit = pPrompt > 0 ? prev.cached / pPrompt : 0;
315
+ if (pHit < MISS_THRESHOLD) {
316
+ causes.push(["cache rebuild", "previous turn lost the cache; this turn still pays fresh input while the new prefix is rebuilt"]);
317
+ }
318
+ }
319
+ if (!causes.length) {
320
+ causes.push(["unclassified", "no rule matched; usually new content appended beyond the cached prefix (large tool results / long user input)"]);
321
+ }
322
+
323
+ for (const [cause] of causes) {
324
+ const s = summary.get(cause) || { count: 0, fresh: 0 };
325
+ s.count += 1;
326
+ s.fresh += r.fresh;
327
+ summary.set(cause, s);
328
+ }
329
+ misses.push({ number: r.number, timestamp: r.timestamp, hit, fresh: r.fresh, causes });
330
+ }
331
+
332
+ const summaryArr = [...summary.entries()].sort((a, b) => b[1].fresh - a[1].fresh);
333
+ return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD, ttlMinutes: TTL_MINUTES };
334
+ }
335
+
336
+ // Streak detection: many consecutive requests stuck in a mid hit-rate band.
337
+ // Persistent mid-band hit rate usually means each turn appends a lot of new
338
+ // content (big tool outputs / long replies), not a broken cache.
339
+ // Band starts at 50% so it lines up with the miss panel (<50%); per-request
340
+ // fresh must exceed STREAK_MIN_FRESH so tiny early-session prefixes (where a
341
+ // few hundred fresh tokens drag the ratio down) do not create fake streaks.
342
+ const STREAK_LOW = 0.5;
343
+ const STREAK_HIGH = 0.975;
344
+ const STREAK_MIN_LEN = 3;
345
+ const STREAK_MIN_FRESH = 800; // tokens per request to count as a streak member
346
+ const STREAK_MIN_PROMPT = 4000; // ignore tiny prefixes where ratios are meaningless
347
+ // Membership hit rate excludes cacheWrite from the denominator: during an
348
+ // Anthropic-style rebuild cacheWrite is huge and would fake a mid-band hit.
349
+
350
+ export function analyzeStreaks(requests) {
351
+ const streaks = [];
352
+ let cur = null;
353
+ let gaps = 0; // consecutive non-member requests inside the current run
354
+ const close = () => {
355
+ if (cur && cur.hits.length >= STREAK_MIN_LEN) streaks.push(cur);
356
+ cur = null;
357
+ gaps = 0;
358
+ };
359
+ for (const r of requests) {
360
+ const prompt = r.fresh + r.cached; // cacheWrite excluded (see note above)
361
+ let hit = 0;
362
+ let member = false;
363
+ if (prompt >= STREAK_MIN_PROMPT) {
364
+ hit = r.cached / prompt;
365
+ member = hit >= STREAK_LOW && hit < STREAK_HIGH && r.fresh >= STREAK_MIN_FRESH;
366
+ }
367
+ if (member) {
368
+ if (!cur) cur = { from: r.number, to: r.number, hits: [], fresh: 0, tools: new Map(), replyTokens: 0 };
369
+ cur.to = r.number;
370
+ cur.hits.push(hit);
371
+ cur.fresh += r.fresh;
372
+ cur.replyTokens += r.prevOutput;
373
+ for (const g of r.gapTools || []) {
374
+ const tok = Math.round(g.chars / 4); // ~4 chars per token
375
+ cur.tools.set(g.tool, (cur.tools.get(g.tool) || 0) + tok);
376
+ }
377
+ gaps = 0;
378
+ } else if (cur) {
379
+ // tolerate a single healthy/outlier turn inside a streak; two end it
380
+ gaps += 1;
381
+ if (gaps >= 2) close();
382
+ }
383
+ }
384
+ close();
385
+ for (const s of streaks) {
386
+ s.avgHit = s.hits.reduce((a, b) => a + b, 0) / s.hits.length;
387
+ s.toolList = [...s.tools.entries()].sort((a, b) => b[1] - a[1]);
388
+ s.toolTokens = s.toolList.reduce((sum, [, v]) => sum + v, 0);
389
+ // how much of the fresh tokens the trace can explain (heuristic)
390
+ s.coverage = s.fresh > 0 ? Math.min(1, (s.toolTokens + s.replyTokens) / s.fresh) : 0;
391
+ }
392
+ return streaks;
393
+ }
394
+
395
+ function renderStreakPanel(streaks) {
396
+ const rows = streaks
397
+ .map((s) => {
398
+ const tools = s.toolList
399
+ .slice(0, 4)
400
+ .map(([t, v]) => `${esc(t)} ${fmtInt(v)}`)
401
+ .join(", ");
402
+ const pct = (x) => (x * 100).toFixed(1) + "%";
403
+ return `<tr><td>#${s.from}–#${s.to}</td><td>${s.hits.length}</td><td>${pct(s.avgHit)}</td>` +
404
+ `<td>${fmtInt(s.fresh)}</td>` +
405
+ `<td style="white-space:normal">tool outputs: ${tools || "-"} · assistant replies: ${fmtInt(s.replyTokens)} · trace explains ~${pct(s.coverage)} of fresh</td></tr>`;
406
+ })
407
+ .join("");
408
+ return `<div class="usage-panel"><h2>Hit-rate streak analysis</h2>` +
409
+ `<p class="usage-note">Rule-based, no LLM. A streak is ${STREAK_MIN_LEN}+ requests (single healthy outlier tolerated) with hit rate ` +
410
+ `${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (hit excludes cacheWrite; fresh ≥ ${STREAK_MIN_FRESH} tok, prefix ≥ ${STREAK_MIN_PROMPT}): ` +
411
+ `usually each turn appends large fresh content (big tool outputs / long replies), not a cache failure. ` +
412
+ `Below ${(MISS_THRESHOLD * 100).toFixed(0)}% see the miss panel above. Tool tokens estimated at 4 chars/token.</p>` +
413
+ `<div class="usage-table-wrap" style="max-height:260px"><table>` +
414
+ `<thead><tr><th>Requests</th><th>Turns</th><th>Avg hit</th><th>Total fresh</th><th>Trace (fresh composition)</th></tr></thead>` +
415
+ `<tbody>${rows || `<tr><td colspan="5" class="empty">No mid-band streaks found.</td></tr>`}</tbody></table></div></div>`;
416
+ }
417
+
418
+ function renderMissPanel(miss) {
419
+ if (miss.noCacheReporting) {
420
+ return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
421
+ `<p class="empty">This provider reports no cache token fields (cacheRead/cacheWrite always 0), so hit rate is always 0 and misses cannot be attributed.</p></div>`;
422
+ }
423
+ const summaryHtml = miss.summary
424
+ .map(([cause, s]) => `<div class="usage-tool"><span>${esc(cause)} (${s.count})</span><strong>${fmtInt(s.fresh)} fresh</strong></div>`)
425
+ .join("") || `<p class="empty">No request fell below ${(miss.threshold * 100).toFixed(0)}% hit rate.</p>`;
426
+ const rows = miss.misses
427
+ .map((m) =>
428
+ `<tr><td>${m.number}</td><td>${esc(m.timestamp)}</td><td>${fmtPct1(m.hit)}</td><td>${fmtInt(m.fresh)}</td>` +
429
+ `<td style="white-space:normal">${m.causes.map(([c, d]) => `<strong>${esc(c)}</strong>: ${esc(d)}`).join("<br>")}</td></tr>`,
430
+ )
431
+ .join("");
432
+ return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
433
+ `<p class="usage-note">Rule-based attribution, no LLM. Rows are requests below ${(miss.threshold * 100).toFixed(0)}% hit rate. ` +
434
+ `TTL rule assumes ${miss.ttlMinutes}min prefix retention.</p>` +
435
+ `<div class="usage-table-wrap" style="max-height:340px"><table>` +
436
+ `<thead><tr><th>#</th><th>Time</th><th>Hit rate</th><th>Fresh</th><th>Cause</th></tr></thead>` +
437
+ `<tbody>${rows || `<tr><td colspan="5" class="empty">None</td></tr>`}</tbody></table></div>` +
438
+ `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:12px">${summaryHtml}</div></div>`;
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // render_usage_dashboard port
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export function renderDashboard(data) {
446
+ const { requests, events, toolCalls, compactions } = data;
447
+ if (!requests.length) {
448
+ return `<p class="empty">No assistant responses with token usage were found in this session.</p>`;
449
+ }
450
+
451
+ const timestamps = requests.map((r) => r.timestamp);
452
+ const cached = requests.map((r) => r.cached);
453
+ const cacheWrites = requests.map((r) => r.cacheWrite);
454
+ const fresh = requests.map((r) => r.fresh);
455
+ const outputs = requests.map((r) => r.output);
456
+ const reasoning = requests.map((r) => r.reasoning);
457
+ const rates = requests.map((r) => (r.fresh + r.cached + r.cacheWrite > 0 ? r.cached / (r.fresh + r.cached + r.cacheWrite) : 0));
458
+ const cumulativeRates = [];
459
+ let runCached = 0, runPrompt = 0;
460
+ for (const r of requests) {
461
+ runCached += r.cached;
462
+ runPrompt += r.fresh + r.cached + r.cacheWrite;
463
+ cumulativeRates.push(runPrompt > 0 ? runCached / runPrompt : 0);
464
+ }
465
+
466
+ const tot = (f) => requests.reduce((s, r) => s + f(r), 0);
467
+ const totalFresh = tot((r) => r.fresh), totalCached = tot((r) => r.cached),
468
+ totalWrite = tot((r) => r.cacheWrite), totalPrompt = totalFresh + totalCached + totalWrite,
469
+ totalOutput = tot((r) => r.output);
470
+
471
+ const cacheHitRate = totalPrompt > 0 && (totalCached > 0 || totalWrite > 0) ? totalCached / totalPrompt : null;
472
+
473
+ const cards = [
474
+ ["Model requests", fmtInt(requests.length)],
475
+ ["Cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
476
+ ["Cached input", fmtInt(totalCached)],
477
+ ["Cache writes", fmtInt(totalWrite)],
478
+ ["Fresh input", fmtInt(totalFresh)],
479
+ ["Total prompt input", fmtInt(totalPrompt)],
480
+ ["Output tokens", fmtInt(totalOutput)],
481
+ ["Compactions", String(compactions)],
482
+ ];
483
+ const cardsHtml = cards
484
+ .map(([l, v]) => `<div class="usage-card"><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`)
485
+ .join("");
486
+
487
+ const charts = [
488
+ figure(lineChart("Prompt input by request",
489
+ [["cached", cached], ["cache writes", cacheWrites], ["fresh", fresh]],
490
+ { timestamps, events })),
491
+ ];
492
+ if (cacheHitRate !== null) {
493
+ charts.push(figure(lineChart("Cache hit rate",
494
+ [["request", rates], ["cumulative", cumulativeRates]],
495
+ { yMax: 1.0, percent: true, timestamps })));
496
+ }
497
+ charts.push(figure(lineChart("Output and reasoning tokens",
498
+ [["output", outputs], ["reasoning", reasoning]], { timestamps })));
499
+
500
+ const showHit = cacheHitRate !== null;
501
+ const rows = requests
502
+ .map((r) =>
503
+ `<tr><td>${r.number}</td><td>${esc(r.timestamp)}</td><td>${esc(r.provider)}</td>` +
504
+ `<td>${r.responseProvider ? esc(r.responseProvider) : "-"}</td>` +
505
+ `<td>${esc(r.model)}</td><td>${fmtInt(r.fresh)}</td><td>${fmtInt(r.cached)}</td>` +
506
+ `<td>${fmtInt(r.cacheWrite)}</td><td>${fmtInt(r.fresh + r.cached + r.cacheWrite)}</td>` +
507
+ `<td>${showHit ? fmtPct1(r.cached / Math.max(1, r.fresh + r.cached + r.cacheWrite)) : "N/A"}</td>` +
508
+ `<td>${fmtInt(r.output)}</td><td>${esc(r.stopReason, true)}</td></tr>`,
509
+ )
510
+ .join("");
511
+
512
+ const toolRows =
513
+ toolCalls.map(([name, c]) => `<div class="usage-tool"><span>${esc(name)}</span><strong>${c}</strong></div>`).join("") ||
514
+ `<p class="empty">No tool calls.</p>`;
515
+
516
+ return (
517
+ `<div class="usage-cards">${cardsHtml}</div>` +
518
+ `<p class="usage-note">Hover a request for exact values, ` +
519
+ `select a legend item to hide a series, and use PNG to save a chart. Event markers show ` +
520
+ `compactions, model or thinking changes.</p>` +
521
+ `<div class="usage-charts">${charts.join("")}</div>` +
522
+ renderMissPanel(analyzeMisses(requests, events)) +
523
+ renderStreakPanel(analyzeStreaks(requests)) +
524
+ `<div class="usage-details">` +
525
+ `<div class="usage-panel"><h2>Requests</h2><div class="usage-table-wrap"><table>` +
526
+ `<thead><tr><th>#</th><th>Time</th><th>Provider</th><th>Response Provider</th><th>Model</th>` +
527
+ `<th>Fresh</th><th>Cached</th><th>Written</th><th>Prompt</th><th>Hit rate</th><th>Output</th>` +
528
+ `<th>Stop</th></tr></thead><tbody>${rows}</tbody></table></div></div>` +
529
+ `<div class="usage-panel"><h2>Tool calls</h2>${toolRows}</div>` +
530
+ `</div><div class="usage-tooltip" role="status" aria-live="polite"></div>`
531
+ );
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Full page
536
+ // ---------------------------------------------------------------------------
537
+
538
+ export function buildCacheExportHtml(data, opts = {}) {
539
+ const title = opts.title || "cache export";
540
+ const n = data.requests.length;
541
+ return `<!doctype html>
542
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
543
+ <title>${esc(title)}</title>
544
+ <style>
545
+ :root {
546
+ --bg: #000000; --surface: #101419; --surface-2: #161b21; --text: #d8dee9;
547
+ --bright: #e5e7eb; --muted: #667085; --line: #141922; --line-strong: #2d3748;
548
+ --accent: #a7f3f0; --accent-text: #061a1a; --accent-soft: #a7f3f0;
549
+ --mono: "JetBrains Mono", "SFMono-Regular", Consolas, Menlo, monospace;
550
+ }
551
+ html { background: var(--bg); }
552
+ body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 15px; line-height: 1.6; }
553
+ main { max-width: 1080px; margin: 0 auto; padding: 40px 24px 80px; }
554
+ .eyebrow { color: var(--accent); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; }
555
+ .eyebrow::before { content: "$ "; color: var(--muted); }
556
+ h1 { color: var(--bright); font-size: 1.6rem; font-weight: 500; margin: 6px 0 4px; }
557
+ .sub { color: var(--muted); font-size: .78rem; margin: 0 0 30px; }
558
+ .usage-shell { margin-top: 18px; }
559
+ /* keep hover points visible on dark page chrome for both chart palettes */
560
+ .hover-point { stroke: #ffffff; stroke-width: 2; }
561
+ ${USAGE_STYLES}
562
+ </style></head>
563
+ <body><main>
564
+ <div class="eyebrow">usage &amp; cache</div>
565
+ <h1>${esc(title)}</h1>
566
+ <p class="sub">tau-style dashboard rendered by pi-cache-dashboard · ${n} requests · times in ${esc(tzLabel())}${opts.sessionId ? " · session " + esc(opts.sessionId) : ""}</p>
567
+ <section class="usage-shell" id="panel-usage">${renderDashboard(data)}</section>
568
+ <script>${USAGE_SCRIPT}</script>
569
+ </main></body></html>`;
570
+ }