// tau-style usage/cache dashboard renderer.
// Logic ported from huggingface/tau src/tau_coding/session_usage.py (render_usage_dashboard),
// interactivity/styles auto-extracted into ./tau-assets.mjs.
import { USAGE_STYLES, USAGE_SCRIPT } from "./tau-assets.ts";
// ---------------------------------------------------------------------------
// Data aggregation from pi session entries
// ---------------------------------------------------------------------------
function hhmmss(ts) {
const iso = String(ts || "");
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return "";
// render in the machine's local timezone (Asia/Shanghai here -> Beijing time)
return d.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
export function tzLabel() {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
const offMin = -new Date().getTimezoneOffset();
const sign = offMin >= 0 ? "+" : "-";
const abs = Math.abs(offMin);
const off = `${sign}${String(Math.trunc(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
return `${tz} (UTC${off})`;
}
export function parseEntries(entries) {
const requests = [];
const events = [];
const toolCounts = {};
let compactions = 0;
let prevKey = null;
const longCacheUntilByKey = new Map();
let gapTools = []; // tool results accumulated since the previous request
const addEvent = (num, kind, label, ts) =>
events.push({ requestNumber: num, timestamp: hhmmss(ts), kind, label });
for (const e of entries || []) {
const t = e?.type;
const ts = e?.timestamp;
if (t === "compaction") {
compactions += 1;
addEvent(requests.length + 1, "compaction", "Compaction", ts);
continue;
}
if (t === "model_change") {
const mid = e.modelId || e.model || "?";
const prov = e.provider ? `${e.provider}/` : "";
addEvent(requests.length + 1, "model", `Model changed to ${prov}${mid}`, ts);
continue;
}
if (t === "thinking_level_change") {
const lvl = e.thinkingLevel || e.level || "off";
addEvent(requests.length + 1, "thinking", `Thinking changed to ${lvl}`, ts);
continue;
}
if (t !== "message") continue;
const m = e.message;
if (!m || m.role !== "assistant") {
if (m && m.role === "toolResult") {
let chars = 0;
for (const blk of m.content || []) {
if (blk && typeof blk === "object" && typeof blk.text === "string") chars += blk.text.length;
}
gapTools.push({ tool: m.toolName || "?", chars });
}
continue;
}
// pi stores tool calls inside assistant content blocks
const calledTools = [];
for (const blk of m.content || []) {
if (blk && typeof blk === "object" && blk.type === "toolCall") {
const name = blk.tool || blk.name || "?";
toolCounts[name] = (toolCounts[name] || 0) + 1;
calledTools.push(name);
}
}
const u = m.usage;
if (!u) continue;
const fresh = u.input || 0;
const cached = u.cacheRead || 0;
const cacheWrite = u.cacheWrite || 0;
const output = u.output || 0;
if (fresh + cached + cacheWrite + output === 0) continue;
const num = requests.length + 1;
const provider = m.provider || "?";
const model = m.model || "?";
const api = m.api || "";
const key = `${provider}/${model}`;
if (prevKey !== null && key !== prevKey && !events.some((ev) => ev.requestNumber === num && ev.kind === "model")) {
addEvent(num, "model", `Model changed to ${provider}/${model}`, ts);
}
prevKey = key;
const tsMs = Date.parse(String(ts || ""));
let longCacheUntil = longCacheUntilByKey.get(key) || 0;
if (u.cacheRead === 0 && u.cacheWrite > 0 && !u.cacheWrite1h) {
longCacheUntil = 0;
longCacheUntilByKey.delete(key);
}
if (u.cacheWrite1h > 0 && Number.isFinite(tsMs)) {
longCacheUntil = Math.max(longCacheUntil, tsMs + LONG_TTL_MINUTES * 60000);
longCacheUntilByKey.set(key, longCacheUntil);
}
const cacheTtlMinutes = Number.isFinite(tsMs) && tsMs <= longCacheUntil
? LONG_TTL_MINUTES
: DEFAULT_TTL_MINUTES;
const cost = u.cost && typeof u.cost.total === "number" ? u.cost.total : null;
requests.push({
number: num,
isoTs: String(ts || ""),
timestamp: hhmmss(ts),
provider,
model,
api,
responseProvider: null,
fresh,
cached,
cacheWrite,
cacheWrite1h: u.cacheWrite1h || 0,
cacheTtlMinutes,
output,
reasoning: u.reasoning || 0,
stopReason: m.stopReason || "",
estimatedCost: cost,
gapTools,
calledTools,
prevOutput: requests.length ? requests[requests.length - 1].output : 0,
});
gapTools = [];
}
const toolCalls = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
return { requests, events, toolCalls, compactions };
}
// ---------------------------------------------------------------------------
// Helpers (ported from session_usage.py)
// ---------------------------------------------------------------------------
function esc(s, quote = false) {
let out = String(s ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
if (quote) out = out.replaceAll('"', """);
return out;
}
const SERIES_COLORS = {
// [dark, light] - derived from tau's built-in themes
cached: ["#a7f3f0", "#0f766e"],
"cache writes": ["#9cffb1", "#166534"],
fresh: ["#ff4f4f", "#b91c1c"],
request: ["#ff4f4f", "#b91c1c"],
cumulative: ["#93c5fd", "#2563eb"],
output: ["#c084fc", "#9333ea"],
reasoning: ["#9cffb1", "#166534"],
event: ["#a7f3f0", "#0f766e"],
};
const seriesColor = (name) => SERIES_COLORS[name] || ["#93c5fd", "#2563eb"];
function compactNumber(v) {
if (v >= 1e6) return (v / 1e6).toFixed(1) + "m";
if (v >= 1e3) return (v / 1e3).toFixed(0) + "k";
return v.toFixed(0);
}
function fmtCost(v) {
if (v === null || v === undefined) return "$N/A";
if (v > 0 && v < 0.01) return `$${v.toFixed(3)}`;
return `$${v.toFixed(2)}`;
}
const fmtInt = (n) => n.toLocaleString("en-US");
const fmtPct1 = (x) => (x * 100).toFixed(1) + "%";
// ---------------------------------------------------------------------------
// _line_chart port
// ---------------------------------------------------------------------------
function lineChart(title, series, opts = {}) {
const { yMax = null, percent = false, timestamps = null, events = [], breakBefore = [], requestNumbers = null } = opts;
const width = 900, height = 330, left = 68, right = 20, top = 42, bottom = 52;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const count = Math.max(0, ...series.map(([, v]) => v.length));
const observedMax = Math.max(0, ...series.flatMap(([, values]) => values.filter(Number.isFinite)));
const breaks = new Set(breakBefore);
let maximum = yMax || Math.max(observedMax, 1);
if (!percent) {
const magnitude = 10 ** Math.max(0, String(Math.trunc(maximum)).length - 1);
maximum = Math.ceil(maximum / magnitude) * magnitude;
}
const point = (index, value) => [
left + (index / Math.max(count - 1, 1)) * plotWidth,
top + plotHeight - (value / maximum) * plotHeight,
];
const tsAttr = timestamps ? ` data-timestamps="${esc(timestamps.join("|"), true)}"` : "";
// Global per-request numbers (same length as the series) so a small page
// script can rewrite tau's relative "Request N" tooltip line to the global
// number inside segment views (tau hardcodes index + 1).
const reqAttr = requestNumbers ? ` data-requestnumbers="${esc(requestNumbers.join("|"), true)}"` : "";
const parts = [
``,
`${esc(title)} `,
];
for (let tick = 0; tick < 6; tick++) {
const value = (maximum * tick) / 5;
const y = top + plotHeight - (tick * plotHeight) / 5;
const label = percent ? `${(value * 100).toFixed(0)}%` : compactNumber(value);
parts.push(
` ` +
`${label} `,
);
}
for (let index = 0; index < count; index++) {
if (count <= 20 || index % Math.max(1, Math.trunc(count / 12)) === 0 || index === count - 1) {
const [x] = point(index, 0);
parts.push(`${requestNumbers?.[index] ?? index + 1} `);
}
}
parts.push(
` `,
);
const [evDark, evLight] = seriesColor("event");
events
.filter((event) => event.requestNumber >= 1 && event.requestNumber <= count)
.forEach((event, eventIndex) => {
const [x] = point(Math.max(0, Math.min(count - 1, event.requestNumber - 1)), 0);
const markerY = top + 7 + (eventIndex % 3) * 9;
// data-request stays RELATIVE (tau's tooltip matches index + 1); the human
// description shows the GLOBAL request number (chartEvents keeps it).
const shown = event.globalRequestNumber ?? event.requestNumber;
const description = `${event.label} before request ${shown} at ${event.timestamp}`;
parts.push(
`` +
`${esc(description)} ` +
` ` +
` `,
);
});
series.forEach(([name, values], seriesIndex) => {
const [dark, light] = seriesColor(name);
const runs = [];
let run = [];
values.forEach((value, i) => {
if (breaks.has(i) || !Number.isFinite(value)) {
if (run.length) runs.push(run);
run = [];
}
if (Number.isFinite(value)) run.push(point(i, value).map((c) => c.toFixed(1)).join(","));
});
if (run.length) runs.push(run);
const broken = runs.map((points) =>
` `,
).join("");
// tau's tooltip script reads `series.querySelector(".series-line").points.getItem(i)`,
// which returns only the FIRST polyline; with break splits that one is short and
// getItem(i) throws IndexSizeError past the first run, killing the tooltip. Keep a
// hidden full-coverage `.series-line` first in the group so the tooltip works at
// every index while the visible broken polylines still draw the gaps.
const full = ` point(i, Number.isFinite(v) ? v : 0).map((c) => c.toFixed(1)).join(",")).join(" ")}"/>`;
const labels = values.map((v) => !Number.isFinite(v) ? "N/A" : (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
parts.push(
`` +
full +
broken +
` `,
);
});
let legendX = width - right;
[...series].reverse().forEach(([name], i) => {
const seriesIndex = series.length - 1 - i;
const [dark, light] = seriesColor(name);
legendX -= 18 + name.length * 8;
parts.push(
`` +
` ` +
`${esc(name)} `,
);
});
parts.push(
`Model request `,
);
return parts.join("");
}
export function lineChartForTest(title, series, opts) {
return lineChart(title, series, opts);
}
const figure = (chart) =>
`${chart}PNG `;
// ---------------------------------------------------------------------------
// Rule-based (non-LLM) cache-miss attribution
// ---------------------------------------------------------------------------
const MISS_THRESHOLD = 0.5; // hit rate below this gets an explanation row
const PREFIX_REUSE_THRESHOLD = 0.5;
const DEFAULT_TTL_MINUTES = 5;
const LONG_TTL_MINUTES = 60;
function reusablePrefix(request) {
const prompt = request.fresh + request.cached + request.cacheWrite;
if (request.api !== "anthropic-messages") return prompt;
const explicitlyCached = request.cached + request.cacheWrite;
return explicitlyCached > 0 ? explicitlyCached : prompt;
}
function ttlMinutesAfter(request) {
return request.cacheTtlMinutes ?? (request.cacheWrite1h > 0 ? LONG_TTL_MINUTES : DEFAULT_TTL_MINUTES);
}
export function analyzeMisses(requests, events) {
const evByReq = new Map();
for (const ev of events) {
if (!evByReq.has(ev.requestNumber)) evByReq.set(ev.requestNumber, []);
evByReq.get(ev.requestNumber).push(ev);
}
const noCacheReporting = requests.every((r) => r.cached === 0 && r.cacheWrite === 0);
const misses = [];
const summary = new Map();
for (let i = 0; i < requests.length; i++) {
const r = requests[i];
const prompt = r.fresh + r.cached + r.cacheWrite;
if (prompt <= 0) continue;
const hit = r.cached / prompt;
if (hit >= MISS_THRESHOLD) continue;
const causes = [];
const prev = i > 0 ? requests[i - 1] : null;
const expectedPrefix = prev ? reusablePrefix(prev) : 0;
const prefixReuse = expectedPrefix > 0 ? Math.min(1, r.cached / expectedPrefix) : 0;
if (i === 0) {
causes.push(["first request", "cold session: nothing cached yet; the prefix can only be reused by a later request"]);
}
if (prev && (prev.provider !== r.provider || prev.model !== r.model)) {
causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: prefix invalidated by provider/model change`]);
}
for (const ev of evByReq.get(r.number) || []) {
if (ev.kind === "compaction") {
causes.push(["compaction", "context was rewritten by compaction; cache rebuilds on the new prefix"]);
} else if (ev.kind === "model" && r.number > 1 && !causes.some(([c]) => c === "model switch")) {
causes.push(["model switch", `${ev.label}: prefix invalidated by provider/model change`]);
} else if (ev.kind === "thinking" && r.number > 1) {
causes.push(["thinking change", `${ev.label}: request parameters changed`]);
}
}
if (prev && r.isoTs && prev.isoTs) {
const gapMin = (Date.parse(r.isoTs) - Date.parse(prev.isoTs)) / 60000;
const ttlMinutes = ttlMinutesAfter(prev);
if (gapMin > ttlMinutes && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
causes.push(["possible TTL expiry", `idle gap ${gapMin.toFixed(0)}min > ${ttlMinutes}min heuristic; actual retention depends on the provider`]);
}
}
if (prev && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.length) {
const writeDetail = r.cacheWrite > 0 ? `; provider also reported ${r.cacheWrite.toLocaleString("en-US")} tokens written for the replacement prefix` : "";
causes.push(["cache rebuild", `only ${(prefixReuse * 100).toFixed(1)}% of the prior reusable prefix was read; the cache was likely lost or invalidated${writeDetail}`]);
}
if (r.cacheWrite > 0 && !causes.length) {
causes.push(["cache write", `provider reported ${r.cacheWrite.toLocaleString("en-US")} tokens written; a low read-hit rate is expected while a prefix is created or extended`]);
}
if (prev && !causes.length) {
const toolChars = (r.gapTools || []).reduce((sum, tool) => sum + tool.chars, 0);
const toolTokens = Math.round(toolChars / 4);
const toolDetail = toolTokens > 0 ? `, including ~${toolTokens.toLocaleString("en-US")} estimated tool-output tokens` : "";
causes.push(["new input", `reused ${(prefixReuse * 100).toFixed(1)}% of the prior prefix; ${r.fresh.toLocaleString("en-US")} fresh tokens were newly appended${toolDetail}`]);
}
if (!causes.length) {
causes.push(["unclassified", "no rule matched; usually new content appended beyond the cached prefix (large tool results / long user input)"]);
}
for (const [cause] of causes) {
const s = summary.get(cause) || { count: 0, fresh: 0 };
s.count += 1;
s.fresh += r.fresh;
summary.set(cause, s);
}
misses.push({ number: r.number, timestamp: r.timestamp, hit, fresh: r.fresh, causes });
}
const summaryArr = [...summary.entries()].sort((a, b) => b[1].fresh - a[1].fresh);
return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD,
ttlMinutes: DEFAULT_TTL_MINUTES, longTtlMinutes: LONG_TTL_MINUTES };
}
// Streak detection: many consecutive requests stuck in a mid hit-rate band.
// Persistent mid-band hit rate usually means each turn appends a lot of new
// content (big tool outputs / long replies), not a broken cache.
// Band starts at 50% so it lines up with the miss panel (<50%); per-request
// fresh must exceed STREAK_MIN_FRESH so tiny early-session prefixes (where a
// few hundred fresh tokens drag the ratio down) do not create fake streaks.
const STREAK_LOW = 0.5;
const STREAK_HIGH = 0.975;
const STREAK_MIN_LEN = 3;
const STREAK_MIN_FRESH = 800; // tokens per request to count as a streak member
const STREAK_MIN_PROMPT = 4000; // ignore tiny prefixes where ratios are meaningless
// Requests that report cacheWrite are cache-creation/rebuild events, not
// append-heavy read-hit streak members, so they terminate the current streak.
export function analyzeStreaks(requests, events = []) {
const streaks = [];
let cur = null;
let gaps = 0; // consecutive non-member requests inside the current run
let prevKey = null;
const resetBefore = new Set(events
.filter((event) => event.kind === "compaction" || event.kind === "model")
.map((event) => event.requestNumber));
const close = () => {
if (cur && cur.hits.length >= STREAK_MIN_LEN) streaks.push(cur);
cur = null;
gaps = 0;
};
for (const r of requests) {
const key = `${r.provider}/${r.model}`;
if ((prevKey !== null && key !== prevKey) || resetBefore.has(r.number)) close();
prevKey = key;
if (r.cacheWrite > 0) {
close();
continue;
}
const prompt = r.fresh + r.cached;
let hit = 0;
let member = false;
if (prompt >= STREAK_MIN_PROMPT) {
hit = r.cached / prompt;
member = hit >= STREAK_LOW && hit < STREAK_HIGH && r.fresh >= STREAK_MIN_FRESH;
}
if (member) {
if (!cur) cur = { from: r.number, to: r.number, hits: [], fresh: 0, tools: new Map(), replyTokens: 0 };
cur.to = r.number;
cur.hits.push(hit);
cur.fresh += r.fresh;
cur.replyTokens += r.prevOutput;
for (const g of r.gapTools || []) {
const tok = Math.round(g.chars / 4); // ~4 chars per token
cur.tools.set(g.tool, (cur.tools.get(g.tool) || 0) + tok);
}
gaps = 0;
} else if (cur) {
// tolerate a single healthy/outlier turn inside a streak; two end it
gaps += 1;
if (gaps >= 2) close();
}
}
close();
for (const s of streaks) {
s.avgHit = s.hits.reduce((a, b) => a + b, 0) / s.hits.length;
s.toolList = [...s.tools.entries()].sort((a, b) => b[1] - a[1]);
s.toolTokens = s.toolList.reduce((sum, [, v]) => sum + v, 0);
// how much of the fresh tokens the trace can explain (heuristic)
s.coverage = s.fresh > 0 ? Math.min(1, (s.toolTokens + s.replyTokens) / s.fresh) : 0;
}
return streaks;
}
function renderStreakPanel(streaks) {
const rows = streaks
.map((s) => {
const tools = s.toolList
.slice(0, 4)
.map(([t, v]) => `${esc(t)} ${fmtInt(v)}`)
.join(", ");
const pct = (x) => (x * 100).toFixed(1) + "%";
return `
#${s.from}–#${s.to} ${s.hits.length} ${pct(s.avgHit)} ` +
`${fmtInt(s.fresh)} ` +
`tool outputs: ${tools || "-"} · assistant replies: ${fmtInt(s.replyTokens)} · trace explains ~${pct(s.coverage)} of fresh `;
})
.join("");
return `Hit-rate streak analysis ` +
`
Rule-based, no LLM. A streak is ${STREAK_MIN_LEN}+ requests (single healthy outlier tolerated) with hit rate ` +
`${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (requests reporting cacheWrite break the streak; fresh ≥ ${STREAK_MIN_FRESH} tok, prefix ≥ ${STREAK_MIN_PROMPT}): ` +
`usually each turn appends large fresh content (big tool outputs / long replies), not a cache failure. ` +
`Below ${(MISS_THRESHOLD * 100).toFixed(0)}% see the miss panel above. Tool tokens estimated at 4 chars/token.
` +
`
` +
`Requests Turns Avg hit Total fresh Trace (fresh composition) ` +
`${rows || `No mid-band streaks found. `}
`;
}
function renderMissPanel(miss) {
if (miss.noCacheReporting) {
return `Cache miss analysis ` +
`
No cache reads or writes were reported. The provider may omit cache token fields, caching may be disabled, or this session may not have reused a prefix; hit rate cannot be determined.
`;
}
const summaryHtml = miss.summary
.map(([cause, s]) => `${esc(cause)} (${s.count}) ${fmtInt(s.fresh)} fresh
`)
.join("") || `No request fell below ${(miss.threshold * 100).toFixed(0)}% hit rate.
`;
const rows = miss.misses
.map((m) =>
`${m.number} ${esc(m.timestamp)} ${fmtPct1(m.hit)} ${fmtInt(m.fresh)} ` +
`${m.causes.map(([c, d]) => `${esc(c)} : ${esc(d)}`).join(" ")} `,
)
.join("");
return `Cache miss analysis ` +
`
Rule-based attribution, no LLM. Rows are requests below ${(miss.threshold * 100).toFixed(0)}% hit rate. ` +
`TTL attribution uses a ${miss.ttlMinutes}min idle-gap heuristic, or ${miss.longTtlMinutes}min after a reported 1h cache write; actual retention is provider-specific.
` +
`
` +
`# Time Hit rate Fresh Cause ` +
`${rows || `None `}
` +
`
${summaryHtml}
`;
}
// ---------------------------------------------------------------------------
// render_usage_dashboard port
// ---------------------------------------------------------------------------
export function buildContextSegments(requests, events) {
const starts = new Set(events
.filter((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber >= 1 && event.requestNumber <= requests.length)
.map((event) => event.requestNumber));
const segments = [];
let start = 0;
for (let index = 1; index < requests.length; index++) {
if (!starts.has(requests[index].number)) continue;
segments.push({ number: segments.length + 1, requests: requests.slice(start, index) });
start = index;
}
segments.push({ number: segments.length + 1, requests: requests.slice(start) });
return segments;
}
function countToolCalls(requests) {
const counts = new Map();
for (const request of requests) {
for (const name of request.calledTools || []) counts.set(name, (counts.get(name) || 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}
function eventsForRequests(events, requests) {
if (!requests.length) return [];
const from = requests[0].number;
const to = requests[requests.length - 1].number;
return events.filter((event) => event.requestNumber >= from && event.requestNumber <= to);
}
export function chartEvents(events, requests) {
if (!requests.length) return [];
const first = requests[0].number;
return events.map((event) => ({
...event,
requestNumber: event.requestNumber - first + 1, // relative, for tau's data-request matching
globalRequestNumber: event.requestNumber, // absolute, for readable labels
}));
}
function renderDashboardView(requests, events, toolCalls, compactions, scopeLabel) {
const timestamps = requests.map((r) => r.timestamp);
const requestNumbers = requests.map((r) => r.number);
const cached = requests.map((r) => r.cached);
const cacheWrites = requests.map((r) => r.cacheWrite);
const fresh = requests.map((r) => r.fresh);
const outputs = requests.map((r) => r.output);
const reasoning = requests.map((r) => r.reasoning);
const rates = requests.map((r) => (r.fresh + r.cached + r.cacheWrite > 0 ? r.cached / (r.fresh + r.cached + r.cacheWrite) : 0));
const contextBreakBefore = new Set();
const cacheBreakBefore = new Set();
const resetBefore = new Set(events
.filter((event) => event.kind === "compaction" || event.kind === "model")
.map((event) => event.requestNumber));
const cumulativeRates = [];
let runCached = 0, runPrompt = 0;
let previousKey = null;
let cacheWindows = 1;
let currentWindowStart = 0;
for (let index = 0; index < requests.length; index++) {
const r = requests[index];
const key = `${r.provider}/${r.model}`;
const contextReset = index > 0 && events.some((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber === r.number);
const cacheReset = index > 0 && (resetBefore.has(r.number) || (previousKey !== null && key !== previousKey));
if (contextReset) contextBreakBefore.add(index);
if (cacheReset) {
cacheBreakBefore.add(index);
cacheWindows += 1;
currentWindowStart = index;
runCached = 0;
runPrompt = 0;
}
runCached += r.cached;
runPrompt += r.fresh + r.cached + r.cacheWrite;
cumulativeRates.push(runPrompt > 0 ? runCached / runPrompt : 0);
previousKey = key;
}
const tot = (f) => requests.reduce((s, r) => s + f(r), 0);
const totalFresh = tot((r) => r.fresh), totalCached = tot((r) => r.cached),
totalWrite = tot((r) => r.cacheWrite), totalPrompt = totalFresh + totalCached + totalWrite,
totalOutput = tot((r) => r.output);
const cacheHitRate = totalPrompt > 0 && (totalCached > 0 || totalWrite > 0) ? totalCached / totalPrompt : null;
const windowRequests = requests.slice(currentWindowStart);
const windowPrompt = windowRequests.reduce((sum, r) => sum + r.fresh + r.cached + r.cacheWrite, 0);
const windowCached = windowRequests.reduce((sum, r) => sum + r.cached, 0);
const windowHitRate = windowPrompt > 0 && (windowCached > 0 || windowRequests.some((r) => r.cacheWrite > 0))
? windowCached / windowPrompt
: null;
const cards = [
["Scope", scopeLabel],
["Model requests", fmtInt(requests.length)],
["View cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
["Latest cache window", `#${cacheWindows} · ${windowHitRate !== null ? fmtPct1(windowHitRate) : "N/A"}`],
["Cached input", fmtInt(totalCached)],
["Reported cache writes", fmtInt(totalWrite)],
["Fresh input", fmtInt(totalFresh)],
["Total prompt input", fmtInt(totalPrompt)],
["Output tokens", fmtInt(totalOutput)],
["Compactions in view", String(compactions)],
];
const cardsHtml = cards
.map(([l, v]) => `${esc(l)} ${esc(v)}
`)
.join("");
const eventMarkers = chartEvents(events, requests);
const charts = [
figure(lineChart("Prompt input by request",
[["cached", cached], ["cache writes", cacheWrites], ["fresh", fresh]],
{ timestamps, requestNumbers, events: eventMarkers, breakBefore: contextBreakBefore })),
];
if (cacheHitRate !== null) {
charts.push(figure(lineChart("Cache hit rate",
[["request", rates], ["cumulative", cumulativeRates]],
{ yMax: 1.0, percent: true, timestamps, requestNumbers, events: eventMarkers, breakBefore: cacheBreakBefore })));
}
charts.push(figure(lineChart("Output and reasoning tokens",
[["output", outputs], ["reasoning", reasoning]], { timestamps, requestNumbers, events: eventMarkers, breakBefore: contextBreakBefore })));
const showHit = cacheHitRate !== null;
const rows = requests
.map((r) =>
`${r.number} ${esc(r.timestamp)} ${esc(r.provider)} ` +
`${r.responseProvider ? esc(r.responseProvider) : "-"} ` +
`${esc(r.model)} ${fmtInt(r.fresh)} ${fmtInt(r.cached)} ` +
`${fmtInt(r.cacheWrite)} ${fmtInt(r.fresh + r.cached + r.cacheWrite)} ` +
`${showHit ? fmtPct1(r.cached / Math.max(1, r.fresh + r.cached + r.cacheWrite)) : "N/A"} ` +
`${fmtInt(r.output)} ${esc(r.stopReason, true)} `,
)
.join("");
const toolRows =
toolCalls.map(([name, c]) => `${esc(name)} ${c}
`).join("") ||
`No tool calls.
`;
return (
`${cardsHtml}
` +
`Prompt and output lines break at a Context segment boundary (compaction or model change). Cache-window cumulative hit rate also resets at a model change. ` +
`TTL and service interruptions are shown only when recorded or inferred by the miss analysis; they do not create a Context segment.
` +
`${charts.join("")}
` +
renderMissPanel(analyzeMisses(requests, events)) +
renderStreakPanel(analyzeStreaks(requests, events)) +
`` +
`
Requests ` +
`# Time Provider Response Provider Model ` +
`Fresh Cached Written Prompt Hit rate Output ` +
`Stop ${rows}
` +
`
Tool calls ${toolRows}` +
`
`
);
}
export function renderDashboard(data) {
const { requests, events, toolCalls, compactions } = data;
if (!requests.length) {
return `No assistant responses with token usage were found in this session.
`;
}
const segments = buildContextSegments(requests, events);
const latest = segments[segments.length - 1];
const allView = renderDashboardView(requests, events, toolCalls, compactions, `All ${segments.length} context segment${segments.length === 1 ? "" : "s"}`);
const segmentViews = segments.map((segment) => {
const segmentEvents = eventsForRequests(events, segment.requests);
const startsWithCompaction = segmentEvents.some((event) => event.kind === "compaction" && event.requestNumber === segment.requests[0].number);
const first = segment.requests[0];
const model = first ? `${first.provider}/${first.model}` : "";
const label = `Context segment ${segment.number}${model ? ` · ${model}` : ""}${segment.number === latest.number ? " (latest)" : ""}`;
return {
id: `segment-${segment.number}`,
label,
html: renderDashboardView(
segment.requests,
segmentEvents,
countToolCalls(segment.requests),
startsWithCompaction ? 1 : 0,
label,
),
};
});
const options = [
`All context segments `,
...segmentViews.map((view) => `${esc(view.label)} `),
].join("");
const views = [
``,
...segmentViews.map((view) => ``),
].join("");
// ONE shared tooltip for the whole page. tau's script grabs the first
// `.usage-tooltip` in the document; with one per view that element lived in
// the (hidden) all-history section, so segment views set text on an element
// whose ancestor was display:none -> nothing painted. A single tooltip
// outside the view sections renders under every view.
return `View ` +
`${options} ` +
`Default: latest Context segment. All-history values remain available; cache metrics reset where compaction rewrites the prefix or the model changes.
` +
views +
`
`;
}
// ---------------------------------------------------------------------------
// Full page
// ---------------------------------------------------------------------------
export function buildCacheExportHtml(data, opts = {}) {
const title = opts.title || "cache export";
const n = data.requests.length;
return `
${esc(title)}
usage & cache
${esc(title)}
tau-style dashboard rendered by pi-cache-dashboard · ${n} requests · times in ${esc(tzLabel())}${opts.sessionId ? " · session " + esc(opts.sessionId) : ""}
`;
}