reread-cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -2
- package/bin/reread.js +5 -0
- package/package.json +59 -4
- package/src/analyze.js +425 -0
- package/src/antigravity.js +190 -0
- package/src/args.js +155 -0
- package/src/claude.js +121 -0
- package/src/cli.js +127 -0
- package/src/codex.js +135 -0
- package/src/files.js +102 -0
- package/src/findings.js +122 -0
- package/src/format.js +23 -0
- package/src/meta.js +10 -0
- package/src/prices.js +172 -0
- package/src/render.js +213 -0
- package/src/report.html +1183 -0
- package/src/report.js +69 -0
- package/src/statusline.js +165 -0
- package/src/tools.js +127 -0
package/src/report.html
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
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‑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
|
+
function download(name, blob) {
|
|
1104
|
+
const url = URL.createObjectURL(blob)
|
|
1105
|
+
const a = el('a', { href: url, download: name, style: 'display:none' })
|
|
1106
|
+
document.body.append(a); a.click(); a.remove()
|
|
1107
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000)
|
|
1108
|
+
}
|
|
1109
|
+
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())}` }
|
|
1110
|
+
function say(msg) { const s = $('share-status'); s.textContent = msg; clearTimeout(say.t); say.t = setTimeout(() => { s.textContent = '' }, 2500) }
|
|
1111
|
+
async function copy(text) {
|
|
1112
|
+
try {
|
|
1113
|
+
if (!navigator.clipboard) throw new Error('no clipboard')
|
|
1114
|
+
await Promise.race([navigator.clipboard.writeText(text), new Promise((_, no) => setTimeout(() => no(new Error('timeout')), 1500))])
|
|
1115
|
+
return true
|
|
1116
|
+
} catch {
|
|
1117
|
+
const ta = el('textarea', { style: 'position:fixed;opacity:0;top:0;left:0' }); ta.value = text
|
|
1118
|
+
document.body.append(ta); ta.select()
|
|
1119
|
+
let ok = false; try { ok = document.execCommand('copy') } catch { ok = false }
|
|
1120
|
+
ta.remove(); return ok
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
function renderShare() {
|
|
1124
|
+
$('share-text').textContent = shareText()
|
|
1125
|
+
drawCard()
|
|
1126
|
+
$('copy-text').addEventListener('click', async () => say((await copy(shareText())) ? 'Copied.' : 'Could not copy. Select the text above instead.'))
|
|
1127
|
+
$('download-image').addEventListener('click', () => {
|
|
1128
|
+
const c = drawCard()
|
|
1129
|
+
if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); say('Image saved.') } }, 'image/png')
|
|
1130
|
+
})
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ---------- toolbar ----------
|
|
1134
|
+
function csvCell(v) {
|
|
1135
|
+
let t = S(v)
|
|
1136
|
+
if (/^[=+\-@\t\r]/.test(t)) t = "'" + t // keep spreadsheet apps from running a folder name as a formula
|
|
1137
|
+
return /[",\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t
|
|
1138
|
+
}
|
|
1139
|
+
function renderToolbar() {
|
|
1140
|
+
const hideBtn = $('hide-names')
|
|
1141
|
+
if (D.anon) {
|
|
1142
|
+
hideBtn.disabled = true; hideBtn.setAttribute('aria-pressed', 'true')
|
|
1143
|
+
hideBtn.title = 'Names were already hidden when this report was made'
|
|
1144
|
+
} else hideBtn.setAttribute('aria-pressed', String(state.hide))
|
|
1145
|
+
hideBtn.addEventListener('click', () => {
|
|
1146
|
+
state.hide = !state.hide
|
|
1147
|
+
hideBtn.setAttribute('aria-pressed', String(state.hide))
|
|
1148
|
+
store.set('reread.hideNames', state.hide ? '1' : '0')
|
|
1149
|
+
renderProjects(); renderSessions()
|
|
1150
|
+
})
|
|
1151
|
+
// With names hidden, the exports hide them too: they are usually made to be shared.
|
|
1152
|
+
const outName = (p) => (state.hide ? pname(p) : S(p)), outBranch = (b) => (state.hide && S(b) ? bname(b) : S(b))
|
|
1153
|
+
$('export-json').addEventListener('click', () => {
|
|
1154
|
+
const out = !state.hide ? raw : { ...raw, anon: true,
|
|
1155
|
+
projects: (raw.projects || []).map((p) => ({ ...p, project: outName(p.project) })),
|
|
1156
|
+
sessions: (raw.sessions || []).map((x) => ({ ...x, project: outName(x.project), branch: outBranch(x.branch) })) }
|
|
1157
|
+
download(`reread-${stamp()}.json`, new Blob([JSON.stringify(out)], { type: 'application/json' }))
|
|
1158
|
+
})
|
|
1159
|
+
$('export-csv').addEventListener('click', () => {
|
|
1160
|
+
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
|
|
1161
|
+
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(',') }))
|
|
1162
|
+
download(`reread-sessions-${stamp()}.csv`, new Blob([lines.join('\r\n') + '\r\n'], { type: 'text/csv' }))
|
|
1163
|
+
})
|
|
1164
|
+
const filter = $('filter')
|
|
1165
|
+
filter.addEventListener('input', () => { state.filter = filter.value; renderSessions() })
|
|
1166
|
+
let w = 0
|
|
1167
|
+
window.addEventListener('resize', () => { if (window.innerWidth !== w) { w = window.innerWidth; fitDrill() } })
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
renderHeader()
|
|
1171
|
+
renderToolbar()
|
|
1172
|
+
renderDaily()
|
|
1173
|
+
renderBuckets()
|
|
1174
|
+
renderWhy()
|
|
1175
|
+
renderProjects()
|
|
1176
|
+
renderModels()
|
|
1177
|
+
renderSessions()
|
|
1178
|
+
renderFindings()
|
|
1179
|
+
renderShare()
|
|
1180
|
+
})()
|
|
1181
|
+
</script>
|
|
1182
|
+
</body>
|
|
1183
|
+
</html>
|