reread-cli 0.0.0-stage → 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,1192 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <meta name="referrer" content="no-referrer">
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: blob:; base-uri 'none'; form-action 'none'">
9
+ <title>ReRead report</title>
10
+ <style>
11
+ :root {
12
+ color-scheme: light;
13
+ --bg: #fcfcfb;
14
+ --surface: #ffffff;
15
+ --surface-2: #f4f4f1;
16
+ --line: #e7e6e2;
17
+ --line-2: #d3d2cc;
18
+ --text: #0b0b0b;
19
+ --text-2: #3d3c39;
20
+ --muted: #5f5e59;
21
+ --read: #2a78d6;
22
+ --rebuild: #eb6834;
23
+ --other: #a3a29a;
24
+ --shrink: #5f5e59;
25
+ --focus: #2a78d6;
26
+ --tip-bg: #18181a;
27
+ --tip-text: #f5f5f3;
28
+ --tip-muted: #b9b8b1;
29
+ --hover: #f6f6f3;
30
+ --selected: #eef4fc;
31
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
32
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
33
+ }
34
+ @media (prefers-color-scheme: dark) {
35
+ :root {
36
+ color-scheme: dark;
37
+ --bg: #111110;
38
+ --surface: #1a1a19;
39
+ --surface-2: #232321;
40
+ --line: #2d2d2a;
41
+ --line-2: #44443f;
42
+ --text: #f0efec;
43
+ --text-2: #d4d3cb;
44
+ --muted: #a5a49b;
45
+ --read: #3987e5;
46
+ --rebuild: #e0652f;
47
+ --other: #6b6a64;
48
+ --shrink: #a5a49b;
49
+ --focus: #5598e7;
50
+ --tip-bg: #f0efec;
51
+ --tip-text: #111110;
52
+ --tip-muted: #4b4a46;
53
+ --hover: #20201e;
54
+ --selected: #1c2531;
55
+ }
56
+ }
57
+ * { box-sizing: border-box; }
58
+ html { -webkit-text-size-adjust: 100%; }
59
+ body {
60
+ margin: 0;
61
+ background: var(--bg);
62
+ color: var(--text);
63
+ font: 14px/1.5 var(--sans);
64
+ font-variant-numeric: tabular-nums;
65
+ }
66
+ a { color: var(--read); text-underline-offset: 2px; }
67
+ a:hover { text-decoration-thickness: 2px; }
68
+ button { font: inherit; color: inherit; }
69
+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
70
+ .mono { font-family: var(--mono); }
71
+ .muted { color: var(--muted); }
72
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
73
+ @media (min-width: 720px) { .wrap { padding: 0 32px; } }
74
+
75
+ /* top bar */
76
+ .bar { border-bottom: 1px solid var(--line); background: var(--bg); }
77
+ .bar .wrap { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-height: 56px; padding-top: 8px; padding-bottom: 8px; }
78
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-right: auto; }
79
+ .brand svg { width: 20px; height: 20px; color: var(--read); }
80
+ .brand .ver { font: 500 12px var(--mono); color: var(--muted); }
81
+ .tools { display: flex; gap: 8px; flex-wrap: wrap; }
82
+ .btn {
83
+ display: inline-flex; align-items: center; gap: 6px;
84
+ min-height: 36px; padding: 0 12px;
85
+ border: 1px solid var(--line-2); border-radius: 8px;
86
+ background: var(--surface); color: var(--text);
87
+ font-size: 13px; font-weight: 500; cursor: pointer;
88
+ transition: background-color .12s, border-color .12s;
89
+ }
90
+ .btn:hover { background: var(--surface-2); }
91
+ .btn:disabled { cursor: default; opacity: .6; }
92
+ .btn svg { width: 15px; height: 15px; flex: none; }
93
+ .btn.primary { background: var(--text); color: var(--bg); border-color: var(--text); }
94
+ .btn.primary:hover { opacity: .88; }
95
+ .btn[aria-pressed="true"] { background: var(--selected); border-color: var(--read); }
96
+ .btn .check { display: none; }
97
+ .btn[aria-pressed="true"] .check { display: inline; }
98
+ @media (pointer: coarse) { .btn { min-height: 44px; } }
99
+ @media (max-width: 520px) { .tools { width: 100%; } .tools .btn { flex: 1 1 auto; justify-content: center; } .wide { display: none; } }
100
+
101
+ /* hero */
102
+ .hero { padding: 40px 0 8px; }
103
+ .private { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin: 0 0 16px; }
104
+ .private svg { width: 14px; height: 14px; }
105
+ .headline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 18px; margin: 0; font-weight: 600; }
106
+ .headline .big { font-size: clamp(64px, 14vw, 112px); line-height: .95; letter-spacing: -0.045em; font-weight: 650; }
107
+ .headline .words { font-size: clamp(20px, 3.4vw, 30px); line-height: 1.2; letter-spacing: -0.015em; max-width: 18em; }
108
+ .meta { margin: 14px 0 0; color: var(--muted); font-size: 14px; }
109
+ .meta b { color: var(--text-2); font-weight: 600; }
110
+
111
+ .split { margin-top: 28px; }
112
+ .split-bar { display: flex; height: 18px; gap: 2px; border-radius: 5px; overflow: hidden; background: var(--surface-2); }
113
+ .split-bar span { display: block; min-width: 0; }
114
+ .legend3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
115
+ @media (max-width: 640px) { .legend3 { grid-template-columns: 1fr; gap: 10px; } }
116
+ .leg { display: grid; grid-template-columns: 12px 1fr; column-gap: 10px; }
117
+ .leg .sw { margin-top: 6px; }
118
+ .leg .v { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.2; }
119
+ .leg .l { font-weight: 500; color: var(--text-2); }
120
+ .leg .d { font-size: 13px; color: var(--muted); grid-column: 2; }
121
+ .sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; flex: none; }
122
+ .sw.read { background: var(--read); }
123
+ .sw.rebuild { background: var(--rebuild); }
124
+ .sw.other { background: var(--other); }
125
+ .note { margin: 14px 0 0; font-size: 13px; color: var(--muted); }
126
+ .note b { color: var(--text-2); }
127
+
128
+ /* sections */
129
+ main { padding-bottom: 24px; }
130
+ .grid { display: grid; gap: 16px; margin-top: 16px; }
131
+ @media (min-width: 900px) { .grid.two { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
132
+ .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
133
+ @media (max-width: 480px) { .card { padding: 16px; } }
134
+ .first { margin-top: 32px; }
135
+ .card > h2, .card-head h2 { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: -0.005em; }
136
+ .sub { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
137
+ .card-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
138
+ .card > h2 + .sub { margin-bottom: 14px; }
139
+ .empty { color: var(--muted); font-size: 13px; padding: 12px 0; margin: 0; }
140
+ .lead { margin: 0 0 14px; font-size: 14px; color: var(--text-2); }
141
+ .lead b { color: var(--text); }
142
+
143
+ /* charts */
144
+ .chart { position: relative; }
145
+ .chart svg { display: block; width: 100%; overflow: visible; }
146
+ .chart-focus { border-radius: 6px; }
147
+ .axis text { fill: var(--muted); font-size: 11px; font-family: var(--sans); }
148
+ .axis line { stroke: var(--line); }
149
+ .axis .base { stroke: var(--line-2); }
150
+ .hl { fill: var(--surface-2); }
151
+ .tip {
152
+ position: absolute; z-index: 5; pointer-events: none;
153
+ background: var(--tip-bg); color: var(--tip-text);
154
+ border-radius: 8px; padding: 8px 10px; font-size: 12px; line-height: 1.45;
155
+ min-width: 160px; max-width: 260px;
156
+ box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
157
+ }
158
+ .tip[hidden] { display: none; }
159
+ .tip .t { font-weight: 600; margin-bottom: 4px; }
160
+ .tip .r { display: flex; align-items: center; gap: 6px; }
161
+ .tip .r .k { color: var(--tip-muted); flex: 1; }
162
+ .tip .r .sw { width: 8px; height: 8px; border-radius: 2px; }
163
+ .tip .what { margin-top: 4px; padding-top: 4px; border-top: 1px solid color-mix(in srgb, var(--tip-muted) 40%, transparent); }
164
+ details.table-view { margin-top: 12px; }
165
+ details.table-view summary { cursor: pointer; font-size: 13px; color: var(--muted); width: max-content; padding: 4px 0; }
166
+ details.table-view summary:hover { color: var(--text); }
167
+ .chart-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text-2); margin-bottom: 10px; }
168
+ .chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
169
+ .chart-legend svg { width: 14px; height: 14px; }
170
+
171
+ /* horizontal bars */
172
+ .hbars { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; gap: 10px 12px; align-items: center; }
173
+ .hbars .lbl { font: 12px var(--mono); color: var(--text-2); }
174
+ .hbars .track { height: 14px; display: flex; gap: 2px; }
175
+ .hbars .track span { display: block; height: 100%; }
176
+ .hbars .track span:first-child { border-radius: 3px 0 0 3px; }
177
+ .hbars .track span:last-child { border-radius: 0 3px 3px 0; }
178
+ .hbars .track span:only-child { border-radius: 3px; }
179
+ .hbars .val { font-size: 12px; text-align: right; color: var(--text-2); white-space: nowrap; }
180
+ .hbars .val .muted { margin-left: 6px; }
181
+ .hbars .long { font-weight: 600; color: var(--text); }
182
+
183
+ /* why lost */
184
+ .why { list-style: none; margin: 0; padding: 0; }
185
+ .why li { display: grid; grid-template-columns: 18px minmax(0, 1fr) max-content; gap: 2px 12px; padding: 12px 0; border-top: 1px solid var(--line); }
186
+ .why li:first-child { border-top: 0; padding-top: 0; }
187
+ .why svg { width: 16px; height: 16px; margin-top: 2px; }
188
+ .why .name { font-weight: 600; }
189
+ .why .exp { font-size: 13px; color: var(--muted); grid-column: 2 / -1; max-width: 40em; }
190
+ .why .nums { text-align: right; font-size: 13px; }
191
+ .why .nums .usd { font-weight: 600; font-size: 14px; }
192
+ .why .nums .muted { display: block; font-size: 12px; }
193
+
194
+ /* tables */
195
+ .tbl-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
196
+ table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
197
+ .tbl th { text-align: left; font-weight: 500; color: var(--muted); font-size: 12px; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
198
+ .tbl td { padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; }
199
+ .tbl tbody tr:last-child td { border-bottom: 0; }
200
+ .tbl .num { text-align: right; white-space: nowrap; }
201
+ .tbl th.num { text-align: right; }
202
+ .tbl td:first-child, .tbl th:first-child { padding-left: 0; }
203
+ .tbl td:last-child, .tbl th:last-child { padding-right: 0; }
204
+ .tbl .name { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
205
+ .minibar { display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end; }
206
+ .minibar .track { width: 56px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; display: flex; gap: 1px; }
207
+ .minibar .track span { height: 100%; display: block; }
208
+
209
+ .sessions-tools { display: flex; gap: 8px; align-items: center; }
210
+ .search { position: relative; }
211
+ .search svg { position: absolute; left: 10px; top: 50%; width: 14px; height: 14px; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
212
+ .search input {
213
+ height: 36px; width: 260px; max-width: 100%; padding: 0 10px 0 30px;
214
+ border: 1px solid var(--line-2); border-radius: 8px; background: var(--surface); color: var(--text); font: 13px var(--sans);
215
+ }
216
+ .search input::placeholder { color: var(--muted); }
217
+ @media (max-width: 640px) { .sessions-tools, .search, .search input { width: 100%; } }
218
+ @media (pointer: coarse) { .search input { height: 44px; font-size: 16px; } }
219
+ #sessions table.tbl { min-width: 820px; }
220
+ .tbl th button.sort {
221
+ all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; color: inherit; border-radius: 4px;
222
+ }
223
+ .tbl th button.sort:hover { color: var(--text); }
224
+ .tbl th button.sort:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
225
+ .tbl th .arrow { width: 10px; display: inline-block; text-align: center; opacity: 0; }
226
+ .tbl th[aria-sort] { color: var(--text); }
227
+ .tbl th[aria-sort] .arrow { opacity: 1; }
228
+ .srow { cursor: pointer; transition: background-color .1s; }
229
+ .srow:hover { background: var(--hover); }
230
+ .srow[aria-expanded="true"] { background: var(--selected); }
231
+ .srow:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
232
+ .srow .chev { display: inline-block; width: 12px; color: var(--muted); transition: transform .15s; }
233
+ .srow[aria-expanded="true"] .chev { transform: rotate(90deg); }
234
+ .srow td:first-child { white-space: nowrap; }
235
+ @media (max-width: 520px) { .tbl .opt, .grid .minibar .track { display: none; } .tbl td, .tbl th { padding-left: 6px; padding-right: 6px; } }
236
+ .srow .sid { display: block; font: 11px var(--mono); color: var(--muted); margin-left: 16px; }
237
+ .reb { display: inline-flex; gap: 6px; justify-content: flex-end; align-items: center; }
238
+ .reb svg { width: 11px; height: 11px; flex: none; }
239
+ .stats .reb { display: flex; gap: 6px; align-items: center; }
240
+ .reb span { display: inline-flex; align-items: center; gap: 2px; }
241
+ .tbl tr.drill > td { padding: 0; background: var(--selected); }
242
+ .drill-inner { position: sticky; left: 0; padding: 16px 0 20px; }
243
+ .drill-grid { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
244
+ @media (min-width: 820px) { .drill-grid { grid-template-columns: minmax(0, 1fr) 230px; } }
245
+ .drill-head { font-size: 13px; color: var(--text-2); margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 4px 10px; }
246
+ .drill-head b { color: var(--text); }
247
+ .drill .chart { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 12px 6px; }
248
+ .drill .fine { font-size: 12px; color: var(--muted); margin: 8px 0 0; }
249
+ .stats { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; font-size: 13px; align-content: start; }
250
+ .stats dt { color: var(--muted); }
251
+ .stats dd { margin: 0; text-align: right; font-weight: 500; }
252
+ .stats .sep { grid-column: 1 / -1; border-top: 1px solid var(--line-2); margin: 4px 0; }
253
+ .more { font-size: 12px; color: var(--muted); margin: 10px 0 0; }
254
+
255
+ /* key findings */
256
+ .findings { display: grid; gap: 12px; margin-top: 28px; }
257
+ @media (min-width: 720px) { .findings { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
258
+ .finding { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; min-width: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; align-items: baseline; }
259
+ .finding .v { font: 650 26px/1.1 var(--sans); letter-spacing: -0.02em; font-variant-numeric: tabular-nums; grid-row: span 3; min-width: 64px; }
260
+ .finding h3 { font-size: 14px; font-weight: 600; margin: 0; line-height: 1.4; }
261
+ .finding p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
262
+ .finding .act { color: var(--text-2); }
263
+ .finding .act::before { content: '→ '; color: var(--read); font-weight: 600; }
264
+ .findings-head { font-size: 15px; font-weight: 600; margin: 32px 0 0; }
265
+ .show-all { margin-top: 12px; }
266
+
267
+ /* share */
268
+ .share-grid { display: grid; gap: 20px; }
269
+ @media (min-width: 820px) { .share-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 420px); align-items: start; } }
270
+ .quote { margin: 0 0 14px; padding: 12px 14px; border-left: 3px solid var(--read); background: var(--surface-2); border-radius: 0 8px 8px 0; color: var(--text-2); }
271
+ .share-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
272
+ .status { font-size: 13px; color: var(--muted); min-height: 1.5em; }
273
+ .card-preview { width: 100%; height: auto; display: block; border-radius: 8px; border: 1px solid var(--line); }
274
+
275
+ /* footer */
276
+ footer { border-top: 1px solid var(--line); margin-top: 32px; padding: 24px 0 48px; font-size: 13px; color: var(--muted); }
277
+ footer h2 { font-size: 13px; color: var(--text-2); margin: 0 0 8px; font-weight: 600; }
278
+ footer ul { margin: 0 0 16px; padding-left: 18px; max-width: 760px; }
279
+ footer li { margin: 4px 0; }
280
+ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12px; }
281
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
282
+ .empty-page { padding: 80px 0; }
283
+ .empty-page h1 { font-size: 24px; margin: 0 0 8px; }
284
+
285
+ @media print {
286
+ .bar .tools, .share-actions, .sessions-tools, details.table-view { display: none; }
287
+ .card { break-inside: avoid; }
288
+ }
289
+ </style>
290
+ </head>
291
+ <body>
292
+ <header class="bar">
293
+ <div class="wrap">
294
+ <div class="brand">
295
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66"/><path d="M20 4v5h-5"/></svg>
296
+ <span>ReRead</span><span class="ver" id="ver"></span>
297
+ </div>
298
+ <div class="tools" role="toolbar" aria-label="Report actions">
299
+ <button type="button" class="btn" id="hide-names" aria-pressed="false">
300
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8Z"/><circle cx="8" cy="8" r="2"/><path class="check" d="M2 2l12 12"/></svg>
301
+ Hide names
302
+ </button>
303
+ <button type="button" class="btn" id="export-json" aria-label="Export JSON">
304
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v8M4.5 6.5 8 10l3.5-3.5M2.5 13.5h11"/></svg>
305
+ <span><span class="wide">Export </span>JSON</span>
306
+ </button>
307
+ <button type="button" class="btn" id="export-csv" aria-label="Export sessions as CSV">
308
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v8M4.5 6.5 8 10l3.5-3.5M2.5 13.5h11"/></svg>
309
+ <span><span class="wide">Export </span>CSV</span>
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </header>
314
+
315
+ <main class="wrap" id="app">
316
+ <section class="hero" aria-labelledby="headline">
317
+ <p class="private">
318
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/></svg>
319
+ Made on your machine. Nothing was uploaded.
320
+ </p>
321
+ <h1 class="headline" id="headline"><span class="big" id="hero-pct">–</span><span class="words" id="hero-words">of your Claude Code usage was re&#8209;reading context</span></h1>
322
+ <p class="meta" id="meta"></p>
323
+ <div class="split">
324
+ <div class="split-bar" id="split-bar" role="img"></div>
325
+ <div class="legend3" id="split-legend"></div>
326
+ <p class="note" id="split-note"></p>
327
+ </div>
328
+ </section>
329
+
330
+ <section aria-labelledby="h-findings" id="findings-box">
331
+ <h2 class="findings-head" id="h-findings">Key findings</h2>
332
+ <div class="findings" id="findings"></div>
333
+ </section>
334
+
335
+ <section class="card first" aria-labelledby="h-daily">
336
+ <div class="card-head">
337
+ <div>
338
+ <h2 id="h-daily">Day by day</h2>
339
+ <p class="sub">What each day’s replies would cost on the API, split by what the money paid for.</p>
340
+ </div>
341
+ <div class="chart-legend" aria-hidden="true">
342
+ <span><i class="sw read"></i>Cache reads</span>
343
+ <span><i class="sw rebuild"></i>History written again</span>
344
+ <span><i class="sw other"></i>Everything else</span>
345
+ </div>
346
+ </div>
347
+ <div id="daily"></div>
348
+ </section>
349
+
350
+ <div class="grid two">
351
+ <section class="card" aria-labelledby="h-size">
352
+ <h2 id="h-size">Cost by context size</h2>
353
+ <p class="sub">Every reply sends the whole context again, so long contexts cost more per reply.</p>
354
+ <div id="buckets"></div>
355
+ </section>
356
+ <section class="card" aria-labelledby="h-why">
357
+ <h2 id="h-why">Why the cache was lost</h2>
358
+ <p class="sub">Each time, the whole history up to that reply was written to the cache again at full price.</p>
359
+ <div id="why"></div>
360
+ </section>
361
+ </div>
362
+
363
+ <div class="grid two">
364
+ <section class="card" aria-labelledby="h-proj">
365
+ <h2 id="h-proj">By project</h2>
366
+ <p class="sub">Folder each session ran in.</p>
367
+ <div id="projects"></div>
368
+ </section>
369
+ <section class="card" aria-labelledby="h-models">
370
+ <h2 id="h-models">By model</h2>
371
+ <p class="sub">Each model keeps its own cache.</p>
372
+ <div id="models"></div>
373
+ </section>
374
+ </div>
375
+
376
+ <section class="card" style="margin-top:16px" id="sessions" aria-labelledby="h-sessions">
377
+ <div class="card-head">
378
+ <div>
379
+ <h2 id="h-sessions">Sessions</h2>
380
+ <p class="sub" id="sessions-sub">Costliest first. Open a session to see its context reply by reply.</p>
381
+ </div>
382
+ <div class="sessions-tools">
383
+ <label class="search">
384
+ <span class="sr">Filter sessions</span>
385
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>
386
+ <input type="search" id="filter" placeholder="Filter by project, branch or id" autocomplete="off" spellcheck="false">
387
+ </label>
388
+ </div>
389
+ </div>
390
+ <div class="tbl-scroll" id="sessions-scroll"></div>
391
+ <p class="more" id="sessions-more" aria-live="polite"></p>
392
+ </section>
393
+
394
+ <div class="grid">
395
+ <section class="card" aria-labelledby="h-share">
396
+ <h2 id="h-share">Share it</h2>
397
+ <p class="sub">The text and the picture carry only the totals: no project, branch or session.</p>
398
+ <div class="share-grid">
399
+ <div>
400
+ <p class="quote" id="share-text"></p>
401
+ <div class="share-actions">
402
+ <button type="button" class="btn primary" id="copy-text">Copy text</button>
403
+ <button type="button" class="btn" id="download-image">Download image</button>
404
+ <span class="status" id="share-status" role="status" aria-live="polite"></span>
405
+ </div>
406
+ </div>
407
+ <canvas id="card" class="card-preview" width="1200" height="630" role="img" aria-label="Share picture preview"></canvas>
408
+ </div>
409
+ </section>
410
+ </div>
411
+ </main>
412
+
413
+ <footer>
414
+ <div class="wrap">
415
+ <h2>How it counts</h2>
416
+ <ul>
417
+ <li>Each kind of token (input, cache writes, cache reads, output) is weighed by its API price for the model that used it, so the shares match what the same tokens would cost on the API.</li>
418
+ <li>“Written again” is an estimate from token counts: a reply whose cache read fell well below the previous reply’s context while the context did not shrink.</li>
419
+ <li>Subscription plans meter usage their own way and do not publish it, so treat these numbers as a guide, not your plan’s meter.</li>
420
+ </ul>
421
+ <div class="links">
422
+ <a href="https://github.com/tecyfyai/reread" target="_blank" rel="noopener noreferrer">ReRead on GitHub</a>
423
+ <a href="https://tecyfy.com/ai-agents/prompt-caching-calculator" target="_blank" rel="noopener noreferrer">What caching saves for your own prompt</a>
424
+ </div>
425
+ <p id="made" style="margin:0"></p>
426
+ <p style="margin:4px 0 0">ReRead is independent and not made, endorsed or supported by Anthropic.</p>
427
+ </div>
428
+ </footer>
429
+
430
+ <script id="reread-data" type="application/json">__REREAD_DATA__</script>
431
+ <script>
432
+ (() => {
433
+ 'use strict'
434
+
435
+ // ---------- small DOM helpers (every string from the data goes in as text, never as HTML) ----------
436
+ const SVGNS = 'http://www.w3.org/2000/svg'
437
+ function el(tag, props, ...kids) {
438
+ const n = document.createElement(tag)
439
+ for (const [k, v] of Object.entries(props || {})) {
440
+ if (v == null || v === false) continue
441
+ if (k === 'class') n.className = v
442
+ else if (k === 'text') n.textContent = v
443
+ else if (k.startsWith('on')) n.addEventListener(k.slice(2).toLowerCase(), v)
444
+ else n.setAttribute(k, v === true ? '' : String(v))
445
+ }
446
+ for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c))
447
+ return n
448
+ }
449
+ function sv(tag, attrs, ...kids) {
450
+ const n = document.createElementNS(SVGNS, tag)
451
+ for (const [k, v] of Object.entries(attrs || {})) if (v != null) n.setAttribute(k, String(v))
452
+ for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c))
453
+ return n
454
+ }
455
+ const $ = (id) => document.getElementById(id)
456
+
457
+ // ---------- data ----------
458
+ let raw = null
459
+ try { raw = JSON.parse($('reread-data').textContent) } catch { raw = null }
460
+ if (!raw || typeof raw !== 'object' || !raw.summary) {
461
+ $('app').replaceChildren(el('div', { class: 'empty-page' },
462
+ el('h1', { text: 'No data in this report' }),
463
+ el('p', { class: 'muted', text: 'This is ReRead’s report template. Run npx reread-cli in a terminal to make a report from your own Claude Code, Codex or Antigravity sessions.' })))
464
+ for (const id of ['hide-names', 'export-json', 'export-csv']) $(id).disabled = true
465
+ return
466
+ }
467
+ const arr = (v) => (Array.isArray(v) ? v : [])
468
+ const N = (v) => (Number.isFinite(+v) ? +v : 0)
469
+ const S = (v) => (v == null ? '' : String(v))
470
+ const sm = raw.summary || {}
471
+ const D = {
472
+ version: S(raw.version), generatedAt: S(raw.generatedAt), span: S(raw.span) || 'your sessions', anon: !!raw.anon,
473
+ daily: arr(raw.daily), buckets: arr(raw.buckets), models: arr(raw.models), projects: arr(raw.projects),
474
+ sessions: arr(raw.sessions), findings: arr(raw.findings), share: S(raw.share),
475
+ product: S(raw.product) || 'Claude Code', tools: arr(raw.tools),
476
+ }
477
+ const REASONS = ['cold', 'model', 'prefix']
478
+ const rebuild = (k) => { const r = (sm.rebuilds || {})[k] || {}; return { count: N(r.count), usd: N(r.usd), share: N(r.share) } }
479
+ const shares = {
480
+ reread: N(sm.rereadShare), read: N(sm.cacheReadShare), rebuild: N(sm.rebuildShare),
481
+ other: sm.otherShare != null ? N(sm.otherShare) : Math.max(0, 100 - N(sm.rereadShare)),
482
+ }
483
+
484
+ // ---------- formatting ----------
485
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
486
+ const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
487
+ const pad2 = (n) => String(n).padStart(2, '0')
488
+ const int = (n) => Math.round(N(n)).toLocaleString('en-US')
489
+ const trim0 = (s) => s.replace(/\.0$/, '')
490
+ function tok(n) {
491
+ n = N(n)
492
+ if (n >= 1e9) return trim0((n / 1e9).toFixed(1)) + 'B'
493
+ if (n >= 1e6) return trim0((n / 1e6).toFixed(n >= 1e8 ? 0 : 1)) + 'M'
494
+ if (n >= 1e4) return Math.round(n / 1e3) + 'k'
495
+ if (n >= 1e3) return trim0((n / 1e3).toFixed(1)) + 'k'
496
+ return String(Math.round(n))
497
+ }
498
+ function usd(v) {
499
+ v = N(v)
500
+ if (v === 0) return '$0'
501
+ const a = Math.abs(v), sign = v < 0 ? '−' : ''
502
+ if (a < 0.01) return '<$0.01'
503
+ if (a >= 100) return sign + '$' + Math.round(a).toLocaleString('en-US')
504
+ return sign + '$' + a.toFixed(2)
505
+ }
506
+ function usdAxis(v) {
507
+ if (v >= 1e6) return '$' + trim0((v / 1e6).toFixed(1)) + 'M'
508
+ if (v >= 1e4) return '$' + Math.round(v / 1e3) + 'k'
509
+ if (v >= 1e3) return '$' + trim0((v / 1e3).toFixed(1)) + 'k'
510
+ if (v >= 10 || v === 0) return '$' + Math.round(v)
511
+ return '$' + trim0(v.toFixed(v < 1 ? 2 : 1))
512
+ }
513
+ const pct = (v, d = 1) => N(v).toFixed(d) + '%'
514
+ const share = (a, b) => (b > 0 ? (100 * a) / b : 0)
515
+ const plural = (n, one, many) => `${int(n)} ${n === 1 ? one : many}`
516
+ function parseDay(d) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(S(d)); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null }
517
+ const dayShort = (d) => { const t = parseDay(d); return t ? `${MON[t.getMonth()]} ${t.getDate()}` : S(d) }
518
+ const dayLong = (d) => { const t = parseDay(d); return t ? `${DOW[t.getDay()]}, ${MON[t.getMonth()]} ${t.getDate()}, ${t.getFullYear()}` : S(d) }
519
+ function when(iso) {
520
+ const t = new Date(typeof iso === 'number' ? iso : S(iso))
521
+ return isNaN(t) ? '—' : `${MON[t.getMonth()]} ${t.getDate()}, ${pad2(t.getHours())}:${pad2(t.getMinutes())}`
522
+ }
523
+ function clock(ms) { const t = new Date(ms); return isNaN(t) ? '—' : `${pad2(t.getHours())}:${pad2(t.getMinutes())}:${pad2(t.getSeconds())}` }
524
+ function duration(ms) {
525
+ if (!(ms > 0)) return '—'
526
+ const m = Math.round(ms / 60000)
527
+ if (m < 1) return '<1 min'
528
+ if (m < 60) return `${m} min`
529
+ const h = Math.floor(m / 60)
530
+ if (h < 24) return m % 60 ? `${h} h ${m % 60} min` : `${h} h`
531
+ return `${Math.floor(h / 24)} d ${h % 24} h`
532
+ }
533
+ function niceTicks(max, count = 4) {
534
+ if (!(max > 0)) max = 1
535
+ const rough = max / count, p = Math.pow(10, Math.floor(Math.log10(rough))), f = rough / p
536
+ const step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p
537
+ const out = []
538
+ for (let i = 0; i <= count * 3; i++) { const v = +(i * step).toPrecision(12); out.push(v); if (v >= max) break }
539
+ return out
540
+ }
541
+
542
+ // ---------- names ----------
543
+ const store = {
544
+ get(k) { try { return window.localStorage.getItem(k) } catch { return null } },
545
+ set(k, v) { try { window.localStorage.setItem(k, v) } catch { /* private window or blocked storage */ } },
546
+ }
547
+ const state = {
548
+ hide: !D.anon && store.get('reread.hideNames') === '1',
549
+ sort: { key: 'usd', dir: -1 }, filter: '', open: null,
550
+ }
551
+ const projectAlias = new Map(), branchAlias = new Map()
552
+ for (const p of D.projects) { const k = S(p.project); if (!projectAlias.has(k)) projectAlias.set(k, `project ${projectAlias.size + 1}`) }
553
+ for (const s of D.sessions) {
554
+ const k = S(s.project); if (!projectAlias.has(k)) projectAlias.set(k, `project ${projectAlias.size + 1}`)
555
+ const b = S(s.branch); if (b && !branchAlias.has(b)) branchAlias.set(b, `branch ${branchAlias.size + 1}`)
556
+ }
557
+ const pname = (p) => { p = S(p); return state.hide ? projectAlias.get(p) || 'project' : p || '(unknown folder)' }
558
+ const bname = (b) => { b = S(b); return !b ? '—' : state.hide ? branchAlias.get(b) || 'branch' : b }
559
+
560
+ // ---------- marks (one shape per reason, the same everywhere) ----------
561
+ const REASON = {
562
+ cold: { name: 'Cache went cold', flag: 1, exp: 'A break longer than the cache’s life (5 minutes, or 1 hour when the longer cache was used). The next reply wrote the whole history again.' },
563
+ model: { name: 'Model changed mid-session', flag: 2, exp: 'Each model keeps its own cache, so the first reply on the new model wrote the whole history again.' },
564
+ prefix: { name: 'Early context changed', flag: 3, exp: 'Something near the start changed while the cache was warm (tools, MCP servers, settings), so everything after it was written again.' },
565
+ }
566
+ function mark(kind, cx, cy, r = 4.5) {
567
+ const ring = { stroke: 'var(--surface)', 'stroke-width': 2, 'paint-order': 'stroke' }
568
+ if (kind === 'cold') return sv('circle', { cx, cy, r, fill: 'var(--rebuild)', ...ring })
569
+ if (kind === 'model') { const d = r * 1.35; return sv('path', { d: `M${cx},${cy - d}L${cx + d},${cy}L${cx},${cy + d}L${cx - d},${cy}Z`, fill: 'var(--rebuild)', ...ring }) }
570
+ if (kind === 'prefix') { const d = r * 1.3; return sv('path', { d: `M${cx},${cy - d}L${cx + d},${cy + d * 0.8}L${cx - d},${cy + d * 0.8}Z`, fill: 'var(--rebuild)', ...ring }) }
571
+ const d = r * 0.85
572
+ return sv('rect', { x: cx - d, y: cy - d, width: 2 * d, height: 2 * d, fill: 'var(--surface)', stroke: 'var(--shrink)', 'stroke-width': 1.75 })
573
+ }
574
+ const markIcon = (kind) => sv('svg', { viewBox: '0 0 16 16', 'aria-hidden': 'true' }, mark(kind, 8, 8, 4.6))
575
+
576
+ // ---------- tooltip ----------
577
+ function tipRow(sw, k, v) { return el('div', { class: 'r' }, sw ? el('i', { class: `sw ${sw}` }) : null, el('span', { class: 'k', text: k }), el('span', { text: v })) }
578
+ function placeTip(tip, box, x, y) {
579
+ tip.hidden = false
580
+ // Beside the point, so the tip never covers the mark it describes; flips left near the right edge.
581
+ const bw = box.clientWidth, bh = box.clientHeight, tw = tip.offsetWidth, th = tip.offsetHeight
582
+ const right = x + 14 + tw <= bw
583
+ tip.style.left = Math.max(0, right ? x + 14 : x - 14 - tw) + 'px'
584
+ tip.style.top = Math.max(0, Math.min(bh - th, y - th / 2)) + 'px'
585
+ }
586
+
587
+ // Redraw a chart when its box changes width.
588
+ function onWidth(node, draw) {
589
+ let last = 0
590
+ const run = () => { const w = node.clientWidth; if (w && w !== last) { last = w; draw(w) } }
591
+ if ('ResizeObserver' in window) new ResizeObserver(run).observe(node)
592
+ else window.addEventListener('resize', run)
593
+ run()
594
+ }
595
+
596
+ // ---------- header ----------
597
+ function renderHeader() {
598
+ $('ver').textContent = D.version ? `v${D.version}` : ''
599
+ document.title = `ReRead · ${Math.round(shares.reread)}% re-reading`
600
+ $('hero-pct').textContent = `${Math.round(shares.reread)}%`
601
+ $('hero-words').textContent = `of your ${D.product} usage was re\u2011reading context`
602
+ const meta = $('meta')
603
+ // With more than one tool, what each one cost and how much of it was re-reading.
604
+ const byTool = D.tools.length > 1
605
+ ? D.tools.flatMap((t) => [' · ', el('b', { text: S(t.name) }), ` ${usd(t.usd)}, ${pct(t.rereadShare, 0)} re-read`])
606
+ : []
607
+ meta.replaceChildren(
608
+ el('span', { text: D.span }), ' · ',
609
+ el('b', { text: int(sm.sessions) }), ` ${N(sm.sessions) === 1 ? 'session' : 'sessions'} · `,
610
+ el('b', { text: int(sm.calls) }), ` ${N(sm.calls) === 1 ? 'reply' : 'replies'} · `,
611
+ el('b', { text: usd(sm.apiEquivalentUSD) }), ' at API prices', ...byTool)
612
+ const parts = [
613
+ ['read', shares.read, 'Reading history from the cache', 'the history each reply sends again, at the cache price'],
614
+ ['rebuild', shares.rebuild, 'Writing history again', 'after the cache was lost, at the cache-write price'],
615
+ ['other', shares.other, 'Everything else', 'new input, first cache writes, output'],
616
+ ]
617
+ const bar = $('split-bar')
618
+ bar.setAttribute('aria-label', parts.map(([, v, l]) => `${l} ${pct(v)}`).join(', '))
619
+ bar.replaceChildren(...parts.filter(([, v]) => v > 0).map(([c, v]) => el('span', { style: `flex:${v} 1 0;background:var(--${c})` })))
620
+ $('split-legend').replaceChildren(...parts.map(([c, v, l, d]) => el('div', { class: 'leg' },
621
+ el('i', { class: `sw ${c}` }),
622
+ el('div', null, el('div', { class: 'v', text: pct(v) }), el('div', { class: 'l', text: l })),
623
+ el('div', { class: 'd', text: d }))))
624
+ $('split-note').replaceChildren(el('b', { text: pct(sm.tokensReadFromCache) }), ' of all tokens read came from the cache. Cache reads are cheap per token, but every reply in a long session pays for the whole history again.')
625
+ const made = new Date(D.generatedAt)
626
+ $('made').textContent = `Report made ${isNaN(made) ? '' : when(made.getTime()) + ', '}by ReRead${D.version ? ' ' + D.version : ''} from the session files on this machine.`
627
+ }
628
+
629
+ // ---------- daily chart ----------
630
+ function renderDaily() {
631
+ const box = $('daily')
632
+ const days = D.daily.map((d) => {
633
+ const total = N(d.usd), read = Math.min(N(d.readUsd), total), reb = Math.min(N(d.rebuildUsd), Math.max(0, total - read))
634
+ return { day: S(d.day), calls: N(d.calls), total, read, reb, other: Math.max(0, total - read - reb) }
635
+ })
636
+ if (!days.length || !days.some((d) => d.total > 0)) { box.replaceChildren(el('p', { class: 'empty', text: 'No replies in this span.' })); return }
637
+ const chart = el('div', { class: 'chart chart-focus', tabindex: 0, role: 'group',
638
+ 'aria-label': 'Cost per day. Use the left and right arrow keys to read each day.' })
639
+ const tip = el('div', { class: 'tip', hidden: true, 'aria-live': 'polite' })
640
+ const table = el('details', { class: 'table-view' }, el('summary', { text: 'Show as a table' }),
641
+ el('div', { class: 'tbl-scroll' }, el('table', { class: 'tbl' },
642
+ el('thead', null, el('tr', null, ['Day', 'Replies', 'Cost', 'Cache reads', 'Written again', 'Re-reading'].map((t, i) => el('th', { class: i ? 'num' : null, text: t })))),
643
+ el('tbody', null, days.map((d) => el('tr', null,
644
+ el('td', { text: dayLong(d.day) }), el('td', { class: 'num', text: int(d.calls) }), el('td', { class: 'num', text: usd(d.total) }),
645
+ el('td', { class: 'num', text: usd(d.read) }), el('td', { class: 'num', text: usd(d.reb) }),
646
+ el('td', { class: 'num', text: d.total ? pct(share(d.read + d.reb, d.total), 0) : '—' })))))))
647
+ box.replaceChildren(chart, table)
648
+ let active = -1, geo = null
649
+
650
+ function show(i) {
651
+ if (!geo || i < 0) return
652
+ active = i
653
+ const d = days[i]
654
+ geo.hl.setAttribute('x', geo.x(i)); geo.hl.setAttribute('width', geo.band); geo.hl.style.display = ''
655
+ tip.replaceChildren(
656
+ el('div', { class: 't', text: dayLong(d.day) }),
657
+ tipRow(null, 'Cost', usd(d.total)),
658
+ tipRow('read', 'Cache reads', usd(d.read)),
659
+ tipRow('rebuild', 'Written again', usd(d.reb)),
660
+ tipRow('other', 'Everything else', usd(d.other)),
661
+ el('div', { class: 'what' }, tipRow(null, 'Re-reading', d.total ? pct(share(d.read + d.reb, d.total), 0) : '—'), tipRow(null, 'Replies', int(d.calls))))
662
+ placeTip(tip, chart, geo.x(i) + geo.band / 2, geo.top(i))
663
+ }
664
+ function hide() { active = -1; tip.hidden = true; if (geo) geo.hl.style.display = 'none' }
665
+
666
+ function draw(W) {
667
+ const H = W < 520 ? 190 : 230, m = { l: 44, r: 4, t: 10, b: 24 }
668
+ const iw = W - m.l - m.r, ih = H - m.t - m.b, n = days.length
669
+ const ticks = niceTicks(Math.max(...days.map((d) => d.total))), ymax = ticks[ticks.length - 1]
670
+ const band = iw / n, bw = n === 1 ? Math.min(band * 0.4, 64) : Math.max(1.5, Math.min(band * 0.72, 36))
671
+ const x = (i) => m.l + i * band, y = (v) => m.t + ih - (v / ymax) * ih
672
+ const svg = sv('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, 'aria-hidden': 'true' })
673
+ const axis = sv('g', { class: 'axis' })
674
+ for (const t of ticks) {
675
+ axis.append(sv('line', { x1: m.l, x2: W - m.r, y1: y(t), y2: y(t), class: t === 0 ? 'base' : null }))
676
+ axis.append(sv('text', { x: m.l - 8, y: y(t) + 4, 'text-anchor': 'end' }, usdAxis(t)))
677
+ }
678
+ const every = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(iw / 56))))
679
+ for (let i = 0; i < n; i += every) axis.append(sv('text', { x: x(i) + band / 2, y: H - 6, 'text-anchor': 'middle' }, dayShort(days[i].day)))
680
+ const hl = sv('rect', { class: 'hl', y: m.t, height: ih, rx: 3, style: 'display:none' })
681
+ svg.append(hl, axis)
682
+ const bars = sv('g')
683
+ days.forEach((d, i) => {
684
+ const bx = x(i) + (band - bw) / 2
685
+ let base = y(0)
686
+ const segs = [['read', d.read], ['rebuild', d.reb], ['other', d.other]].filter(([, v]) => v > 0)
687
+ segs.forEach(([c, v], k) => {
688
+ const h = (v / ymax) * ih, gap = k > 0 && h > 2 ? 1 : 0, top = base - h
689
+ const r = k === segs.length - 1 ? Math.min(3, bw / 2, h) : 0
690
+ const d2 = `M${bx},${base - gap}V${top + r}${r ? `Q${bx},${top} ${bx + r},${top}` : ''}H${bx + bw - r}${r ? `Q${bx + bw},${top} ${bx + bw},${top + r}` : ''}V${base - gap}Z`
691
+ bars.append(sv('path', { d: d2, fill: `var(--${c})` }))
692
+ base = top
693
+ })
694
+ })
695
+ svg.append(bars)
696
+ const hit = sv('g')
697
+ days.forEach((d, i) => {
698
+ const r = sv('rect', { x: x(i), y: m.t, width: band, height: ih + m.b, fill: 'transparent' })
699
+ r.addEventListener('pointerenter', () => show(i))
700
+ hit.append(r)
701
+ })
702
+ svg.addEventListener('pointerleave', () => { if (document.activeElement !== chart) hide() })
703
+ svg.append(hit)
704
+ chart.replaceChildren(svg, tip)
705
+ geo = { x, band, hl, top: (i) => y(days[i].total) }
706
+ if (active >= 0) show(active)
707
+ }
708
+ chart.addEventListener('keydown', (e) => {
709
+ const last = days.length - 1
710
+ let i = active
711
+ if (e.key === 'ArrowRight') i = Math.min(last, i < 0 ? last : i + 1)
712
+ else if (e.key === 'ArrowLeft') i = Math.max(0, i < 0 ? last : i - 1)
713
+ else if (e.key === 'Home') i = 0
714
+ else if (e.key === 'End') i = last
715
+ else if (e.key === 'Escape') { hide(); return }
716
+ else return
717
+ e.preventDefault(); show(i)
718
+ })
719
+ chart.addEventListener('focus', () => { if (active < 0) { let i = days.length - 1; while (i > 0 && !days[i].total) i--; show(i) } })
720
+ chart.addEventListener('blur', hide)
721
+ onWidth(chart, draw)
722
+ }
723
+
724
+ // ---------- context size ----------
725
+ function renderBuckets() {
726
+ const box = $('buckets')
727
+ const rows = D.buckets.map((b) => ({ label: S(b.label), calls: N(b.calls), usd: N(b.usd), read: Math.min(N(b.readUsd), N(b.usd)) })).filter((b) => b.calls || b.usd)
728
+ if (!rows.length) { box.replaceChildren(el('p', { class: 'empty', text: 'No replies to group.' })); return }
729
+ const total = rows.reduce((a, b) => a + b.usd, 0), max = Math.max(...rows.map((b) => b.usd), 1e-9)
730
+ // A label's lower bound, with the first unit in it: "150–200k" → 150k, "500k+" → 500k, "1M+" → 1M.
731
+ const isLong = (l) => { const n = parseFloat(l), u = (/[kKmM]/.exec(l) || [''])[0].toLowerCase(); return n * (u === 'm' ? 1e6 : u === 'k' ? 1e3 : 1) >= 150e3 }
732
+ const lead = el('p', { class: 'lead' }, 'Replies over 150k tokens of context made ', el('b', { text: pct(sm.longContextShare, 0) }), ' of usage.')
733
+ const grid = el('div', { class: 'hbars', role: 'list' })
734
+ for (const b of rows) {
735
+ const long = isLong(b.label)
736
+ grid.append(
737
+ el('span', { class: 'lbl' + (long ? ' long' : ''), text: b.label }),
738
+ el('span', { class: 'track', role: 'img', 'aria-label': `${b.label}: ${usd(b.usd)}, of which cache reads ${usd(b.read)}, ${plural(b.calls, 'reply', 'replies')}` },
739
+ b.read > 0 ? el('span', { style: `width:${(100 * b.read) / max}%;background:var(--read)` }) : null,
740
+ b.usd - b.read > 0 ? el('span', { style: `width:${(100 * (b.usd - b.read)) / max}%;background:var(--other)` }) : null),
741
+ el('span', { class: 'val' }, usd(b.usd), el('span', { class: 'muted', text: pct(share(b.usd, total), 0) })))
742
+ }
743
+ const legend = el('div', { class: 'chart-legend', style: 'margin:14px 0 0' },
744
+ el('span', null, el('i', { class: 'sw read' }), 'Cache reads'),
745
+ el('span', null, el('i', { class: 'sw other' }), 'Everything else in those replies'))
746
+ box.replaceChildren(lead, grid, legend)
747
+ }
748
+
749
+ // ---------- why the cache was lost ----------
750
+ function renderWhy() {
751
+ const box = $('why')
752
+ const rs = REASONS.map((k) => ({ k, ...rebuild(k) }))
753
+ const count = rs.reduce((a, r) => a + r.count, 0), cost = rs.reduce((a, r) => a + r.usd, 0)
754
+ if (!count) { box.replaceChildren(el('p', { class: 'empty', text: 'The cache was never lost in this span. Nothing was written again.' })); return }
755
+ const lead = el('p', { class: 'lead' }, 'History was written again ', el('b', { text: plural(count, 'time', 'times') }), ', costing ',
756
+ el('b', { text: usd(cost) }), ` (${pct(shares.rebuild)} of usage).`)
757
+ const list = el('ul', { class: 'why' }, rs.map((r) => el('li', null,
758
+ markIcon(r.k),
759
+ el('div', { class: 'name', text: REASON[r.k].name }),
760
+ el('div', { class: 'nums' }, el('div', { class: 'usd', text: usd(r.usd) }),
761
+ el('span', { class: 'muted', text: `${plural(r.count, 'time', 'times')} · ${pct(r.share)}` })),
762
+ el('div', { class: 'exp', text: REASON[r.k].exp }))))
763
+ box.replaceChildren(lead, list)
764
+ }
765
+
766
+ // ---------- projects and models ----------
767
+ function minibar(parts, label) {
768
+ const t = el('span', { class: 'track', 'aria-hidden': 'true' })
769
+ for (const [c, v] of parts) if (v > 0) t.append(el('span', { style: `width:${Math.min(100, v)}%;background:var(--${c})` }))
770
+ return el('span', { class: 'minibar' }, t, el('span', { text: label }))
771
+ }
772
+ function simpleTable(head, rows) {
773
+ return el('div', { class: 'tbl-scroll' }, el('table', { class: 'tbl' },
774
+ el('thead', null, el('tr', null, head.map(([t, num, opt]) => el('th', { class: [num ? 'num' : '', opt ? 'opt' : ''].join(' ').trim() || null, scope: 'col', text: t })))),
775
+ el('tbody', null, rows)))
776
+ }
777
+ function renderProjects() {
778
+ const box = $('projects')
779
+ if (!D.projects.length) { box.replaceChildren(el('p', { class: 'empty', text: 'No projects.' })); return }
780
+ const shown = state.allProjects ? D.projects : D.projects.slice(0, 6)
781
+ const rows = shown.map((p) => el('tr', null,
782
+ el('td', { class: 'name', title: pname(p.project), text: pname(p.project) }),
783
+ el('td', { class: 'num', text: int(p.sessions) }),
784
+ el('td', { class: 'num opt', text: int(p.calls) }),
785
+ el('td', { class: 'num', text: usd(p.usd) }),
786
+ el('td', { class: 'num' }, minibar([['read', N(p.rereadShare)]], pct(p.rereadShare, 0)))))
787
+ const more = D.projects.length > 6 ? el('button', { type: 'button', class: 'btn show-all', onclick: () => { state.allProjects = !state.allProjects; renderProjects() },
788
+ text: state.allProjects ? 'Show fewer' : `Show all ${int(D.projects.length)} projects` }) : null
789
+ box.replaceChildren(simpleTable([['Project'], ['Sessions', 1], ['Replies', 1, 1], ['Cost', 1], ['Re-reading', 1]], rows), more || '')
790
+ }
791
+ function renderModels() {
792
+ const box = $('models')
793
+ if (!D.models.length) { box.replaceChildren(el('p', { class: 'empty', text: 'No models.' })); return }
794
+ const rows = D.models.map((m) => {
795
+ const u = N(m.usd), r = share(N(m.readUsd), u), b = share(N(m.rebuildUsd), u)
796
+ return el('tr', null,
797
+ el('td', { class: 'name mono', title: S(m.model), text: S(m.model) || '(unknown)' }),
798
+ el('td', { class: 'num opt', text: int(m.calls) }),
799
+ el('td', { class: 'num', text: usd(u) }),
800
+ el('td', { class: 'num' }, minibar([['read', r], ['rebuild', b]], pct(r + b, 0))))
801
+ })
802
+ box.replaceChildren(simpleTable([['Model'], ['Replies', 1, 1], ['Cost', 1], ['Re-reading', 1]], rows),
803
+ el('div', { class: 'chart-legend', style: 'margin:12px 0 0' },
804
+ el('span', null, el('i', { class: 'sw read' }), 'Cache reads'),
805
+ el('span', null, el('i', { class: 'sw rebuild' }), 'Written again')))
806
+ }
807
+
808
+ // ---------- sessions ----------
809
+ const rebuildsOf = (s) => { const r = s.rebuilds || {}; return { cold: N(r.cold), model: N(r.model), prefix: N(r.prefix) } }
810
+ const rebuildTotal = (s) => { const r = rebuildsOf(s); return r.cold + r.model + r.prefix }
811
+ const COLS = [
812
+ { key: 'started', label: 'Started', get: (s) => Date.parse(S(s.started)) || 0 },
813
+ { key: 'project', label: 'Project', text: true, get: (s) => pname(s.project).toLowerCase() },
814
+ { key: 'branch', label: 'Branch', text: true, get: (s) => bname(s.branch).toLowerCase() },
815
+ { key: 'calls', label: 'Replies', num: true, get: (s) => N(s.calls) },
816
+ { key: 'peak', label: 'Peak context', num: true, get: (s) => N(s.peak) },
817
+ { key: 'usd', label: 'Cost', num: true, get: (s) => N(s.usd) },
818
+ { key: 'rereadShare', label: 'Re-read', num: true, get: (s) => N(s.rereadShare) },
819
+ { key: 'rebuilds', label: 'Rebuilds', num: true, get: rebuildTotal },
820
+ ]
821
+ const sessionKey = (s, i) => `${S(s.id)}#${i}`
822
+ const SESSIONS = D.sessions.map((s, i) => ({ s, key: sessionKey(s, i) }))
823
+ let scrollObserver = null
824
+
825
+ function renderSessions() {
826
+ const scroll = $('sessions-scroll')
827
+ if (!SESSIONS.length) {
828
+ scroll.replaceChildren(el('p', { class: 'empty', text: 'No sessions in this span.' }))
829
+ $('filter').disabled = true
830
+ return
831
+ }
832
+ const q = state.filter.trim().toLowerCase()
833
+ const col = COLS.find((c) => c.key === state.sort.key) || COLS[5]
834
+ const list = SESSIONS.filter(({ s }) => !q || [pname(s.project), bname(s.branch), S(s.id)].some((t) => t.toLowerCase().includes(q)))
835
+ .sort((a, b) => {
836
+ const x = col.get(a.s), y = col.get(b.s)
837
+ return (x < y ? -1 : x > y ? 1 : 0) * state.sort.dir || N(b.s.usd) - N(a.s.usd)
838
+ })
839
+ const head = el('tr', null, COLS.map((c) => {
840
+ const sorted = state.sort.key === c.key
841
+ return el('th', { class: c.num ? 'num' : null, scope: 'col', 'aria-sort': sorted ? (state.sort.dir > 0 ? 'ascending' : 'descending') : null },
842
+ el('button', { type: 'button', class: 'sort', onclick: () => {
843
+ state.sort = sorted ? { key: c.key, dir: -state.sort.dir } : { key: c.key, dir: c.text ? 1 : -1 }
844
+ renderSessions()
845
+ const btn = document.querySelector(`#sessions th[data-k="${c.key}"] button`)
846
+ if (btn) btn.focus()
847
+ } }, c.num ? el('span', { class: 'arrow', 'aria-hidden': 'true', text: sorted && state.sort.dir > 0 ? '↑' : '↓' }) : null,
848
+ c.label, c.num ? null : el('span', { class: 'arrow', 'aria-hidden': 'true', text: sorted && state.sort.dir > 0 ? '↑' : '↓' })))
849
+ }))
850
+ head.querySelectorAll('th').forEach((th, i) => th.setAttribute('data-k', COLS[i].key))
851
+ const body = el('tbody')
852
+ // The 10 costliest by default; a filter or "Show all" lists every match.
853
+ const LIMIT = 10, cut = !q && !state.allSessions && list.length > LIMIT
854
+ for (const { s, key } of cut ? list.slice(0, LIMIT) : list) {
855
+ const open = state.open === key
856
+ const r = rebuildsOf(s)
857
+ const toggle = () => { state.open = open ? null : key; renderSessions(); const row = document.querySelector(`#sessions tr[data-key="${CSS.escape(key)}"]`); if (row) row.focus() }
858
+ const tr = el('tr', { class: 'srow', tabindex: 0, 'aria-expanded': open ? 'true' : 'false', 'data-key': key,
859
+ 'aria-label': `Session ${S(s.id)}, ${pname(s.project)}, ${usd(s.usd)}. ${open ? 'Hide' : 'Show'} its context chart.`,
860
+ onclick: toggle,
861
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle() } } },
862
+ el('td', null, el('span', { class: 'chev', 'aria-hidden': 'true', text: '›' }), ' ', when(s.started), el('span', { class: 'sid', text: S(s.id) })),
863
+ el('td', { class: 'name', title: pname(s.project), text: pname(s.project) }),
864
+ el('td', { class: 'name mono', title: bname(s.branch), text: bname(s.branch), style: 'font-size:12px' }),
865
+ el('td', { class: 'num', text: int(s.calls) }),
866
+ el('td', { class: 'num', text: tok(s.peak) }),
867
+ el('td', { class: 'num', text: usd(s.usd) }),
868
+ el('td', { class: 'num' }, minibar([['read', N(s.rereadShare)]], pct(s.rereadShare, 0))),
869
+ el('td', { class: 'num' }, rebuildTotal(s)
870
+ ? el('span', { class: 'reb', title: `cold ${r.cold} · model ${r.model} · early context ${r.prefix}` },
871
+ REASONS.filter((k) => r[k]).map((k) => el('span', null, markIcon(k), String(r[k]))))
872
+ : el('span', { class: 'muted', text: '0' })))
873
+ body.append(tr)
874
+ if (open) body.append(drillRow(s))
875
+ }
876
+ if (!list.length) body.append(el('tr', null, el('td', { colspan: COLS.length, class: 'empty', text: 'No session matches that filter.' })))
877
+ scroll.replaceChildren(el('table', { class: 'tbl' }, el('thead', null, head), body))
878
+ const total = N(sm.sessions)
879
+ const moreBox = $('sessions-more')
880
+ moreBox.textContent = (q ? `${plural(list.length, 'session', 'sessions')} match. ` : '') +
881
+ (!q && total > SESSIONS.length ? `The report keeps the ${int(SESSIONS.length)} costliest of ${plural(total, 'session', 'sessions')}. ` : '')
882
+ if (!q && list.length > LIMIT) moreBox.prepend(el('button', { type: 'button', class: 'btn show-all', style: 'margin:0 12px 0 0',
883
+ onclick: () => { state.allSessions = !state.allSessions; renderSessions() },
884
+ text: state.allSessions ? 'Show the 10 costliest' : `Show all ${int(list.length)} sessions` }))
885
+ fitDrill()
886
+ }
887
+ function fitDrill() {
888
+ const scroll = $('sessions-scroll'), inner = scroll.querySelector('.drill-inner')
889
+ if (!inner) return
890
+ const cs = getComputedStyle(scroll)
891
+ inner.style.width = scroll.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 'px'
892
+ }
893
+
894
+ const WHAT = {
895
+ 0: 'Read the history from the cache',
896
+ 1: 'Cache went cold: history written again',
897
+ 2: 'Model changed: history written again',
898
+ 3: 'Early context changed: history written again',
899
+ 4: 'Context shrank (compaction or /clear)',
900
+ }
901
+ function drillRow(s) {
902
+ const pts = arr(s.points).filter(Array.isArray).map((p) => ({ ts: N(p[0]), ctx: N(p[1]), read: Math.min(N(p[2]), N(p[1])), flag: N(p[3]) }))
903
+ const r = rebuildsOf(s)
904
+ const shrinks = pts.filter((p) => p.flag === 4).length
905
+ const start = Date.parse(S(s.started)), end = Date.parse(S(s.ended))
906
+ const stats = el('dl', { class: 'stats' },
907
+ el('dt', { text: 'Started' }), el('dd', { text: when(s.started) }),
908
+ el('dt', { text: 'Ran for' }), el('dd', { text: duration(end - start) }),
909
+ el('dt', { text: 'Replies' }), el('dd', { text: int(s.calls) }),
910
+ el('dt', { text: 'Peak context' }), el('dd', { text: tok(s.peak) }),
911
+ el('dt', { text: 'Cost at API prices' }), el('dd', { text: usd(s.usd) }),
912
+ el('dt', { text: 'Re-reading' }), el('dd', { text: pct(s.rereadShare) }),
913
+ el('div', { class: 'sep' }),
914
+ ...REASONS.flatMap((k) => [el('dt', null, el('span', { class: 'reb', style: 'justify-content:flex-start' }, markIcon(k), REASON[k].name)), el('dd', { text: int(r[k]) })]),
915
+ el('dt', null, el('span', { class: 'reb', style: 'justify-content:flex-start' }, markIcon('shrink'), 'Context shrank')), el('dd', { text: int(shrinks) }))
916
+ const chartBox = el('div', { class: 'chart chart-focus', tabindex: 0, role: 'group',
917
+ 'aria-label': 'Context per reply. Use the left and right arrow keys to read each reply.' })
918
+ const legend = el('div', { class: 'chart-legend', 'aria-hidden': 'true' },
919
+ el('span', null, sv('svg', { viewBox: '0 0 14 14' }, sv('rect', { x: 1, y: 3, width: 12, height: 9, fill: 'var(--other)', 'fill-opacity': 0.35 }), sv('path', { d: 'M1 3.5h12', stroke: 'var(--text-2)', 'stroke-width': 1.5 })), 'Context sent'),
920
+ el('span', null, el('i', { class: 'sw read' }), 'Read from the cache'),
921
+ ...REASONS.map((k) => el('span', null, markIcon(k), REASON[k].name)),
922
+ el('span', null, markIcon('shrink'), 'Context shrank'))
923
+ const fine = pts.length && N(s.calls) > pts.length ? el('p', { class: 'fine', text: `Shown at ${int(pts.length)} of ${int(s.calls)} replies; every rebuild and shrink is kept.` }) : null
924
+ const inner = el('div', { class: 'drill-inner' },
925
+ el('div', { class: 'drill-head' }, el('b', { text: pname(s.project) }), el('span', { class: 'mono', text: bname(s.branch) }), el('span', { class: 'mono muted', text: S(s.id) })),
926
+ el('div', { class: 'drill-grid drill' }, el('div', null, legend, chartBox, fine), stats))
927
+ const row = el('tr', { class: 'drill' }, el('td', { colspan: COLS.length }, inner))
928
+ requestAnimationFrame(() => {
929
+ fitDrill()
930
+ if (!pts.length) { chartBox.replaceChildren(el('p', { class: 'empty', text: 'No reply-by-reply data for this session.' })); return }
931
+ sessionChart(chartBox, pts, N(s.calls))
932
+ })
933
+ return row
934
+ }
935
+
936
+ function sessionChart(box, pts, calls) {
937
+ const tip = el('div', { class: 'tip', hidden: true, 'aria-live': 'polite' })
938
+ const n = pts.length, total = Math.max(calls, n), sampled = total > n
939
+ const replyNo = (i) => (n === 1 ? 1 : Math.round(1 + (i * (total - 1)) / (n - 1)))
940
+ let active = -1, geo = null
941
+ function show(i) {
942
+ if (!geo) return
943
+ active = i
944
+ const p = pts[i], prev = pts[i - 1]
945
+ const cx = geo.x(i)
946
+ geo.cross.setAttribute('x1', cx); geo.cross.setAttribute('x2', cx); geo.cross.style.display = ''
947
+ geo.dot.setAttribute('cx', cx); geo.dot.setAttribute('cy', geo.y(p.ctx)); geo.dot.style.display = ''
948
+ const gap = !sampled && prev && p.ts - prev.ts >= 60e3 ? ` · ${duration(p.ts - prev.ts)} after the last reply` : ''
949
+ tip.replaceChildren(
950
+ el('div', { class: 't', text: `${sampled ? '≈ ' : ''}Reply ${int(replyNo(i))} of ${int(total)}` }),
951
+ el('div', { class: 'r' }, el('span', { class: 'k', text: clock(p.ts) + gap })),
952
+ tipRow('other', 'Context', tok(p.ctx)),
953
+ tipRow('read', 'Read from cache', `${tok(p.read)}${p.ctx ? ` (${p.read < p.ctx ? Math.min(99, Math.round(share(p.read, p.ctx))) : 100}%)` : ''}`),
954
+ el('div', { class: 'what' }, el('span', { text: WHAT[p.flag] && p.flag ? WHAT[p.flag] : p.read < p.ctx * 0.5 ? 'Mostly new context' : WHAT[0] })))
955
+ placeTip(tip, box, cx, geo.y(p.ctx))
956
+ }
957
+ function hide() { active = -1; tip.hidden = true; if (geo) { geo.cross.style.display = 'none'; geo.dot.style.display = 'none' } }
958
+ function draw(W) {
959
+ const H = W < 520 ? 200 : 240, m = { l: 40, r: 10, t: 12, b: 26 }
960
+ const iw = W - m.l - m.r, ih = H - m.t - m.b
961
+ const peak = Math.max(1, ...pts.map((p) => p.ctx)), guide = peak >= 100e3
962
+ const ticks = niceTicks(Math.max(peak, guide ? 150e3 : 0) * 1.04), ymax = ticks[ticks.length - 1]
963
+ const x = (i) => m.l + (n === 1 ? iw / 2 : (i * iw) / (n - 1)), y = (v) => m.t + ih - (v / ymax) * ih
964
+ const svg = sv('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, 'aria-hidden': 'true' })
965
+ const axis = sv('g', { class: 'axis' })
966
+ for (const t of ticks) {
967
+ axis.append(sv('line', { x1: m.l, x2: W - m.r, y1: y(t), y2: y(t), class: t === 0 ? 'base' : null }))
968
+ axis.append(sv('text', { x: m.l - 6, y: y(t) + 4, 'text-anchor': 'end' }, tok(t)))
969
+ }
970
+ const xt = n === 1 ? [0] : [...new Set([0, Math.round((n - 1) / 2), n - 1])]
971
+ xt.forEach((i, k) => axis.append(sv('text', { x: x(i), y: H - 6, 'text-anchor': n === 1 ? 'middle' : k === 0 ? 'start' : k === xt.length - 1 ? 'end' : 'middle' },
972
+ `${k === 0 && n > 1 ? 'reply ' : ''}${int(replyNo(i))}`)))
973
+ svg.append(axis)
974
+ const base = y(0)
975
+ const area = (f) => n === 1 ? '' : `M${x(0)},${base}` + pts.map((p, i) => `L${x(i).toFixed(1)},${y(f(p)).toFixed(1)}`).join('') + `L${x(n - 1)},${base}Z`
976
+ const line = (f) => pts.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(f(p)).toFixed(1)}`).join('')
977
+ if (n > 1) {
978
+ svg.append(sv('path', { d: area((p) => p.ctx), fill: 'var(--other)', 'fill-opacity': 0.3 }))
979
+ svg.append(sv('path', { d: area((p) => p.read), fill: 'var(--read)', 'fill-opacity': 0.75 }))
980
+ svg.append(sv('path', { d: line((p) => p.ctx), fill: 'none', stroke: 'var(--text-2)', 'stroke-width': 1.5, 'stroke-linejoin': 'round' }))
981
+ } else {
982
+ const bw = 18
983
+ svg.append(sv('rect', { x: x(0) - bw / 2, y: y(pts[0].ctx), width: bw, height: base - y(pts[0].ctx), fill: 'var(--other)', 'fill-opacity': 0.3 }))
984
+ svg.append(sv('rect', { x: x(0) - bw / 2, y: y(pts[0].read), width: bw, height: base - y(pts[0].read), fill: 'var(--read)', 'fill-opacity': 0.75 }))
985
+ }
986
+ if (guide) {
987
+ svg.append(sv('line', { x1: m.l, x2: W - m.r, y1: y(150e3), y2: y(150e3), stroke: 'var(--muted)', 'stroke-dasharray': '4 4', 'stroke-width': 1 }))
988
+ svg.append(sv('text', { x: m.l + 4, y: y(150e3) - 5, fill: 'var(--muted)', 'font-size': 11 }, '150k'))
989
+ }
990
+ const marks = sv('g')
991
+ pts.forEach((p, i) => {
992
+ if (p.flag === 4) {
993
+ marks.append(sv('line', { x1: x(i), x2: x(i), y1: y(p.ctx), y2: base, stroke: 'var(--shrink)', 'stroke-dasharray': '2 3', 'stroke-width': 1 }))
994
+ marks.append(mark('shrink', x(i), y(p.ctx)))
995
+ } else if (p.flag >= 1 && p.flag <= 3) marks.append(mark(REASONS[p.flag - 1], x(i), y(p.ctx)))
996
+ })
997
+ const cross = sv('line', { y1: m.t, y2: base, stroke: 'var(--muted)', 'stroke-width': 1, style: 'display:none' })
998
+ const dot = sv('circle', { r: 3.5, fill: 'var(--text)', stroke: 'var(--surface)', 'stroke-width': 2, style: 'display:none' })
999
+ svg.append(cross, marks, dot)
1000
+ const hit = sv('rect', { x: m.l - 6, y: m.t, width: iw + 12, height: ih, fill: 'transparent' })
1001
+ hit.addEventListener('pointermove', (e) => {
1002
+ const rect = svg.getBoundingClientRect(), px = ((e.clientX - rect.left) * W) / rect.width
1003
+ const i = n === 1 ? 0 : Math.max(0, Math.min(n - 1, Math.round(((px - m.l) / iw) * (n - 1))))
1004
+ if (i !== active) show(i)
1005
+ })
1006
+ hit.addEventListener('pointerleave', () => { if (document.activeElement !== box) hide() })
1007
+ svg.append(hit)
1008
+ box.replaceChildren(svg, tip)
1009
+ geo = { x, y, cross, dot }
1010
+ if (active >= 0) show(active)
1011
+ }
1012
+ box.addEventListener('keydown', (e) => {
1013
+ const step = e.shiftKey ? Math.max(1, Math.round(n / 20)) : 1
1014
+ let i = active
1015
+ if (e.key === 'ArrowRight') i = i < 0 ? 0 : Math.min(n - 1, i + step)
1016
+ else if (e.key === 'ArrowLeft') i = i < 0 ? n - 1 : Math.max(0, i - step)
1017
+ else if (e.key === 'Home') i = 0
1018
+ else if (e.key === 'End') i = n - 1
1019
+ else if (e.key === 'Escape') { hide(); return }
1020
+ else return
1021
+ e.preventDefault(); e.stopPropagation(); show(i)
1022
+ })
1023
+ box.addEventListener('focus', () => { if (active < 0) show(Math.max(0, pts.findIndex((p) => p.flag > 0))) })
1024
+ box.addEventListener('blur', hide)
1025
+ onWidth(box, draw)
1026
+ }
1027
+
1028
+ // ---------- key findings ----------
1029
+ function renderFindings() {
1030
+ const box = $('findings')
1031
+ const list = (Array.isArray(D.findings) ? D.findings : []).filter((f) => f && S(f.title))
1032
+ if (!list.length) { box.replaceChildren(el('p', { class: 'empty', text: 'Nothing stands out: your sessions already reuse the cache well.' })); return }
1033
+ box.replaceChildren(...list.map((f) => el('article', { class: 'finding' },
1034
+ el('div', { class: 'v', text: S(f.value) }),
1035
+ el('h3', { text: S(f.title) }),
1036
+ el('p', { text: S(f.text) }),
1037
+ S(f.action) ? el('p', { class: 'act', text: S(f.action) }) : '')))
1038
+ }
1039
+
1040
+ // ---------- share ----------
1041
+ function shareText() { return D.share || `${Math.round(shares.reread)}% of my ${D.product} usage over ${D.span} was re-reading context. Check yours: npx reread-cli` }
1042
+ function wrapLines(ctx, text, width) {
1043
+ const words = text.split(/\s+/), lines = []
1044
+ let line = ''
1045
+ for (const w of words) { const t = line ? line + ' ' + w : w; if (ctx.measureText(t).width > width && line) { lines.push(line); line = w } else line = t }
1046
+ if (line) lines.push(line)
1047
+ return lines
1048
+ }
1049
+ function drawCard() {
1050
+ const c = $('card'), ctx = c.getContext('2d')
1051
+ if (!ctx) return c
1052
+ const W = 1200, H = 630, P = 80, FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
1053
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
1054
+ ctx.fillStyle = '#111110'; ctx.fillRect(0, 0, W, H)
1055
+ // brand
1056
+ ctx.strokeStyle = '#3987e5'; ctx.lineWidth = 4; ctx.lineCap = 'round'
1057
+ ctx.beginPath(); ctx.arc(P + 14, 86, 13, -Math.PI * 0.25, Math.PI * 1.6); ctx.stroke()
1058
+ ctx.fillStyle = '#f0efec'; ctx.font = `600 28px ${FONT}`; ctx.textBaseline = 'middle'
1059
+ ctx.fillText('ReRead', P + 42, 87)
1060
+ ctx.fillStyle = '#a5a49b'; ctx.font = `400 24px ${FONT}`; ctx.textAlign = 'right'
1061
+ ctx.fillText(`${D.product} usage, from token counts`, W - P, 87)
1062
+ ctx.textAlign = 'left'
1063
+ // number and sentence
1064
+ const big = `${Math.round(shares.reread)}%`
1065
+ ctx.fillStyle = '#f0efec'; ctx.textBaseline = 'alphabetic'; ctx.font = `700 190px ${FONT}`
1066
+ ctx.fillText(big, P - 6, 320)
1067
+ const bigW = ctx.measureText(big).width
1068
+ ctx.font = `500 40px ${FONT}`; ctx.fillStyle = '#d4d3cb'
1069
+ const sx = P + bigW + 36, room = W - P - sx
1070
+ const sentence = `of my ${D.product} usage was re-reading context`
1071
+ if (room > 330) {
1072
+ const lines = wrapLines(ctx, sentence, room).slice(0, 3)
1073
+ lines.forEach((l, i) => ctx.fillText(l, sx, 320 - (lines.length - 1 - i) * 50))
1074
+ } else {
1075
+ wrapLines(ctx, sentence, W - 2 * P).slice(0, 2).forEach((l, i) => ctx.fillText(l, P, 380 + i * 50))
1076
+ }
1077
+ // split bar
1078
+ const by = 400, bh = 22, parts = [['#3987e5', shares.read], ['#e0652f', shares.rebuild], ['#6b6a64', shares.other]]
1079
+ const sum = parts.reduce((a, [, v]) => a + Math.max(0, v), 0) || 1
1080
+ let bx = P
1081
+ const bwid = W - 2 * P, vis = parts.filter(([, v]) => v > 0)
1082
+ vis.forEach(([col, v], i) => {
1083
+ const w = (Math.max(0, v) / sum) * (bwid - 4 * (vis.length - 1))
1084
+ ctx.fillStyle = col; ctx.fillRect(bx, by, w, bh); bx += w + 4
1085
+ })
1086
+ ctx.font = `400 22px ${FONT}`; ctx.textBaseline = 'top'
1087
+ let lx = P
1088
+ for (const [col, v, label] of [['#3987e5', shares.read, 'cache reads'], ['#e0652f', shares.rebuild, 'written again'], ['#6b6a64', shares.other, 'everything else']]) {
1089
+ ctx.fillStyle = col; ctx.fillRect(lx, by + bh + 22, 14, 14)
1090
+ ctx.fillStyle = '#d4d3cb'; const t = `${pct(v)} ${label}`; ctx.fillText(t, lx + 22, by + bh + 18)
1091
+ lx += 22 + ctx.measureText(t).width + 36
1092
+ }
1093
+ // footer
1094
+ ctx.fillStyle = '#a5a49b'; ctx.font = `400 26px ${FONT}`; ctx.textBaseline = 'alphabetic'
1095
+ ctx.fillText(`${D.span} · ${plural(N(sm.sessions), 'session', 'sessions')}`, P, H - 72)
1096
+ ctx.font = `600 28px ${MONO}`
1097
+ const cmd = 'npx reread-cli', cw = ctx.measureText(cmd).width
1098
+ ctx.strokeStyle = '#44443f'; ctx.lineWidth = 2
1099
+ ctx.strokeRect(W - P - cw - 36, H - 72 - 40, cw + 36, 56)
1100
+ ctx.fillStyle = '#f0efec'; ctx.fillText(cmd, W - P - cw - 18, H - 72)
1101
+ return c
1102
+ }
1103
+ // Inside the VS Code extension the page cannot download; it hands the file to the editor, which asks where to save it.
1104
+ const editor = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null
1105
+ function download(name, blob) {
1106
+ if (editor) {
1107
+ const r = new FileReader()
1108
+ r.onload = () => editor.postMessage({ type: 'save', name, data: r.result })
1109
+ r.readAsDataURL(blob)
1110
+ return
1111
+ }
1112
+ const url = URL.createObjectURL(blob)
1113
+ const a = el('a', { href: url, download: name, style: 'display:none' })
1114
+ document.body.append(a); a.click(); a.remove()
1115
+ setTimeout(() => URL.revokeObjectURL(url), 2000)
1116
+ }
1117
+ const stamp = () => { const d = new Date(D.generatedAt); const t = isNaN(d) ? new Date() : d; return `${t.getFullYear()}-${pad2(t.getMonth() + 1)}-${pad2(t.getDate())}` }
1118
+ function say(msg) { const s = $('share-status'); s.textContent = msg; clearTimeout(say.t); say.t = setTimeout(() => { s.textContent = '' }, 2500) }
1119
+ async function copy(text) {
1120
+ try {
1121
+ if (!navigator.clipboard) throw new Error('no clipboard')
1122
+ await Promise.race([navigator.clipboard.writeText(text), new Promise((_, no) => setTimeout(() => no(new Error('timeout')), 1500))])
1123
+ return true
1124
+ } catch {
1125
+ const ta = el('textarea', { style: 'position:fixed;opacity:0;top:0;left:0' }); ta.value = text
1126
+ document.body.append(ta); ta.select()
1127
+ let ok = false; try { ok = document.execCommand('copy') } catch { ok = false }
1128
+ ta.remove(); return ok
1129
+ }
1130
+ }
1131
+ function renderShare() {
1132
+ $('share-text').textContent = shareText()
1133
+ drawCard()
1134
+ $('copy-text').addEventListener('click', async () => say((await copy(shareText())) ? 'Copied.' : 'Could not copy. Select the text above instead.'))
1135
+ $('download-image').addEventListener('click', () => {
1136
+ const c = drawCard()
1137
+ if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); if (!editor) say('Image saved.') } }, 'image/png')
1138
+ })
1139
+ }
1140
+
1141
+ // ---------- toolbar ----------
1142
+ function csvCell(v) {
1143
+ let t = S(v)
1144
+ if (/^[=+\-@\t\r]/.test(t)) t = "'" + t // keep spreadsheet apps from running a folder name as a formula
1145
+ return /[",\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t
1146
+ }
1147
+ function renderToolbar() {
1148
+ const hideBtn = $('hide-names')
1149
+ if (D.anon) {
1150
+ hideBtn.disabled = true; hideBtn.setAttribute('aria-pressed', 'true')
1151
+ hideBtn.title = 'Names were already hidden when this report was made'
1152
+ } else hideBtn.setAttribute('aria-pressed', String(state.hide))
1153
+ hideBtn.addEventListener('click', () => {
1154
+ state.hide = !state.hide
1155
+ hideBtn.setAttribute('aria-pressed', String(state.hide))
1156
+ store.set('reread.hideNames', state.hide ? '1' : '0')
1157
+ renderProjects(); renderSessions()
1158
+ })
1159
+ // With names hidden, the exports hide them too: they are usually made to be shared.
1160
+ const outName = (p) => (state.hide ? pname(p) : S(p)), outBranch = (b) => (state.hide && S(b) ? bname(b) : S(b))
1161
+ $('export-json').addEventListener('click', () => {
1162
+ const out = !state.hide ? raw : { ...raw, anon: true,
1163
+ projects: (raw.projects || []).map((p) => ({ ...p, project: outName(p.project) })),
1164
+ sessions: (raw.sessions || []).map((x) => ({ ...x, project: outName(x.project), branch: outBranch(x.branch) })) }
1165
+ download(`reread-${stamp()}.json`, new Blob([JSON.stringify(out)], { type: 'application/json' }))
1166
+ })
1167
+ $('export-csv').addEventListener('click', () => {
1168
+ const head = ['session', 'tool', 'project', 'branch', 'started', 'ended', 'replies', 'peak_context', 'api_usd', 'reread_pct', 'rebuilds_cold', 'rebuilds_model', 'rebuilds_prefix'] // the same as reread --csv
1169
+ const lines = [head.join(',')].concat(D.sessions.map((s) => { const r = rebuildsOf(s); return [s.id, S(s.tool) || 'claude', outName(s.project), outBranch(s.branch), s.started, s.ended, N(s.calls), N(s.peak), N(s.usd), N(s.rereadShare), r.cold, r.model, r.prefix].map(csvCell).join(',') }))
1170
+ download(`reread-sessions-${stamp()}.csv`, new Blob([lines.join('\r\n') + '\r\n'], { type: 'text/csv' }))
1171
+ })
1172
+ const filter = $('filter')
1173
+ filter.addEventListener('input', () => { state.filter = filter.value; renderSessions() })
1174
+ let w = 0
1175
+ window.addEventListener('resize', () => { if (window.innerWidth !== w) { w = window.innerWidth; fitDrill() } })
1176
+ }
1177
+
1178
+ renderHeader()
1179
+ renderToolbar()
1180
+ renderDaily()
1181
+ renderBuckets()
1182
+ renderWhy()
1183
+ renderProjects()
1184
+ renderModels()
1185
+ renderSessions()
1186
+ renderFindings()
1187
+ renderShare()
1188
+ if (editor) editor.postMessage({ type: 'ready' })
1189
+ })()
1190
+ </script>
1191
+ </body>
1192
+ </html>