opencode-cache-hit 0.2.0 → 0.2.1

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,728 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Timeline JSONL -> interactive HTML dashboard (Bun, no npm deps).
4
+ *
5
+ * bun scripts/timeline-dashboard.ts # auto-detect logs/
6
+ * bun scripts/timeline-dashboard.ts ~/logs/timeline-*.jsonl # globs expanded by script
7
+ * bun scripts/timeline-dashboard.ts --open # open browser after write
8
+ * bun scripts/timeline-dashboard.ts -o /tmp/report.html # custom output
9
+ *
10
+ * Default output: /tmp/timeline-dashboard-YYYY-MM-DD-HHmmss.html
11
+ * Browser is NOT opened unless you pass --open.
12
+ */
13
+
14
+ import { execSync } from "child_process"
15
+ import { existsSync, readdirSync } from "fs"
16
+ import { homedir } from "os"
17
+ import { basename, dirname, resolve } from "path"
18
+ import { Glob } from "bun"
19
+ import {
20
+ createCostFormatter,
21
+ normalizeCostDisplay,
22
+ normalizeCostDisplayEmbed,
23
+ type CostDisplayEmbed,
24
+ } from "../src/format-cost.ts"
25
+ import { loadPluginConfig } from "../src/load-config.ts"
26
+ import type { LlmCallRecord } from "../src/timeline/types.ts"
27
+
28
+ function timestampSuffix(): string {
29
+ const d = new Date()
30
+ const pad = (n: number) => String(n).padStart(2, "0")
31
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
32
+ }
33
+
34
+ function parseArgs(argv: string[]) {
35
+ const positional: string[] = []
36
+ let output = `/tmp/timeline-dashboard-${timestampSuffix()}.html`
37
+ let open = false
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i]
40
+ if (a === "-o" || a === "--output") {
41
+ const next = argv[++i]
42
+ if (!next) {
43
+ console.error("error: -o/--output requires a path")
44
+ process.exit(1)
45
+ }
46
+ output = next
47
+ } else if (a === "--open") {
48
+ open = true
49
+ } else if (a === "-h" || a === "--help") {
50
+ console.error(
51
+ "usage: bun scripts/timeline-dashboard.ts [files...] [-o path] [--open]\n" +
52
+ " --open open the HTML file in the default browser (macOS/Linux/Windows)",
53
+ )
54
+ process.exit(0)
55
+ } else if (a.startsWith("-")) {
56
+ console.error(`error: unknown option ${a}`)
57
+ process.exit(1)
58
+ } else {
59
+ positional.push(a)
60
+ }
61
+ }
62
+ return { patterns: positional, output, open }
63
+ }
64
+
65
+ function expandUserPath(p: string): string {
66
+ if (p.startsWith("~/")) return resolve(homedir(), p.slice(2))
67
+ return p
68
+ }
69
+
70
+ function isTimelineLogFile(name: string): boolean {
71
+ return name.startsWith("timeline-") && /\.jsonl(\.\d+)?$/.test(name)
72
+ }
73
+
74
+ async function expandPattern(pattern: string): Promise<string[]> {
75
+ const p = expandUserPath(pattern)
76
+ if (!/[?*[]]/.test(p)) {
77
+ const abs = resolve(p)
78
+ return existsSync(abs) ? [abs] : []
79
+ }
80
+ const dir = resolve(dirname(p))
81
+ const base = basename(p)
82
+ if (!existsSync(dir)) return []
83
+ const glob = new Glob(base)
84
+ const out: string[] = []
85
+ for await (const file of glob.scan({ cwd: dir, onlyFiles: true, absolute: true })) {
86
+ if (isTimelineLogFile(basename(file))) out.push(file)
87
+ }
88
+ return out.sort()
89
+ }
90
+
91
+ async function resolveInputPaths(patterns: string[]): Promise<string[]> {
92
+ if (patterns.length > 0) {
93
+ const paths: string[] = []
94
+ for (const pat of patterns) {
95
+ paths.push(...(await expandPattern(pat)))
96
+ }
97
+ return [...new Set(paths)].sort()
98
+ }
99
+ const logDir = resolve(homedir(), ".local/share/opencode/logs/cache-hit")
100
+ if (!existsSync(logDir)) return []
101
+ return readdirSync(logDir)
102
+ .filter(isTimelineLogFile)
103
+ .sort()
104
+ .map((f) => resolve(logDir, f))
105
+ }
106
+
107
+ function isValidRecord(v: unknown): v is LlmCallRecord {
108
+ if (!v || typeof v !== "object") return false
109
+ const r = v as Record<string, unknown>
110
+ if (r.schema !== 1) return false
111
+ if (typeof r.created !== "string") return false
112
+ if (new Date(r.created).getFullYear() < 2024) return false
113
+ if (typeof r.sessionId !== "string" || typeof r.rootSessionId !== "string") return false
114
+ if (r.scope !== "main" && r.scope !== "child") return false
115
+ for (const k of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "cost"] as const) {
116
+ if (typeof r[k] !== "number" || !Number.isFinite(r[k])) return false
117
+ }
118
+ if (r.hitPercent != null && (typeof r.hitPercent !== "number" || !Number.isFinite(r.hitPercent))) {
119
+ return false
120
+ }
121
+ return true
122
+ }
123
+
124
+ function sortKey(r: LlmCallRecord): number {
125
+ const ts = r.completedAt ?? r.created
126
+ return new Date(ts).getTime() || 0
127
+ }
128
+
129
+ async function loadRecords(paths: string[]): Promise<LlmCallRecord[]> {
130
+ const records: LlmCallRecord[] = []
131
+ const seenKeys = new Set<string>()
132
+ for (const p of paths) {
133
+ if (!existsSync(p)) continue
134
+ const text = await Bun.file(p).text()
135
+ for (const line of text.split("\n")) {
136
+ const s = line.trim()
137
+ if (!s) continue
138
+ try {
139
+ const parsed: unknown = JSON.parse(s)
140
+ if (!isValidRecord(parsed)) continue
141
+ // Deduplicate by messageKey — handles historical duplicates from pre-fix logs
142
+ if (seenKeys.has(parsed.messageKey)) continue
143
+ seenKeys.add(parsed.messageKey)
144
+ records.push(parsed)
145
+ } catch {
146
+ /* skip malformed */
147
+ }
148
+ }
149
+ }
150
+ records.sort((a, b) => sortKey(a) - sortKey(b))
151
+ return records
152
+ }
153
+
154
+ function summarizeStats(data: LlmCallRecord[]) {
155
+ const sessionIds = new Set(data.map((r) => r.rootSessionId || "(unknown)"))
156
+ return {
157
+ totalCalls: data.length,
158
+ totalSessions: sessionIds.size,
159
+ totalCost: data.reduce((s, r) => s + r.cost, 0),
160
+ }
161
+ }
162
+
163
+ /** Safe JSON inside <script> (prevents </script> breakout). */
164
+ function embedJson(json: string): string {
165
+ return json.replace(/</g, "\\u003c")
166
+ }
167
+
168
+ function genHTML(data: LlmCallRecord[], cost: CostDisplayEmbed): string {
169
+ const jsonData = embedJson(JSON.stringify(data))
170
+ const jsonCost = embedJson(JSON.stringify(cost))
171
+
172
+ return `<!DOCTYPE html>
173
+ <html lang="zh-CN">
174
+ <head>
175
+ <meta charset="UTF-8">
176
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
177
+ <title>Timeline Dashboard</title>
178
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7"></script>
179
+ <style>
180
+ *{box-sizing:border-box;margin:0;padding:0}
181
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0d1117;color:#e6edf3;padding:20px;max-width:1400px;margin:auto}
182
+ h1{font-size:24px;margin-bottom:8px;color:#f0f6fc}
183
+ h2{font-size:16px;margin:24px 0 8px;color:#e6edf3;border-bottom:1px solid #30363d;padding-bottom:4px}
184
+ .sub{color:#8b949e;font-size:13px;margin-bottom:16px}
185
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
186
+ .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px 16px}
187
+ .card .val{font-size:22px;font-weight:600;color:#f0f6fc;margin-top:4px}
188
+ .card .lbl{font-size:12px;color:#8b949e;text-transform:uppercase;letter-spacing:.05em}
189
+ .card .subval{font-size:13px;color:#8b949e;margin-top:2px}
190
+ .filters{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px 16px}
191
+ .filters label{font-size:13px;color:#8b949e;margin-right:4px}
192
+ .filters input,.filters select{background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;font-size:13px}
193
+ .filters select{min-width:120px}
194
+ .chart-wrap{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
195
+ .chart-wrap canvas{width:100%!important;max-height:320px}
196
+ .table-wrap{overflow-x:auto;background:#161b22;border:1px solid #30363d;border-radius:8px;margin-bottom:16px}
197
+ table{width:100%;border-collapse:collapse;font-size:13px}
198
+ th{text-align:left;padding:10px 12px;background:#0d1117;color:#8b949e;font-weight:500;border-bottom:1px solid #30363d;white-space:nowrap;cursor:pointer;user-select:none}
199
+ th:hover{color:#e6edf3}
200
+ td{padding:8px 12px;border-bottom:1px solid #21262d;white-space:nowrap;font-variant-numeric:tabular-nums}
201
+ tr:hover td{background:#1c2128}
202
+ .num{text-align:right;font-family:"SF Mono","Cascadia Code","Fira Code",monospace}
203
+ .ok{color:#3fb950}
204
+ .warn{color:#d29922}
205
+ .err{color:#f85149}
206
+ .muted{color:#8b949e}
207
+ .pill{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:500}
208
+ .pill-main{background:#1f6feb33;color:#58a6ff}
209
+ .pill-child{background:#3fb95033;color:#3fb950}
210
+ .pill-model{background:#8b949e22;color:#8b949e;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
211
+ .pill-mixed{background:#d2992233;color:#d29922}
212
+ .dr{display:none}
213
+ .dr td{padding:0}
214
+ .dr.open{display:table-row}
215
+ .detail-inner{padding:12px 16px;background:#0d1117}
216
+ .dr .detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 16px}
217
+ .dg-item{display:flex;flex-direction:column;min-width:0}
218
+ .dg-label{font-size:11px;color:#8b949e;margin-bottom:2px}
219
+ .dg-value{font-size:13px;color:#e6edf3;font-family:"SF Mono",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
220
+ .tip-box{position:fixed;max-width:360px;padding:8px 12px;background:#1c2128;border:1px solid #30363d;border-radius:6px;font-size:12px;line-height:1.5;color:#e6edf3;pointer-events:none;z-index:999;display:none;box-shadow:0 4px 12px rgba(0,0,0,.4)}
221
+ </style>
222
+ </head>
223
+ <body>
224
+
225
+ <h1>Timeline Dashboard</h1>
226
+ <p class="sub" id="subtitle">Loading...</p>
227
+ <p class="sub" id="costNote" style="display:none;margin-top:-8px"></p>
228
+
229
+ <div class="grid" id="summaryGrid"></div>
230
+
231
+ <div class="filters">
232
+ <label>Time</label>
233
+ <input type="date" id="filterDateFrom">
234
+ <span class="muted">--</span>
235
+ <input type="date" id="filterDateTo">
236
+
237
+ <label style="margin-left:8px">Session</label>
238
+ <select id="filterSession"><option value="all">All</option></select>
239
+
240
+ <label style="margin-left:8px">Scope</label>
241
+ <select id="filterScope"><option value="all">All</option><option value="main">main</option><option value="child">child</option></select>
242
+
243
+ <label style="margin-left:8px">Model</label>
244
+ <select id="filterModel"><option value="all">All</option></select>
245
+
246
+ <label style="margin-left:8px">Search</label>
247
+ <input type="text" id="filterSearch" placeholder="session / model / messageKey..." style="width:220px">
248
+ </div>
249
+
250
+ <div class="chart-wrap">
251
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
252
+ <span style="font-size:14px;font-weight:500">Token Volume <span class="tip-trigger" data-tip="Input=cache miss tokens | Cache Read=cache hit tokens | Output=generated tokens | Cache Write=tokens written to cache" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
253
+ <label style="font-size:12px;color:#8b949e">
254
+ <input type="checkbox" id="toggleStack" checked> Stacked
255
+ </label>
256
+ </div>
257
+ <canvas id="chartTokens"></canvas>
258
+ </div>
259
+
260
+ <div class="chart-wrap">
261
+ <span style="font-size:14px;font-weight:500;display:block;margin-bottom:8px">Cache Hit Rate &amp; Cost <span class="tip-trigger" data-tip="Per-call prompt cache hit rate (green) and message cost (red) | Hit % = cacheRead / (cacheRead + input) | Avg hit excludes skippedForHit rows (same as plot-hit-rate.ts)" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
262
+ <canvas id="chartHitCost"></canvas>
263
+ </div>
264
+
265
+ <div class="chart-wrap">
266
+ <span style="font-size:14px;font-weight:500;display:block;margin-bottom:8px">Duration <span class="tip-trigger" data-tip="Assistant response generation time in milliseconds" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></span>
267
+ <canvas id="chartDuration"></canvas>
268
+ </div>
269
+
270
+ <h2>Session Summary <span class="tip-trigger" data-tip="Aggregated stats grouped by rootSessionId (one row per unique session)" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></h2>
271
+ <div class="table-wrap">
272
+ <table id="sessionTable">
273
+ <thead><tr>
274
+ <th>Session ID</th><th>Model</th><th>Scope</th><th class="num">Calls</th>
275
+ <th class="num">Total Tokens</th><th class="num">Input</th><th class="num">Output</th>
276
+ <th class="num">Cache Read</th><th class="num">Avg Hit</th><th class="num" id="thSessionCost">Cost</th><th>Start</th>
277
+ </tr></thead>
278
+ <tbody id="sessionBody"></tbody>
279
+ </table>
280
+ </div>
281
+
282
+ <h2>Per-Call Detail <span class="tip-trigger" data-tip="Each row is one assistant message. Click to expand all JSONL fields" style="font-size:11px;color:#8b949e;cursor:help"><span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:1px solid #8b949e;border-radius:50%;font-size:10px;font-weight:700;line-height:1;color:#8b949e;font-style:normal;margin-right:1px">!</span></span></h2> <span style="font-size:12px;color:#8b949e">(click row to expand; table shows latest N rows)</span>
283
+ <div class="filters" style="margin-bottom:4px">
284
+ <label style="color:#8b949e">Rows</label>
285
+ <select id="pageSize" style="width:70px">
286
+ <option value="20">20</option><option value="50" selected>50</option>
287
+ <option value="100">100</option><option value="500">500</option><option value="99999">All</option>
288
+ </select>
289
+ </div>
290
+ <div class="table-wrap">
291
+ <table id="detailTable">
292
+ <thead><tr>
293
+ <th style="width:0"></th><th class="num">Time</th><th>Scope</th><th>Session</th><th>Model</th>
294
+ <th class="num">Input</th><th class="num">Output</th><th class="num">CacheR</th><th class="num">CacheW</th>
295
+ <th class="num">Hit%</th><th class="num">Cost</th><th class="num">Dur</th>
296
+ </tr></thead>
297
+ <tbody id="detailBody"></tbody>
298
+ </table>
299
+ </div>
300
+
301
+ <script>
302
+ var RAW_DATA = TMPL_DATA
303
+ var EXPAND_FIELDS = ["schema","recordedAt","sessionId","rootSessionId","scope","messageKey","modelId","created","completedAt","durationMs","isComplete","input","output","reasoning","cacheRead","cacheWrite","cost","hitPercent","skippedForHit"]
304
+
305
+ function fmtDur(ms) { if (ms == null) return "-"; return ms < 1000 ? ms + "ms" : (ms/1000).toFixed(1) + "s" }
306
+ function esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;") }
307
+
308
+ function ensureCostDisplay(raw) {
309
+ var d = { currency:"CNY", costUnit:"USD", rate:6.77, symbol:"¥", decimals:3, minDisplay:0.01, chartLabel:"Cost (¥)", costNote:"JSONL cost is USD; displayed as CNY @ 6.77" }
310
+ if (!raw || typeof raw !== "object") return d
311
+ var rate = Number(raw.rate)
312
+ if (!isFinite(rate) || rate <= 0) rate = d.rate
313
+ return {
314
+ currency: raw.currency || d.currency,
315
+ costUnit: raw.costUnit || d.costUnit,
316
+ rate: rate,
317
+ symbol: raw.symbol || d.symbol,
318
+ decimals: typeof raw.decimals === "number" && raw.decimals >= 0 ? raw.decimals : d.decimals,
319
+ minDisplay: typeof raw.minDisplay === "number" && raw.minDisplay > 0 ? raw.minDisplay : d.minDisplay,
320
+ chartLabel: raw.chartLabel || d.chartLabel,
321
+ costNote: typeof raw.costNote === "string" ? raw.costNote : d.costNote
322
+ }
323
+ }
324
+ var COST_DISPLAY = ensureCostDisplay(TMPL_COST)
325
+
326
+ function convertCost(amount) {
327
+ if (!isFinite(amount) || amount <= 0) return 0
328
+ return amount * COST_DISPLAY.rate
329
+ }
330
+
331
+ function fmtCost(amount) {
332
+ if (!isFinite(amount) || amount <= 0) return "-"
333
+ var v = convertCost(amount)
334
+ if (!isFinite(v)) return "-"
335
+ if (v < COST_DISPLAY.minDisplay) return "<" + COST_DISPLAY.symbol + COST_DISPLAY.minDisplay
336
+ return "~" + COST_DISPLAY.symbol + v.toFixed(COST_DISPLAY.decimals)
337
+ }
338
+
339
+ function applyCostLabels() {
340
+ var th = document.getElementById("thSessionCost")
341
+ if (th) th.textContent = COST_DISPLAY.chartLabel
342
+ var note = document.getElementById("costNote")
343
+ if (!note) return
344
+ if (COST_DISPLAY.costNote) {
345
+ note.textContent = COST_DISPLAY.costNote
346
+ note.style.display = "block"
347
+ } else {
348
+ note.textContent = ""
349
+ note.style.display = "none"
350
+ }
351
+ }
352
+
353
+ function hitValues(rows) {
354
+ return rows.filter(function(r){ return !r.skippedForHit && r.hitPercent != null }).map(function(r){ return r.hitPercent })
355
+ }
356
+
357
+ function sessionScopeLabel(rows) {
358
+ var scopes = [...new Set(rows.map(function(r){ return r.scope }).filter(Boolean))]
359
+ if (scopes.length === 0) return "(unknown)"
360
+ if (scopes.length === 1) return scopes[0]
361
+ return scopes.join("+")
362
+ }
363
+
364
+ function scopePillClass(scope) {
365
+ if (scope === "main") return "pill-main"
366
+ if (scope === "child") return "pill-child"
367
+ return "pill-mixed"
368
+ }
369
+
370
+ function renderSummary(data) {
371
+ var ti = 0, to = 0, tcr = 0, tcw = 0, tc = 0, tt = 0
372
+ data.forEach(function(r){ ti+=r.input; to+=r.output; tcr+=r.cacheRead; tcw+=r.cacheWrite; tc+=r.cost })
373
+ tt = ti+to+tcr+tcw
374
+ var hits = hitValues(data)
375
+ var avg = hits.length ? hits.reduce(function(s,h){return s+h},0)/hits.length : 0
376
+ var sessions = [...new Set(data.map(function(r){return r.rootSessionId}).filter(Boolean))].length
377
+ var models = [...new Set(data.map(function(r){return r.modelId}).filter(Boolean))].join(", ")
378
+ var cls = avg>90 ? "ok" : avg>70 ? "warn" : "err"
379
+ var cards = [
380
+ {l:"Records", v:data.length, s:sessions+" sessions"},
381
+ {l:"Total Tokens", v:(tt/1e6).toFixed(2)+"M", s:"In "+(ti/1e6).toFixed(2)+"M"},
382
+ {l:"Total Input", v:ti.toLocaleString(), s:"Out "+to.toLocaleString()},
383
+ {l:"Cache Read", v:tcr.toLocaleString(), s:"Write "+tcw.toLocaleString()},
384
+ {l:"Avg Hit Rate", v:avg.toFixed(1)+"%", s:hits.length+" plottable calls", c:cls},
385
+ {l:"Total Cost", v:fmtCost(tc), s:COST_DISPLAY.costUnit !== COST_DISPLAY.currency ? "raw "+COST_DISPLAY.costUnit+" in JSONL" : ""},
386
+ {l:"Models", v:models||"(none)", s:""},
387
+ {l:"Date Range", v:data.length?data[0].created.slice(0,10):"-", s:data.length?"~ "+data[data.length-1].created.slice(0,10):""}
388
+ ]
389
+ document.getElementById("summaryGrid").innerHTML = cards.map(function(c){
390
+ return '<div class="card"><div class="lbl">'+c.l+'</div><div class="val'+(c.c?" "+c.c:"")+'">'+c.v+'</div>'+(c.s?'<div class="subval">'+c.s+'</div>':"")+'</div>'
391
+ }).join("")
392
+ }
393
+
394
+ function updateSubtitle(data) {
395
+ var sessions = [...new Set(data.map(function(r){return r.rootSessionId}).filter(Boolean))]
396
+ var models = [...new Set(data.map(function(r){return r.modelId}).filter(Boolean))]
397
+ document.getElementById("subtitle").textContent = data.length + " records (filtered), " + sessions.length + " sessions" + (models.length?", "+models.join(", "):"")
398
+ }
399
+
400
+ function populateFilters() {
401
+ var sessions = [...new Set(RAW_DATA.filter(function(r){return r.rootSessionId}).map(function(r){return r.rootSessionId}))].sort()
402
+ var models = [...new Set(RAW_DATA.filter(function(r){return r.modelId}).map(function(r){return r.modelId}))].sort()
403
+ var selS = document.getElementById("filterSession")
404
+ sessions.forEach(function(s){ var o=document.createElement("option"); o.value=s; o.textContent=s.slice(-16); selS.appendChild(o) })
405
+ var selM = document.getElementById("filterModel")
406
+ models.forEach(function(m){ var o=document.createElement("option"); o.value=m; o.textContent=m; selM.appendChild(o) })
407
+ if (RAW_DATA.length > 0) {
408
+ var dates = RAW_DATA.map(function(r){return r.created.slice(0,10)}).filter(function(d,i,a){return a.indexOf(d)===i}).sort()
409
+ document.getElementById("filterDateFrom").value = dates[0]
410
+ document.getElementById("filterDateTo").value = dates[dates.length-1]
411
+ }
412
+ updateSubtitle(RAW_DATA)
413
+ }
414
+
415
+ function getFilteredData() {
416
+ var df = document.getElementById("filterDateFrom").value
417
+ var dt = document.getElementById("filterDateTo").value
418
+ var sess = document.getElementById("filterSession").value
419
+ var sc = document.getElementById("filterScope").value
420
+ var mdl = document.getElementById("filterModel").value
421
+ var q = document.getElementById("filterSearch").value.toLowerCase()
422
+ return RAW_DATA.filter(function(r){
423
+ var d = r.created.slice(0,10)
424
+ if (df && d<df) return false
425
+ if (dt && d>dt) return false
426
+ if (sess!=="all" && r.rootSessionId!==sess) return false
427
+ if (sc!=="all" && r.scope!==sc) return false
428
+ if (mdl!=="all" && r.modelId!==mdl) return false
429
+ if (q) {
430
+ var mk = (r.messageKey || "").toLowerCase()
431
+ var rs = (r.rootSessionId || "").toLowerCase()
432
+ var sid = (r.sessionId || "").toLowerCase()
433
+ var mid = (r.modelId || "").toLowerCase()
434
+ if (mk.indexOf(q)===-1 && rs.indexOf(q)===-1 && sid.indexOf(q)===-1 && mid.indexOf(q)===-1) return false
435
+ }
436
+ return true
437
+ })
438
+ }
439
+
440
+ var chartTokens = null, chartHitCost = null, chartDuration = null
441
+
442
+ function chartAvailable() { return typeof Chart !== "undefined" }
443
+
444
+ function chartCtx(id) { try { return document.getElementById(id).getContext("2d") } catch(e){ return null } }
445
+
446
+ function buildTokenChart(data) {
447
+ if (!chartAvailable()) return
448
+ var ctx = chartCtx("chartTokens")
449
+ if (!ctx) return
450
+ var stacked = document.getElementById("toggleStack").checked
451
+ if (chartTokens) chartTokens.destroy()
452
+ chartTokens = new Chart(ctx, {
453
+ type: "bar",
454
+ data: {
455
+ labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
456
+ datasets: [
457
+ { label:"Input", data:data.map(function(r){return r.input}), backgroundColor:"#58a6ff80", borderColor:"#58a6ff", borderWidth:1 },
458
+ { label:"Output", data:data.map(function(r){return r.output}), backgroundColor:"#3fb95080", borderColor:"#3fb950", borderWidth:1 },
459
+ { label:"Cache Read", data:data.map(function(r){return r.cacheRead}), backgroundColor:"#d2992280", borderColor:"#d29922", borderWidth:1 },
460
+ { label:"Cache Write", data:data.map(function(r){return r.cacheWrite}), backgroundColor:"#a371f780", borderColor:"#a371f7", borderWidth:1 }
461
+ ]
462
+ },
463
+ options: {
464
+ responsive:true, maintainAspectRatio:false, animation:false,
465
+ scales: {
466
+ x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
467
+ y: { stacked:stacked, beginAtZero:true, ticks:{color:"#8b949e"}, grid:{color:"#21262d"} }
468
+ },
469
+ plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
470
+ }
471
+ })
472
+ }
473
+
474
+ function buildHitCostChart(data) {
475
+ if (!chartAvailable()) return
476
+ var ctx = chartCtx("chartHitCost")
477
+ if (!ctx) return
478
+ if (chartHitCost) chartHitCost.destroy()
479
+ chartHitCost = new Chart(ctx, {
480
+ type: "line",
481
+ data: {
482
+ labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
483
+ datasets: [
484
+ { label:"Hit %", data:data.map(function(r){return r.hitPercent}), yAxisID:"y",
485
+ borderColor:"#3fb950", backgroundColor:"#3fb95022", fill:true, tension:0.2,
486
+ pointRadius:2, pointBackgroundColor:"#3fb950" },
487
+ { label:COST_DISPLAY.chartLabel, data:data.map(function(r){return convertCost(r.cost)}), yAxisID:"y1",
488
+ borderColor:"#f85149", backgroundColor:"#f8514922", fill:true, tension:0.2,
489
+ pointRadius:2, pointBackgroundColor:"#f85149" }
490
+ ]
491
+ },
492
+ options: {
493
+ responsive:true, maintainAspectRatio:false, animation:false,
494
+ scales: {
495
+ x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
496
+ y: { type:"linear", position:"left", min:0, max:100, ticks:{color:"#3fb950"}, grid:{color:"#21262d"}, title:{display:true, text:"Hit %", color:"#3fb950"} },
497
+ y1: { type:"linear", position:"right", min:0, ticks:{color:"#f85149"}, grid:{display:false}, title:{display:true, text:COST_DISPLAY.chartLabel, color:"#f85149"} }
498
+ },
499
+ plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
500
+ }
501
+ })
502
+ }
503
+
504
+ function buildDurationChart(data) {
505
+ if (!chartAvailable()) return
506
+ var ctx = chartCtx("chartDuration")
507
+ if (!ctx) return
508
+ if (chartDuration) chartDuration.destroy()
509
+ chartDuration = new Chart(ctx, {
510
+ type: "bar",
511
+ data: {
512
+ labels: data.map(function(r){return r.created.slice(0,19).replace("T"," ")}),
513
+ datasets: [
514
+ { label:"Duration (ms)", data:data.map(function(r){return r.durationMs||0}), backgroundColor:"#58a6ff80", borderColor:"#58a6ff", borderWidth:1 }
515
+ ]
516
+ },
517
+ options: {
518
+ responsive:true, maintainAspectRatio:false, animation:false,
519
+ scales: {
520
+ x: { ticks:{color:"#8b949e",maxRotation:45,maxTicksLimit:20}, grid:{color:"#21262d"} },
521
+ y: { beginAtZero:true, ticks:{color:"#8b949e"}, grid:{color:"#21262d"} }
522
+ },
523
+ plugins: { legend:{ labels:{color:"#e6edf3",boxWidth:12,padding:12} } }
524
+ }
525
+ })
526
+ }
527
+
528
+ function renderSessionTable(data) {
529
+ var map = {}
530
+ data.forEach(function(r){
531
+ var id = r.rootSessionId || "(unknown)"
532
+ if (!map[id]) map[id] = []
533
+ map[id].push(r)
534
+ })
535
+ var rows = []
536
+ Object.keys(map).forEach(function(id){
537
+ var rd = map[id]
538
+ var hits = hitValues(rd)
539
+ var models = [...new Set(rd.map(function(r){return r.modelId}).filter(Boolean))]
540
+ var scope = sessionScopeLabel(rd)
541
+ var avg = hits.length ? hits.reduce(function(s,h){return s+h},0)/hits.length : 0
542
+ var totalT = rd.reduce(function(s,r){return s+r.input+r.output+r.cacheRead+r.cacheWrite},0)
543
+ rows.push({
544
+ id:id, model:models.join(" | ")||"(unknown)", scope:scope,
545
+ calls:rd.length, totalT:totalT,
546
+ ti:rd.reduce(function(s,r){return s+r.input},0),
547
+ to:rd.reduce(function(s,r){return s+r.output},0),
548
+ tcr:rd.reduce(function(s,r){return s+r.cacheRead},0),
549
+ avg:avg, cost:rd.reduce(function(s,r){return s+r.cost},0),
550
+ start:rd[0].created||""
551
+ })
552
+ })
553
+ rows.sort(function(a,b){return a.start.localeCompare(b.start)})
554
+ var cls = function(p){return p>90?"ok":p>70?"warn":"err"}
555
+ document.getElementById("sessionBody").innerHTML = rows.map(function(r){
556
+ return '<tr><td style="max-width:180px;overflow:hidden;text-overflow:ellipsis" title="'+esc(r.id)+'">'+esc(r.id.slice(-16))+
557
+ '</td><td><span class="pill pill-model">'+esc(r.model)+'</span></td><td><span class="pill '+scopePillClass(r.scope)+'">'+esc(r.scope)+
558
+ '</span></td><td class="num">'+r.calls+'</td><td class="num">'+(r.totalT/1e6).toFixed(2)+'M</td><td class="num">'+r.ti.toLocaleString()+
559
+ '</td><td class="num">'+r.to.toLocaleString()+'</td><td class="num">'+r.tcr.toLocaleString()+
560
+ '</td><td class="num '+cls(r.avg)+'">'+r.avg.toFixed(1)+'%</td><td class="num">'+fmtCost(r.cost)+
561
+ '</td><td>'+r.start.slice(0,19)+'</td></tr>'
562
+ }).join("")
563
+ }
564
+
565
+ function expandDetailGrid(r) {
566
+ return EXPAND_FIELDS.map(function(f){
567
+ var v = r[f]
568
+ if (v == null) v = "-"
569
+ else if (f === "cost") {
570
+ var rawCost = v
571
+ v = String(rawCost) + " " + COST_DISPLAY.costUnit
572
+ if (COST_DISPLAY.rate !== 1) v += " (" + fmtCost(rawCost) + ")"
573
+ }
574
+ else if (typeof v === "boolean") v = v ? "true" : "false"
575
+ else v = String(v)
576
+ return '<div class="dg-item"><span class="dg-label">'+f+'</span><span class="dg-value" title="'+esc(v)+'">'+esc(v)+'</span></div>'
577
+ }).join("")
578
+ }
579
+
580
+ function renderDetailTable(data) {
581
+ var ps = parseInt(document.getElementById("pageSize").value)
582
+ var disp = ps >= data.length ? data : data.slice(data.length - ps)
583
+ var cls = function(p){return p!=null?(p>90?"ok":p>70?"warn":"err"):""}
584
+ document.getElementById("detailBody").innerHTML = disp.map(function(r, i){
585
+ var shortId = r.rootSessionId ? r.rootSessionId.slice(-12) : "-"
586
+ var hitPct = r.hitPercent != null ? r.hitPercent.toFixed(1)+"%" : "-"
587
+ return '<tr class="dp" data-idx="'+i+'">'+
588
+ '<td style="cursor:pointer;color:#8b949e;text-align:center;font-size:16px;user-select:none">&#9654;</td>'+
589
+ '<td class="num">'+r.created.slice(0,19).replace("T"," ")+'</td>'+
590
+ '<td><span class="pill '+scopePillClass(r.scope)+'">'+esc(r.scope)+'</span></td>'+
591
+ '<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis" title="'+esc(r.rootSessionId||"")+'">'+esc(shortId)+'</td>'+
592
+ '<td><span class="pill pill-model">'+esc(r.modelId||"-")+'</span></td>'+
593
+ '<td class="num">'+r.input.toLocaleString()+'</td>'+
594
+ '<td class="num">'+r.output.toLocaleString()+'</td>'+
595
+ '<td class="num">'+r.cacheRead.toLocaleString()+'</td>'+
596
+ '<td class="num">'+r.cacheWrite.toLocaleString()+'</td>'+
597
+ '<td class="num '+cls(r.hitPercent)+'">'+hitPct+'</td>'+
598
+ '<td class="num">'+fmtCost(r.cost)+'</td>'+
599
+ '<td class="num">'+fmtDur(r.durationMs)+'</td>'+
600
+ '</tr>'+
601
+ '<tr class="dr" data-idx="'+i+'"><td colspan="12"><div class="detail-inner"><div class="detail-grid">'+expandDetailGrid(r)+'</div></div></td></tr>'
602
+ }).join("")
603
+ }
604
+
605
+ document.getElementById("detailBody").addEventListener("click", function(e){
606
+ var row = e.target.closest(".dp")
607
+ if (!row) return
608
+ var idx = row.dataset["idx"]
609
+ var detail = document.querySelector('.dr[data-idx="'+idx+'"]')
610
+ if (detail) {
611
+ detail.classList.toggle("open")
612
+ row.querySelector("td:first-child").innerHTML = detail.classList.contains("open") ? "&#9660;" : "&#9654;"
613
+ }
614
+ })
615
+
616
+ function refresh() {
617
+ var data = getFilteredData()
618
+ renderSummary(data)
619
+ updateSubtitle(data)
620
+ buildTokenChart(data)
621
+ buildHitCostChart(data)
622
+ buildDurationChart(data)
623
+ renderSessionTable(data)
624
+ renderDetailTable(data)
625
+ }
626
+
627
+ var searchTimer = null
628
+ document.getElementById("toggleStack").addEventListener("change", refresh)
629
+ document.getElementById("filterDateFrom").addEventListener("change", refresh)
630
+ document.getElementById("filterDateTo").addEventListener("change", refresh)
631
+ document.getElementById("filterSession").addEventListener("change", refresh)
632
+ document.getElementById("filterScope").addEventListener("change", refresh)
633
+ document.getElementById("filterModel").addEventListener("change", refresh)
634
+ document.getElementById("filterSearch").addEventListener("input", function(){
635
+ clearTimeout(searchTimer)
636
+ searchTimer = setTimeout(refresh, 200)
637
+ })
638
+ document.getElementById("pageSize").addEventListener("change", refresh)
639
+
640
+ var tipEl = document.createElement("div")
641
+ tipEl.className = "tip-box"
642
+ document.body.appendChild(tipEl)
643
+ document.querySelectorAll(".tip-trigger").forEach(function(el){
644
+ el.addEventListener("mouseenter", function(e){
645
+ tipEl.textContent = el.getAttribute("data-tip")
646
+ tipEl.style.display = "block"
647
+ tipEl.style.left = e.clientX + "px"
648
+ tipEl.style.top = (e.clientY + 14) + "px"
649
+ })
650
+ el.addEventListener("mousemove", function(e){
651
+ tipEl.style.left = e.clientX + "px"
652
+ tipEl.style.top = (e.clientY + 14) + "px"
653
+ })
654
+ el.addEventListener("mouseleave", function(){
655
+ tipEl.style.display = "none"
656
+ })
657
+ })
658
+
659
+ applyCostLabels()
660
+ populateFilters()
661
+ refresh()
662
+ </script>
663
+ </body>
664
+ </html>`.replace("TMPL_DATA", jsonData).replace("TMPL_COST", jsonCost)
665
+ }
666
+
667
+ function openInBrowser(filePath: string): void {
668
+ const quoted = JSON.stringify(filePath)
669
+ if (process.platform === "darwin") execSync(`open ${quoted}`)
670
+ else if (process.platform === "win32") execSync(`cmd /c start "" ${quoted}`)
671
+ else execSync(`xdg-open ${quoted}`)
672
+ }
673
+
674
+ const { patterns, output, open } = parseArgs(process.argv.slice(2))
675
+ const paths = await resolveInputPaths(patterns)
676
+ if (paths.length === 0) {
677
+ console.error(
678
+ "No timeline JSONL files found.\n" +
679
+ "Pass paths/globs or ensure ~/.local/share/opencode/logs/cache-hit/ exists.",
680
+ )
681
+ process.exit(1)
682
+ }
683
+
684
+ console.error("Reading " + paths.length + " file(s):")
685
+ for (const p of paths) {
686
+ const stats = await Bun.file(p).stat()
687
+ console.error(" " + p + " (" + (stats.size / 1024).toFixed(1) + " KB)")
688
+ }
689
+
690
+ const records = await loadRecords(paths)
691
+ if (records.length === 0) {
692
+ console.error("No valid records found.")
693
+ process.exit(1)
694
+ }
695
+
696
+ function loadCostContext(): { embed: CostDisplayEmbed; format: (n: number) => string } {
697
+ try {
698
+ const cost = normalizeCostDisplay(loadPluginConfig().cost)
699
+ return {
700
+ embed: normalizeCostDisplayEmbed(cost),
701
+ format: createCostFormatter(cost),
702
+ }
703
+ } catch {
704
+ const cost = normalizeCostDisplay(null)
705
+ return { embed: normalizeCostDisplayEmbed(cost), format: createCostFormatter(cost) }
706
+ }
707
+ }
708
+
709
+ const { embed: costEmbed, format: fmtCostCli } = loadCostContext()
710
+ const stats = summarizeStats(records)
711
+ console.error(
712
+ records.length +
713
+ " records, " +
714
+ stats.totalSessions +
715
+ " sessions, " +
716
+ (fmtCostCli(stats.totalCost) || stats.totalCost.toFixed(6) + " " + costEmbed.costUnit) +
717
+ " total cost",
718
+ )
719
+ if (costEmbed.costNote) console.error(" " + costEmbed.costNote)
720
+
721
+ const html = genHTML(records, costEmbed)
722
+ await Bun.write(output, html)
723
+ console.error("Written: " + output + " (" + (Buffer.byteLength(html) / 1024).toFixed(0) + " KB)")
724
+
725
+ if (open) {
726
+ openInBrowser(resolve(output))
727
+ console.error("Opened in browser")
728
+ }