reread-cli 0.2.0 → 0.3.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/README.md +43 -6
- package/bin/reread.js +1 -1
- package/package.json +4 -2
- package/src/analyze.js +97 -8
- package/src/args.js +3 -3
- package/src/claude.js +104 -10
- package/src/cli.js +6 -1
- package/src/codex.js +10 -5
- package/src/files.js +14 -0
- package/src/findings.js +30 -0
- package/src/prices.js +12 -0
- package/src/render.js +37 -2
- package/src/report.html +39 -4
- package/src/statusline.js +44 -16
- package/src/tools.js +15 -4
package/README.md
CHANGED
|
@@ -21,13 +21,14 @@ Code, Codex and Antigravity already keep. Nothing is uploaded, and it has no dep
|
|
|
21
21
|
Reading history from the cache 33.6% 95.2% of all tokens read
|
|
22
22
|
Writing history again 18.7% after the cache was lost
|
|
23
23
|
Everything else 47.7% new input, first cache writes, output
|
|
24
|
+
Of which thinking 5.9% 27% of output tokens
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Every reply in a session sends the whole conversation to the model again. Prompt caching makes those re-reads
|
|
27
28
|
cheaper, but in a long session they still add up, and when the cache is lost the whole history is written again at
|
|
28
29
|
full price. ReRead shows you the split, when and why the cache was lost, and which sessions cost the most.
|
|
29
30
|
|
|
30
|
-
Every tool with sessions on your machine is read; `--tool claude`, `--tool codex` or `--tool antigravity` picks one. Needs Node.js 20 or newer (22.15 or newer also reads Codex's
|
|
31
|
+
Every tool with sessions on your machine is read; `--tool claude`, `--tool codex` or `--tool antigravity` picks one. Needs Node.js 20 or newer (22.15 or newer also reads Codex's compressed sessions; on an older Node.js, ReRead says how many it had to skip).
|
|
31
32
|
|
|
32
33
|
## The report
|
|
33
34
|
|
|
@@ -35,13 +36,14 @@ Every tool with sessions on your machine is read; `--tool claude`, `--tool codex
|
|
|
35
36
|
|
|
36
37
|
- **Key findings** first: a few cards, the costliest first, each with one number, what it means and what to do
|
|
37
38
|
(how much of your usage came from long contexts, how concentrated it is in a few sessions, what losing the cache
|
|
38
|
-
cost and why, how large your context grew before each compaction).
|
|
39
|
+
cost and why, how large your context grew before each compaction, how much of your usage was thinking).
|
|
39
40
|
- **The split**: re-reading from the cache, history written again, and everything else.
|
|
40
41
|
- **By day**: what each day cost and how much of it was re-reading.
|
|
41
42
|
- **By context size**: how much of your usage came from replies with a large context (over 150k is where it adds up).
|
|
42
43
|
- **Why the cache was lost**: a break longer than its life (5 minutes or 1 hour), a model switch mid-session, or a
|
|
43
44
|
change near the start of the context (tools, MCP servers, settings).
|
|
44
|
-
- **By project and by model**, and by tool when you use
|
|
45
|
+
- **By project and by model**, and by tool when you use more than one.
|
|
46
|
+
- **By effort level**: the cost of each tool's effort levels, and how much of their output was thinking.
|
|
45
47
|
- **Sessions**: the 10 costliest, sortable and searchable across all of them, with their git branch. Open one to see
|
|
46
48
|
its context grow reply by reply, with each rebuild and compaction marked.
|
|
47
49
|
- **Share**: copy a line of text, or save an image card with no project names on it. Hide names in the whole report
|
|
@@ -76,6 +78,14 @@ Then add it to `~/.claude/settings.json`:
|
|
|
76
78
|
|
|
77
79
|
More in [docs/statusline.md](docs/statusline.md).
|
|
78
80
|
|
|
81
|
+
## In VS Code
|
|
82
|
+
|
|
83
|
+
The [ReRead extension](vscode/README.md) shows the same countdown in the editor's status bar, for the latest Claude Code
|
|
84
|
+
session in your workspace, whether Claude Code runs in the terminal or in the editor. Click it for the report of that
|
|
85
|
+
workspace, in an editor tab. Install it from the
|
|
86
|
+
[VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=hamperpick.reread), or from
|
|
87
|
+
[Open VSX](https://open-vsx.org/extension/hamperpick/reread) for Cursor, Antigravity and other editors that use it.
|
|
88
|
+
|
|
79
89
|
## Options
|
|
80
90
|
|
|
81
91
|
```
|
|
@@ -95,7 +105,7 @@ Output
|
|
|
95
105
|
--json all the numbers as JSON
|
|
96
106
|
--csv every session as CSV
|
|
97
107
|
--md a Markdown summary, to paste into an issue or a pull request
|
|
98
|
-
--by <what> add a breakdown to the summary: project, model, tool or day
|
|
108
|
+
--by <what> add a breakdown to the summary: project, model, tool, effort or day
|
|
99
109
|
--top <n> sessions in the summary (default 10)
|
|
100
110
|
--anon replace project names with "project 1", "project 2", ... and leave branches out
|
|
101
111
|
```
|
|
@@ -104,6 +114,7 @@ Examples:
|
|
|
104
114
|
|
|
105
115
|
```
|
|
106
116
|
npx reread-cli --days 7 --by model # this week, by model
|
|
117
|
+
npx reread-cli --by effort # cost and thinking share by effort level
|
|
107
118
|
npx reread-cli --tool codex # Codex sessions only
|
|
108
119
|
npx reread-cli --project api --text # one project, terminal only
|
|
109
120
|
npx reread-cli --json | jq .summary # the numbers, for your own scripts
|
|
@@ -132,8 +143,13 @@ A reply counts once, in the session that made it, even when a resumed session co
|
|
|
132
143
|
toward the session that started it. "Writing history again" is estimated from token counts: a reply whose cache read
|
|
133
144
|
fell below half of the previous reply's context while the context did not shrink.
|
|
134
145
|
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
Thinking is the part of the output the model spent reasoning, billed as output: Claude Code records it as
|
|
147
|
+
`thinking_tokens` and Codex as `reasoning_output_tokens`, next to the effort level each reply ran at. ReRead shows its
|
|
148
|
+
share of output and of usage, and `--by effort` breaks the cost down by tool and effort level. Antigravity's
|
|
149
|
+
transcripts record neither, so its replies count no thinking.
|
|
150
|
+
|
|
151
|
+
The details, with every rule and threshold: [docs/how-it-counts.md](docs/how-it-counts.md). None of the tools documents
|
|
152
|
+
its session format, and any may change it; if ReRead reads yours wrong, please
|
|
137
153
|
[open an issue](https://github.com/tecyfyai/reread/issues).
|
|
138
154
|
|
|
139
155
|
Want to see what caching saves for a prompt of your own? The
|
|
@@ -173,6 +189,20 @@ it, so ReRead prices its replies at the model in Antigravity's settings (Gemini
|
|
|
173
189
|
stay right whatever the model. Gemini CLI was retired for individual users in June 2026 in favour of Antigravity CLI,
|
|
174
190
|
so ReRead does not read it.
|
|
175
191
|
|
|
192
|
+
### How much of my Claude Code usage is thinking?
|
|
193
|
+
|
|
194
|
+
ReRead shows it directly. Claude Code records the thinking tokens of each reply and the effort level it ran at, and
|
|
195
|
+
Codex records its reasoning tokens the same way. ReRead adds them up as a share of your output and of your usage, and
|
|
196
|
+
`--by effort` shows the cost and thinking share of each effort level. Thinking is billed as output, so on a high
|
|
197
|
+
effort level it can be a large part of each reply's price; a lower level (`claude --effort low`, or
|
|
198
|
+
`model_reasoning_effort` in Codex's `config.toml`) suits routine tasks.
|
|
199
|
+
|
|
200
|
+
### What about DeepSeek, Qwen or other models?
|
|
201
|
+
|
|
202
|
+
When a coding agent ReRead reads runs a model it has no price for, such as a DeepSeek or Qwen model run through Claude
|
|
203
|
+
Code, ReRead counts those replies and their tokens, leaves them out of every cost and share rather than guess a price,
|
|
204
|
+
and names the models in the summary and the report.
|
|
205
|
+
|
|
176
206
|
### How is ReRead different from ccusage?
|
|
177
207
|
|
|
178
208
|
[ccusage](https://github.com/ccusage/ccusage) reports how many tokens you used and what they cost, by day, month or
|
|
@@ -189,6 +219,13 @@ their own way, so the dollar figures are what the same tokens would cost on the
|
|
|
189
219
|
No. It reads only token counts from local files, makes no network requests and has no dependencies. See
|
|
190
220
|
[Private by design](#private-by-design).
|
|
191
221
|
|
|
222
|
+
## Related
|
|
223
|
+
|
|
224
|
+
[Agent Chime](https://marketplace.visualstudio.com/items?itemName=hamperpick.agent-chime) (also on
|
|
225
|
+
[Open VSX](https://open-vsx.org/extension/hamperpick/agent-chime)) plays a sound in VS Code or Cursor when Claude Code,
|
|
226
|
+
Codex, Cursor's agent or Antigravity CLI finishes or needs your approval, so you can return before the prompt cache goes
|
|
227
|
+
cold.
|
|
228
|
+
|
|
192
229
|
## Contributing
|
|
193
230
|
|
|
194
231
|
Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
package/bin/reread.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reread-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Token usage and prompt cache analyzer for Claude Code, Codex and Antigravity: how much of your coding agent's usage was re-reading context, and why. Visual report and a cache-countdown status line. Local only; uploads nothing.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Token usage and prompt cache analyzer for Claude Code, Codex and Antigravity: how much of your coding agent's usage was re-reading context or thinking, and why. Visual report, cost by effort level and a cache-countdown status line. Local only; uploads nothing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"reread": "bin/reread.js"
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"token-cost",
|
|
41
41
|
"prompt-caching",
|
|
42
42
|
"prompt-cache",
|
|
43
|
+
"thinking-tokens",
|
|
44
|
+
"reasoning-effort",
|
|
43
45
|
"context-window",
|
|
44
46
|
"llm",
|
|
45
47
|
"llm-cost",
|
package/src/analyze.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// Counting: what each reply cost, how much of it was re-reading history, and when the whole history had to be
|
|
3
3
|
// written again.
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { isInside } from './files.js'
|
|
5
6
|
import { findingsOf } from './findings.js'
|
|
6
7
|
import { dayOf, pct, round1, round2 } from './format.js'
|
|
7
|
-
import { contextOf, costOf, priceOf } from './prices.js'
|
|
8
|
+
import { contextOf, costOf, hasPrice, priceOf } from './prices.js'
|
|
8
9
|
import { DISPLAY_ORDER, toolOf, TOOLS } from './tools.js'
|
|
9
10
|
|
|
10
11
|
/** @typedef {import('./prices.js').Call} Call */
|
|
@@ -55,6 +56,11 @@ export const BUCKETS = /** @type {const} */ ([
|
|
|
55
56
|
* @property {number} tokensRead
|
|
56
57
|
* @property {number} peak largest context
|
|
57
58
|
* @property {number} longCost cost of replies with a context over LONG_CONTEXT
|
|
59
|
+
* @property {number} out output tokens
|
|
60
|
+
* @property {number} think of which thinking (reasoning) tokens, where the tool records them
|
|
61
|
+
* @property {number} thinkCost what the thinking tokens cost
|
|
62
|
+
* @property {number} unpricedCalls replies from models with no known price, left out of every cost
|
|
63
|
+
* @property {number} unpricedTokens their tokens, sent and output
|
|
58
64
|
* @property {Record<Reason, { n: number, cost: number }>} rebuilds
|
|
59
65
|
* @property {{ n: number, ctx: number }} compact compactions, and the sum of the contexts they replaced
|
|
60
66
|
* @property {number} [first]
|
|
@@ -71,6 +77,11 @@ export const emptyTotals = () => ({
|
|
|
71
77
|
tokensRead: 0,
|
|
72
78
|
peak: 0,
|
|
73
79
|
longCost: 0,
|
|
80
|
+
out: 0,
|
|
81
|
+
think: 0,
|
|
82
|
+
thinkCost: 0,
|
|
83
|
+
unpricedCalls: 0,
|
|
84
|
+
unpricedTokens: 0,
|
|
74
85
|
rebuilds: { cold: { n: 0, cost: 0 }, model: { n: 0, cost: 0 }, prefix: { n: 0, cost: 0 } },
|
|
75
86
|
compact: { n: 0, ctx: 0 },
|
|
76
87
|
})
|
|
@@ -90,6 +101,11 @@ export function addTotals(a, b) {
|
|
|
90
101
|
a.tokensIn += b.tokensIn
|
|
91
102
|
a.tokensRead += b.tokensRead
|
|
92
103
|
a.longCost += b.longCost
|
|
104
|
+
a.out += b.out
|
|
105
|
+
a.think += b.think
|
|
106
|
+
a.thinkCost += b.thinkCost
|
|
107
|
+
a.unpricedCalls += b.unpricedCalls
|
|
108
|
+
a.unpricedTokens += b.unpricedTokens
|
|
93
109
|
a.peak = Math.max(a.peak, b.peak)
|
|
94
110
|
for (const w of REASONS) {
|
|
95
111
|
a.rebuilds[w].n += b.rebuilds[w].n
|
|
@@ -108,6 +124,7 @@ export function addTotals(a, b) {
|
|
|
108
124
|
* @property {number} cost
|
|
109
125
|
* @property {number} readCost
|
|
110
126
|
* @property {number} rebuildCost
|
|
127
|
+
* @property {number} thinkCost
|
|
111
128
|
* @property {Reason | null} why set when the whole history was written again
|
|
112
129
|
* @property {number} flag FLAG value for the report
|
|
113
130
|
*/
|
|
@@ -137,9 +154,17 @@ export function walk(calls, onCall = () => {}) {
|
|
|
137
154
|
continue
|
|
138
155
|
}
|
|
139
156
|
const ctx = contextOf(c)
|
|
140
|
-
|
|
141
|
-
const
|
|
157
|
+
// A model with no known price costs nothing here, rather than a guessed price.
|
|
158
|
+
const priced = hasPrice(c.model)
|
|
159
|
+
const p = priced ? priceOf(c.model, ctx) : { input: 0, write5m: 0, write1h: 0, read: 0, output: 0 }
|
|
160
|
+
const cost = priced ? costOf(c) : 0
|
|
142
161
|
const readCost = (c.read * p.read) / 1e6
|
|
162
|
+
const think = c.think ?? 0
|
|
163
|
+
const thinkCost = (think * p.output) / 1e6
|
|
164
|
+
if (!priced) {
|
|
165
|
+
s.unpricedCalls++
|
|
166
|
+
s.unpricedTokens += ctx + c.out
|
|
167
|
+
}
|
|
143
168
|
/** @type {Reason | null} */
|
|
144
169
|
let why = null
|
|
145
170
|
let rebuildCost = 0
|
|
@@ -166,11 +191,22 @@ export function walk(calls, onCall = () => {}) {
|
|
|
166
191
|
s.rebuildCost += rebuildCost
|
|
167
192
|
s.tokensIn += ctx
|
|
168
193
|
s.tokensRead += c.read
|
|
194
|
+
s.out += c.out
|
|
195
|
+
s.think += think
|
|
196
|
+
s.thinkCost += thinkCost
|
|
169
197
|
s.peak = Math.max(s.peak, ctx)
|
|
170
198
|
if (ctx > LONG_CONTEXT) s.longCost += cost
|
|
171
199
|
s.first ??= c.ts
|
|
172
200
|
s.last = c.ts
|
|
173
|
-
onCall(c, {
|
|
201
|
+
onCall(c, {
|
|
202
|
+
ctx,
|
|
203
|
+
cost,
|
|
204
|
+
readCost,
|
|
205
|
+
rebuildCost,
|
|
206
|
+
thinkCost,
|
|
207
|
+
why,
|
|
208
|
+
flag: why ? FLAG[why] : shrank ? FLAG.shrink : FLAG.none,
|
|
209
|
+
})
|
|
174
210
|
if (c.cw1 > 0) ttl = 3600e3
|
|
175
211
|
else if (c.cw5 > 0) ttl = 300e3
|
|
176
212
|
prev = c
|
|
@@ -182,15 +218,17 @@ export function walk(calls, onCall = () => {}) {
|
|
|
182
218
|
* @typedef {Totals & { id: string, tool: Tool, project: string, branch: string, points: [number, number, number, number][] }} Session
|
|
183
219
|
* points: [time, context, cache read, FLAG] for each reply, for the report's chart
|
|
184
220
|
* @typedef {{ calls: number, usd: number, readUsd: number, rebuildUsd: number }} Money
|
|
221
|
+
* @typedef {{ tool: Tool, effort: string, calls: number, usd: number, out: number, think: number, thinkUsd: number }} EffortRow
|
|
185
222
|
*/
|
|
186
223
|
|
|
187
224
|
/**
|
|
188
225
|
* Read and count session files.
|
|
189
226
|
* @param {string[]} files in the order sessionFiles() gives
|
|
190
|
-
* @param {{ since: number, project?: string, onProgress?: (done: number, total: number) => void }} opts
|
|
227
|
+
* @param {{ since: number, project?: string, folders?: string[], onProgress?: (done: number, total: number) => void }} opts
|
|
228
|
+
* folders: only sessions that ran in one of these folders or a folder inside one.
|
|
191
229
|
* A file that cannot be read (removed meanwhile, no permission, a broken archive) is skipped and listed in `skipped`.
|
|
192
230
|
*/
|
|
193
|
-
export function analyze(files, { since, project = '', onProgress }) {
|
|
231
|
+
export function analyze(files, { since, project = '', folders = [], onProgress }) {
|
|
194
232
|
const needle = project.toLowerCase()
|
|
195
233
|
/** @type {Set<string>} */
|
|
196
234
|
const seen = new Set()
|
|
@@ -203,6 +241,8 @@ export function analyze(files, { since, project = '', onProgress }) {
|
|
|
203
241
|
const models = new Map()
|
|
204
242
|
/** @type {Map<string, Money>} */
|
|
205
243
|
const buckets = new Map()
|
|
244
|
+
/** @type {Map<string, EffortRow>} */
|
|
245
|
+
const efforts = new Map()
|
|
206
246
|
const zero = () => ({ calls: 0, usd: 0, readUsd: 0, rebuildUsd: 0 })
|
|
207
247
|
/**
|
|
208
248
|
* @param {Map<string, Money>} map
|
|
@@ -220,6 +260,8 @@ export function analyze(files, { since, project = '', onProgress }) {
|
|
|
220
260
|
|
|
221
261
|
/** @type {{ file: string, error: string }[]} */
|
|
222
262
|
const skipped = []
|
|
263
|
+
/** Compressed files this Node.js cannot read. */
|
|
264
|
+
const unread = /** @type {string[]} */ ([])
|
|
223
265
|
|
|
224
266
|
files.forEach((file, i) => {
|
|
225
267
|
onProgress?.(i + 1, files.length)
|
|
@@ -234,16 +276,30 @@ export function analyze(files, { since, project = '', onProgress }) {
|
|
|
234
276
|
return
|
|
235
277
|
}
|
|
236
278
|
const { id, sub, cwd, branch, calls } = read
|
|
279
|
+
if (read.unread) unread.push(file)
|
|
237
280
|
if (!calls.some((c) => !c.seen)) return
|
|
238
|
-
|
|
281
|
+
const info = /** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool])
|
|
282
|
+
if (!info.projectInPath && needle) {
|
|
239
283
|
if (!cwd.toLowerCase().includes(needle)) return
|
|
240
284
|
}
|
|
285
|
+
// A file with no folder in it (a subagent's) was already chosen by its path, where the path holds the project.
|
|
286
|
+
if (folders.length && !(cwd ? folders.some((f) => isInside(cwd, f)) : info.projectInPath)) return
|
|
241
287
|
const cur = sessions.get(id) ?? { id, tool, project: '', branch: '', points: [], ...emptyTotals() }
|
|
242
288
|
const s = walk(calls, (c, x) => {
|
|
243
289
|
tally(daily, dayOf(c.ts), x)
|
|
244
290
|
tally(models, c.model, x)
|
|
245
291
|
const label = /** @type {(typeof BUCKETS)[number]} */ (BUCKETS.find(([top]) => x.ctx < top))[1]
|
|
246
292
|
tally(buckets, label, x)
|
|
293
|
+
if (c.effort) {
|
|
294
|
+
const ek = `${tool}|${c.effort}`
|
|
295
|
+
const e = efforts.get(ek) ?? { tool, effort: c.effort, calls: 0, usd: 0, out: 0, think: 0, thinkUsd: 0 }
|
|
296
|
+
e.calls++
|
|
297
|
+
e.usd += x.cost
|
|
298
|
+
e.out += c.out
|
|
299
|
+
e.think += c.think ?? 0
|
|
300
|
+
e.thinkUsd += x.thinkCost
|
|
301
|
+
efforts.set(ek, e)
|
|
302
|
+
}
|
|
247
303
|
if (!sub) cur.points.push([c.ts, x.ctx, c.read, x.flag])
|
|
248
304
|
})
|
|
249
305
|
if (cwd && !cur.project) cur.project = path.basename(cwd)
|
|
@@ -252,7 +308,16 @@ export function analyze(files, { since, project = '', onProgress }) {
|
|
|
252
308
|
addTotals(total, s)
|
|
253
309
|
})
|
|
254
310
|
|
|
255
|
-
return {
|
|
311
|
+
return {
|
|
312
|
+
total,
|
|
313
|
+
sessions: [...sessions.values()].sort((a, b) => b.cost - a.cost),
|
|
314
|
+
daily,
|
|
315
|
+
models,
|
|
316
|
+
buckets,
|
|
317
|
+
efforts,
|
|
318
|
+
skipped,
|
|
319
|
+
unread,
|
|
320
|
+
}
|
|
256
321
|
}
|
|
257
322
|
|
|
258
323
|
/**
|
|
@@ -352,6 +417,18 @@ export function summarize(a, { span, anon, version, now = new Date() }) {
|
|
|
352
417
|
otherShare: round1(100 - share),
|
|
353
418
|
tokensReadFromCache: round1(pct(total.tokensRead, total.tokensIn)),
|
|
354
419
|
longContextShare: round1(pct(total.longCost, total.cost)),
|
|
420
|
+
unpriced: {
|
|
421
|
+
calls: total.unpricedCalls,
|
|
422
|
+
tokens: total.unpricedTokens,
|
|
423
|
+
models: [...a.models.keys()].filter((m) => !hasPrice(m)).map((m) => m || '(no model named)'),
|
|
424
|
+
},
|
|
425
|
+
thinking: {
|
|
426
|
+
tokens: total.think,
|
|
427
|
+
outputTokens: total.out,
|
|
428
|
+
shareOfOutput: round1(pct(total.think, total.out)),
|
|
429
|
+
usd: round2(total.thinkCost),
|
|
430
|
+
share: round1(pct(total.thinkCost, total.cost)),
|
|
431
|
+
},
|
|
355
432
|
apiEquivalentUSD: round2(total.cost),
|
|
356
433
|
rebuilds: /** @type {Record<Reason, { count: number, usd: number, share: number }>} */ (
|
|
357
434
|
Object.fromEntries(
|
|
@@ -379,11 +456,23 @@ export function summarize(a, { span, anon, version, now = new Date() }) {
|
|
|
379
456
|
.sort((x, y) => y[1].usd - x[1].usd)
|
|
380
457
|
.map(([model, v]) => ({
|
|
381
458
|
model,
|
|
459
|
+
priced: hasPrice(model),
|
|
382
460
|
calls: v.calls,
|
|
383
461
|
usd: round2(v.usd),
|
|
384
462
|
readUsd: round2(v.readUsd),
|
|
385
463
|
rebuildUsd: round2(v.rebuildUsd),
|
|
386
464
|
})),
|
|
465
|
+
efforts: [...a.efforts.values()]
|
|
466
|
+
.sort((x, y) => y.usd - x.usd)
|
|
467
|
+
.map((e) => ({
|
|
468
|
+
tool: e.tool,
|
|
469
|
+
name: TOOLS[e.tool].name,
|
|
470
|
+
effort: e.effort,
|
|
471
|
+
calls: e.calls,
|
|
472
|
+
usd: round2(e.usd),
|
|
473
|
+
thinkingShareOfOutput: round1(pct(e.think, e.out)),
|
|
474
|
+
thinkingUsd: round2(e.thinkUsd),
|
|
475
|
+
})),
|
|
387
476
|
projects: projects.map((p) => ({
|
|
388
477
|
project: nameOf(p.project),
|
|
389
478
|
sessions: p.sessions,
|
package/src/args.js
CHANGED
|
@@ -15,7 +15,7 @@ export class UsageError extends Error {}
|
|
|
15
15
|
* @property {string} dir the sessions folder given with --dir, or ''
|
|
16
16
|
* @property {'all' | import('./tools.js').Tool} tool whose sessions to read
|
|
17
17
|
* @property {'text' | 'json' | 'csv' | 'md'} format
|
|
18
|
-
* @property {'' | 'project' | 'model' | 'day' | 'tool'} by
|
|
18
|
+
* @property {'' | 'project' | 'model' | 'day' | 'tool' | 'effort'} by
|
|
19
19
|
* @property {number} top sessions in the summary
|
|
20
20
|
* @property {string | null} html null: decide by the terminal; '': a file in a private temp folder; else this path
|
|
21
21
|
* @property {boolean} text --text: the summary only
|
|
@@ -109,8 +109,8 @@ export function parseArgs(argv, now = Date.now()) {
|
|
|
109
109
|
if (!Number.isInteger(top) || top < 0) throw new UsageError('--top takes a whole number, such as --top 20')
|
|
110
110
|
|
|
111
111
|
const by = values.get('--by') ?? ''
|
|
112
|
-
if (by !== '' && by !== 'project' && by !== 'model' && by !== 'day' && by !== 'tool') {
|
|
113
|
-
throw new UsageError('--by takes project, model, tool or day')
|
|
112
|
+
if (by !== '' && by !== 'project' && by !== 'model' && by !== 'day' && by !== 'tool' && by !== 'effort') {
|
|
113
|
+
throw new UsageError('--by takes project, model, tool, effort or day')
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
const tool = /** @type {Options['tool']} */ (values.get('--tool') ?? 'all')
|
package/src/claude.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
// Reading Claude Code's session files: ~/.claude/projects/<folder>/<session-id>.jsonl, a subagent's in
|
|
3
3
|
// <session-id>/subagents/. Each line is one JSON event; ReRead keeps only the token counts, model, time and id of each
|
|
4
4
|
// reply, plus the folder and git branch of the session. It never keeps or prints what was written in the conversation.
|
|
5
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
|
|
5
6
|
import { homedir } from 'node:os'
|
|
6
7
|
import path from 'node:path'
|
|
7
|
-
import { eachLine } from './files.js'
|
|
8
|
+
import { eachLine, existingDirs, isInside, splitLines } from './files.js'
|
|
8
9
|
|
|
9
10
|
/** @typedef {import('./prices.js').Call} Call */
|
|
10
11
|
|
|
12
|
+
const USAGE = Buffer.from('"usage"')
|
|
13
|
+
const CWD = Buffer.from('"cwd"')
|
|
14
|
+
const BRANCH = Buffer.from('"gitBranch"')
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* The folders Claude Code may keep sessions in, most specific first.
|
|
13
18
|
* @param {NodeJS.ProcessEnv} [env]
|
|
@@ -22,8 +27,94 @@ export function claudeDirs(env = process.env, home = homedir()) {
|
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* The folder name Claude Code gives a project: its path, with every character that is not a letter or digit as '-'.
|
|
31
|
+
* Different paths can share a name ('a b' and 'a-b'), so a session's own `cwd` is what decides.
|
|
32
|
+
* @param {string} cwd
|
|
33
|
+
*/
|
|
34
|
+
export const claudeFolderName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, '-')
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The folder a session ran in, from the first line that names it (near the top of the file), or ''.
|
|
38
|
+
* @param {string} file
|
|
39
|
+
*/
|
|
40
|
+
export function cwdOf(file) {
|
|
41
|
+
let fd
|
|
42
|
+
try {
|
|
43
|
+
fd = openSync(file, 'r')
|
|
44
|
+
const buf = Buffer.alloc(256 * 1024)
|
|
45
|
+
const got = readSync(fd, buf, 0, buf.length, 0)
|
|
46
|
+
let cwd = ''
|
|
47
|
+
splitLines(buf.subarray(0, got), (line) => {
|
|
48
|
+
if (cwd || !line.includes(CWD)) return
|
|
49
|
+
try {
|
|
50
|
+
const v = JSON.parse(line.toString('utf8')).cwd
|
|
51
|
+
if (typeof v === 'string') cwd = v
|
|
52
|
+
} catch {
|
|
53
|
+
/* not JSON, or cut off at the end of the buffer */
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
return cwd
|
|
57
|
+
} catch {
|
|
58
|
+
return ''
|
|
59
|
+
} finally {
|
|
60
|
+
if (fd !== undefined) closeSync(fd)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The session file written to last, of the sessions that ran in one of these folders or a folder inside one. Subagent
|
|
66
|
+
* files are left out: the status of a session is its main file's.
|
|
67
|
+
* @param {string[]} folders
|
|
68
|
+
* @param {{ dirs?: string[], cwdCache?: Map<string, string> }} [opts] cwdCache: file to cwd, kept between calls
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function latestClaudeSession(folders, { dirs = claudeDirs(), cwdCache = new Map() } = {}) {
|
|
72
|
+
const root = existingDirs(dirs)[0]
|
|
73
|
+
if (!root || !folders.length) return null
|
|
74
|
+
const names = folders.map(claudeFolderName)
|
|
75
|
+
let best = null
|
|
76
|
+
let bestTime = -1
|
|
77
|
+
let entries
|
|
78
|
+
try {
|
|
79
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
for (const d of entries) {
|
|
84
|
+
if (!d.isDirectory() || !names.some((n) => d.name === n || d.name.startsWith(`${n}-`))) continue
|
|
85
|
+
const dir = path.join(root, d.name)
|
|
86
|
+
let files
|
|
87
|
+
try {
|
|
88
|
+
files = readdirSync(dir)
|
|
89
|
+
} catch {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
for (const name of files) {
|
|
93
|
+
if (!name.endsWith('.jsonl')) continue
|
|
94
|
+
const f = path.join(dir, name)
|
|
95
|
+
let t
|
|
96
|
+
try {
|
|
97
|
+
t = statSync(f).mtimeMs
|
|
98
|
+
} catch {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (t <= bestTime) continue
|
|
102
|
+
let cwd = cwdCache.get(f)
|
|
103
|
+
if (cwd === undefined) {
|
|
104
|
+
cwd = cwdOf(f)
|
|
105
|
+
if (cwd) cwdCache.set(f, cwd)
|
|
106
|
+
}
|
|
107
|
+
if (!cwd || !folders.some((folder) => isInside(cwd, folder))) continue
|
|
108
|
+
best = f
|
|
109
|
+
bestTime = t
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return best
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One line of a session file as a reply, or null when it is not one. Only token counts, the model, the effort
|
|
117
|
+
* level, the time and the ids are kept.
|
|
27
118
|
* @param {string} line
|
|
28
119
|
* @param {number} since replies before this time are left out
|
|
29
120
|
* @returns {Call | null}
|
|
@@ -45,6 +136,7 @@ export function callOf(line, since) {
|
|
|
45
136
|
// Some replies list 1-hour writes in the breakdown while the total says 0; the breakdown is kept.
|
|
46
137
|
const cw = n(u.cache_creation_input_tokens)
|
|
47
138
|
const cw1 = n(u.cache_creation?.ephemeral_1h_input_tokens)
|
|
139
|
+
const out = n(u.output_tokens)
|
|
48
140
|
return {
|
|
49
141
|
key: `${m.id}|${j.requestId ?? ''}`,
|
|
50
142
|
ts,
|
|
@@ -53,7 +145,9 @@ export function callOf(line, since) {
|
|
|
53
145
|
cw5: Math.max(0, cw - cw1),
|
|
54
146
|
cw1,
|
|
55
147
|
read: n(u.cache_read_input_tokens),
|
|
56
|
-
out
|
|
148
|
+
out,
|
|
149
|
+
think: n(u.output_tokens_details?.thinking_tokens),
|
|
150
|
+
effort: typeof j.effort === 'string' ? j.effort : '',
|
|
57
151
|
}
|
|
58
152
|
}
|
|
59
153
|
|
|
@@ -65,14 +159,12 @@ export function callOf(line, since) {
|
|
|
65
159
|
*/
|
|
66
160
|
export function collect(calls, call) {
|
|
67
161
|
const old = calls.get(call.key)
|
|
68
|
-
if (old)
|
|
69
|
-
|
|
162
|
+
if (old) {
|
|
163
|
+
old.out = Math.max(old.out, call.out)
|
|
164
|
+
old.think = Math.max(old.think ?? 0, call.think ?? 0)
|
|
165
|
+
} else calls.set(call.key, call)
|
|
70
166
|
}
|
|
71
167
|
|
|
72
|
-
const USAGE = Buffer.from('"usage"')
|
|
73
|
-
const CWD = Buffer.from('"cwd"')
|
|
74
|
-
const BRANCH = Buffer.from('"gitBranch"')
|
|
75
|
-
|
|
76
168
|
/**
|
|
77
169
|
* Read the replies of one session file. A resumed or forked session copies the replies it carries over into its new
|
|
78
170
|
* file: those are marked `seen` (walked for the cache, counted once) and every key is added to `seen`.
|
|
@@ -110,6 +202,8 @@ export function readClaudeFile(file, since, seen) {
|
|
|
110
202
|
}
|
|
111
203
|
eachLine(file, onLine)
|
|
112
204
|
for (const [key, c] of calls) {
|
|
205
|
+
// A reply written once per content block can list thinking from a later block than its output count.
|
|
206
|
+
c.think = Math.min(c.think ?? 0, c.out)
|
|
113
207
|
c.seen = seen.has(key)
|
|
114
208
|
seen.add(key)
|
|
115
209
|
}
|
package/src/cli.js
CHANGED
|
@@ -29,7 +29,7 @@ Output
|
|
|
29
29
|
--json all the numbers as JSON
|
|
30
30
|
--csv every session as CSV
|
|
31
31
|
--md a Markdown summary, to paste into an issue or a pull request
|
|
32
|
-
--by <what> add a breakdown to the summary: project, model, tool or day
|
|
32
|
+
--by <what> add a breakdown to the summary: project, model, tool, effort or day
|
|
33
33
|
--top <n> sessions in the summary (default 10)
|
|
34
34
|
--anon replace project names with "project 1", "project 2", ... and leave branches out
|
|
35
35
|
|
|
@@ -91,6 +91,11 @@ async function run(argv) {
|
|
|
91
91
|
onProgress: progress ? (n, total) => process.stderr.write(`\rReading ${n} of ${total} session files…`) : undefined,
|
|
92
92
|
})
|
|
93
93
|
if (progress) process.stderr.write('\r\x1b[K')
|
|
94
|
+
if (a.unread.length) {
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`reread: ${a.unread.length} compressed Codex session ${a.unread.length === 1 ? 'file was' : 'files were'} not read, so their usage is missing: reading them needs Node.js 22.15 or newer (this is ${process.version})\n`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
94
99
|
if (a.skipped.length) {
|
|
95
100
|
const [first] = a.skipped
|
|
96
101
|
const more = a.skipped.length > 1 ? ` and ${a.skipped.length - 1} more` : ''
|
package/src/codex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
// Reading Codex's session files ("rollouts"): ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, and archived ones in
|
|
3
3
|
// ~/.codex/archived_sessions (zstd-compressed, read where Node.js can decompress zstd). As with Claude Code, only token
|
|
4
|
-
// counts, the model, the time and ids are kept, plus the folder and git branch from the session's header.
|
|
4
|
+
// counts, the model, the effort level, the time and ids are kept, plus the folder and git branch from the session's header.
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
@@ -43,9 +43,10 @@ const n = (v) => (typeof v === 'number' && v > 0 ? v : 0)
|
|
|
43
43
|
* @param {string} key
|
|
44
44
|
* @param {number} ts
|
|
45
45
|
* @param {string} model
|
|
46
|
+
* @param {string} effort
|
|
46
47
|
* @returns {Call}
|
|
47
48
|
*/
|
|
48
|
-
function callFrom(u, key, ts, model) {
|
|
49
|
+
function callFrom(u, key, ts, model, effort) {
|
|
49
50
|
const read = n(u.cached_input_tokens)
|
|
50
51
|
const cw = n(u.cache_write_input_tokens)
|
|
51
52
|
return {
|
|
@@ -57,6 +58,8 @@ function callFrom(u, key, ts, model) {
|
|
|
57
58
|
cw1: 0,
|
|
58
59
|
read,
|
|
59
60
|
out: n(u.output_tokens),
|
|
61
|
+
think: Math.min(n(u.reasoning_output_tokens), n(u.output_tokens)),
|
|
62
|
+
effort,
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -75,6 +78,7 @@ export function readCodexFile(file, since, seen) {
|
|
|
75
78
|
let cwd = ''
|
|
76
79
|
let branch = ''
|
|
77
80
|
let model = ''
|
|
81
|
+
let effort = ''
|
|
78
82
|
/** @type {Map<string, Call>} */
|
|
79
83
|
const records = new Map()
|
|
80
84
|
/** @type {Map<string, Call>} */
|
|
@@ -100,12 +104,13 @@ export function readCodexFile(file, since, seen) {
|
|
|
100
104
|
}
|
|
101
105
|
if (j.type === 'turn_context') {
|
|
102
106
|
if (typeof p.model === 'string' && p.model) model = p.model
|
|
107
|
+
if (typeof p.effort === 'string') effort = p.effort
|
|
103
108
|
return
|
|
104
109
|
}
|
|
105
110
|
const ts = Date.parse(j.timestamp)
|
|
106
111
|
if (j.type === 'token_usage_record' && p.usage && typeof p.usage === 'object') {
|
|
107
112
|
const key = `codex|${p.response_id ?? `${id}|${j.ordinal}`}`
|
|
108
|
-
if (ts >= since && !records.has(key)) records.set(key, callFrom(p.usage, key, ts, model))
|
|
113
|
+
if (ts >= since && !records.has(key)) records.set(key, callFrom(p.usage, key, ts, model, effort))
|
|
109
114
|
return
|
|
110
115
|
}
|
|
111
116
|
if (j.type === 'event_msg' && p.type === 'token_count') {
|
|
@@ -114,12 +119,12 @@ export function readCodexFile(file, since, seen) {
|
|
|
114
119
|
if (typeof total !== 'number' || total === lastTotal || !last) return
|
|
115
120
|
lastTotal = total
|
|
116
121
|
const key = `codex|${id}|${total}`
|
|
117
|
-
if (ts >= since) counts.set(key, callFrom(last, key, ts, model))
|
|
122
|
+
if (ts >= since) counts.set(key, callFrom(last, key, ts, model, effort))
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
if (file.endsWith('.zst')) {
|
|
122
|
-
if (!canReadZstd) return { id, sub: false, cwd, branch, calls: [] }
|
|
127
|
+
if (!canReadZstd) return { id, sub: false, cwd, branch, calls: [], unread: true }
|
|
123
128
|
const data = /** @type {any} */ (zlib).zstdDecompressSync(readFileSync(file))
|
|
124
129
|
splitLines(data, onLine)
|
|
125
130
|
} else {
|
package/src/files.js
CHANGED
|
@@ -28,6 +28,20 @@ export function filesUnder(dir, keep) {
|
|
|
28
28
|
return out
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether a path is a folder or inside it. Paths are compared as written, ignoring case on macOS and Windows, whose
|
|
33
|
+
* disks usually do.
|
|
34
|
+
* @param {string} p
|
|
35
|
+
* @param {string} folder
|
|
36
|
+
*/
|
|
37
|
+
export function isInside(p, folder) {
|
|
38
|
+
const fold = process.platform === 'darwin' || process.platform === 'win32'
|
|
39
|
+
const a = path.resolve(p)
|
|
40
|
+
const b = path.resolve(folder)
|
|
41
|
+
const [x, y] = fold ? [a.toLowerCase(), b.toLowerCase()] : [a, b]
|
|
42
|
+
return x === y || x.startsWith(y.endsWith(path.sep) ? y : y + path.sep)
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
/** The folders that exist. */
|
|
32
46
|
export const existingDirs = (/** @type {string[]} */ dirs) => dirs.filter((d) => existsSync(d))
|
|
33
47
|
|
package/src/findings.js
CHANGED
|
@@ -47,6 +47,17 @@ function freshAction(tools) {
|
|
|
47
47
|
return `Compact earlier, or start fresh when you switch task (${tools.map((t) => TOOLS[t].fresh).join(', ')}).`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* How to lower the effort level, in the tools used that record thinking.
|
|
52
|
+
* @param {import('./tools.js').Tool[]} tools
|
|
53
|
+
*/
|
|
54
|
+
function effortAction(tools) {
|
|
55
|
+
const ways = []
|
|
56
|
+
if (tools.includes('claude')) ways.push('claude --effort low in Claude Code')
|
|
57
|
+
if (tools.includes('codex')) ways.push('model_reasoning_effort in ~/.codex/config.toml for Codex')
|
|
58
|
+
return `For routine tasks, use a lower effort level (${ways.join('; ') || 'in your tool'}); keep the higher levels for hard problems.`
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
/**
|
|
51
62
|
* @param {import('./analyze.js').Analysis} a
|
|
52
63
|
* @param {import('./tools.js').Tool[]} [tools] the tools the sessions came from
|
|
@@ -118,5 +129,24 @@ export function findingsOf(a, tools = ['claude']) {
|
|
|
118
129
|
})
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
const thinkShare = pct(total.thinkCost, total.cost)
|
|
133
|
+
if (thinkShare >= 3) {
|
|
134
|
+
const levels = [...a.efforts.values()].filter((e) => e.out > 0 && e.calls >= 20)
|
|
135
|
+
const lo = levels.reduce((m, e) => (!m || e.think / e.out < m.think / m.out ? e : m), /** @type {any} */ (null))
|
|
136
|
+
const hi = levels.reduce((m, e) => (!m || e.think / e.out > m.think / m.out ? e : m), /** @type {any} */ (null))
|
|
137
|
+
const spread =
|
|
138
|
+
lo && hi && lo !== hi
|
|
139
|
+
? ` At ${hi.effort} effort in ${TOOLS[/** @type {import('./tools.js').Tool} */ (hi.tool)].name}, ${pct(hi.think, hi.out).toFixed(0)}% of the output was thinking; at ${lo.effort} effort in ${TOOLS[/** @type {import('./tools.js').Tool} */ (lo.tool)].name}, ${pct(lo.think, lo.out).toFixed(0)}%.`
|
|
140
|
+
: ''
|
|
141
|
+
out.push({
|
|
142
|
+
id: 'thinking',
|
|
143
|
+
value: usd(total.thinkCost),
|
|
144
|
+
title: `Thinking made ${pct(total.think, total.out).toFixed(0)}% of your output tokens, ${thinkShare.toFixed(1)}% of your usage`,
|
|
145
|
+
text: `${tokens(total.think)} thinking tokens, billed as output.${spread}`,
|
|
146
|
+
action: effortAction(tools),
|
|
147
|
+
usd: round2(total.thinkCost),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
return out.sort((x, y) => (y.usd ?? 0) - (x.usd ?? 0))
|
|
122
152
|
}
|
package/src/prices.js
CHANGED
|
@@ -124,6 +124,8 @@ function isOf(model, id) {
|
|
|
124
124
|
* @property {number} cw1 tokens written to Anthropic's 1-hour cache
|
|
125
125
|
* @property {number} read tokens read from the cache
|
|
126
126
|
* @property {number} out output, reasoning included
|
|
127
|
+
* @property {number} [think] of the output, thinking (reasoning) tokens, where the tool records them
|
|
128
|
+
* @property {string} [effort] the effort level the reply ran at, where the tool records it
|
|
127
129
|
* @property {boolean} [seen] counted already in an earlier file
|
|
128
130
|
*/
|
|
129
131
|
|
|
@@ -156,6 +158,16 @@ export function priceOf(model = '', context = 0) {
|
|
|
156
158
|
return entry.long && context > entry.long.above ? entry.long.price : entry.base
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Whether ReRead knows a price for a model id: a listed model, a dated or variant id of one, or a known family. Any
|
|
163
|
+
* other id (a DeepSeek or Qwen model run through Claude Code, say) has no price, and its replies are left out of costs.
|
|
164
|
+
* @param {string} [model]
|
|
165
|
+
*/
|
|
166
|
+
export function hasPrice(model = '') {
|
|
167
|
+
if (!model) return false
|
|
168
|
+
return IDS.some((k) => isOf(model, k)) || FAMILIES.some(([re]) => re.test(model.toLowerCase()))
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
/**
|
|
160
172
|
* The tokens a reply sent: new input, cache writes and cache reads.
|
|
161
173
|
* @param {Call} c
|
package/src/render.js
CHANGED
|
@@ -25,7 +25,7 @@ const REASON_TEXT = {
|
|
|
25
25
|
/**
|
|
26
26
|
* The summary for the terminal.
|
|
27
27
|
* @param {Report} data
|
|
28
|
-
* @param {{ top: number, by: '' | 'project' | 'model' | 'day' | 'tool', color: boolean }} opts
|
|
28
|
+
* @param {{ top: number, by: '' | 'project' | 'model' | 'day' | 'tool' | 'effort', color: boolean }} opts
|
|
29
29
|
*/
|
|
30
30
|
export function renderText(data, { top, by, color }) {
|
|
31
31
|
const paint = (/** @type {number} */ code) => (/** @type {unknown} */ t) =>
|
|
@@ -44,12 +44,26 @@ export function renderText(data, { top, by, color }) {
|
|
|
44
44
|
dim(
|
|
45
45
|
` ${data.span} · ${plural(S.sessions, 'session')} · ${plural(S.calls, 'reply', 'replies')} · ${usd(S.apiEquivalentUSD)} at API prices`,
|
|
46
46
|
),
|
|
47
|
+
...(S.unpriced.calls
|
|
48
|
+
? [
|
|
49
|
+
yellow(
|
|
50
|
+
` ${plural(S.unpriced.calls, 'reply', 'replies')} from models with no known price (${S.unpriced.models.join(', ')}) are left out of the cost.`,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
47
54
|
'',
|
|
48
55
|
` ${pad('Reading history from the cache', 34)}${lpad(`${S.cacheReadShare.toFixed(1)}%`, 7)} ${dim(`${S.tokensReadFromCache}% of all tokens read`)}`,
|
|
49
56
|
` ${pad('Writing history again', 34)}${lpad(`${S.rebuildShare.toFixed(1)}%`, 7)} ${dim('after the cache was lost')}`,
|
|
50
57
|
` ${pad('Everything else', 34)}${lpad(`${S.otherShare.toFixed(1)}%`, 7)} ${dim('new input, first cache writes, output')}`,
|
|
51
58
|
'',
|
|
52
59
|
]
|
|
60
|
+
if (S.thinking.tokens) {
|
|
61
|
+
out.splice(
|
|
62
|
+
-1,
|
|
63
|
+
0,
|
|
64
|
+
` ${pad('Of which thinking', 34)}${lpad(`${S.thinking.share.toFixed(1)}%`, 7)} ${dim(`${S.thinking.shareOfOutput}% of output tokens`)}`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
53
67
|
|
|
54
68
|
const reasons = REASONS.filter((w) => S.rebuilds[w].count)
|
|
55
69
|
if (reasons.length) {
|
|
@@ -62,7 +76,19 @@ export function renderText(data, { top, by, color }) {
|
|
|
62
76
|
out.push('')
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
if (by) {
|
|
79
|
+
if (by === 'effort') {
|
|
80
|
+
out.push(bold(' By effort level'))
|
|
81
|
+
out.push(
|
|
82
|
+
dim(` ${pad('tool', 14)}${pad('effort', 16)}${lpad('replies', 9)}${lpad('cost', 10)}${lpad('thinking', 10)}`),
|
|
83
|
+
)
|
|
84
|
+
for (const e of data.efforts) {
|
|
85
|
+
out.push(
|
|
86
|
+
` ${pad(e.name.slice(0, 13), 14)}${pad(e.effort.slice(0, 15), 16)}${lpad(int(e.calls), 9)}${lpad(usd(e.usd), 10)}${lpad(`${e.thinkingShareOfOutput.toFixed(0)}%`, 10)}`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (!data.efforts.length) out.push(dim(' No effort levels recorded in these sessions.'))
|
|
90
|
+
out.push(dim(' thinking: share of the output tokens'), '')
|
|
91
|
+
} else if (by) {
|
|
66
92
|
/** @type {[string, number, number, number][]} */
|
|
67
93
|
const rows =
|
|
68
94
|
by === 'project'
|
|
@@ -122,11 +148,20 @@ export function renderMarkdown(data, { top }) {
|
|
|
122
148
|
'',
|
|
123
149
|
`_${data.span} · ${plural(S.sessions, 'session')} · ${plural(S.calls, 'reply', 'replies')} · ${usd(S.apiEquivalentUSD)} at API prices · [ReRead](${HOMEPAGE}) ${VERSION}_`,
|
|
124
150
|
'',
|
|
151
|
+
...(S.unpriced.calls
|
|
152
|
+
? [
|
|
153
|
+
`> ${plural(S.unpriced.calls, 'reply', 'replies')} from models with no known price (${S.unpriced.models.map((m) => `\`${cell(m)}\``).join(', ')}) are left out of the cost.`,
|
|
154
|
+
'',
|
|
155
|
+
]
|
|
156
|
+
: []),
|
|
125
157
|
'| Where the usage went | Share |',
|
|
126
158
|
'|---|---:|',
|
|
127
159
|
`| Reading history from the cache | ${S.cacheReadShare.toFixed(1)}% |`,
|
|
128
160
|
`| Writing history again, after the cache was lost | ${S.rebuildShare.toFixed(1)}% |`,
|
|
129
161
|
`| Everything else (new input, first cache writes, output) | ${S.otherShare.toFixed(1)}% |`,
|
|
162
|
+
...(S.thinking.tokens
|
|
163
|
+
? [`| Of all usage, thinking (${S.thinking.shareOfOutput}% of output tokens) | ${S.thinking.share.toFixed(1)}% |`]
|
|
164
|
+
: []),
|
|
130
165
|
'',
|
|
131
166
|
]
|
|
132
167
|
const reasons = REASONS.filter((w) => S.rebuilds[w].count)
|
package/src/report.html
CHANGED
|
@@ -373,6 +373,12 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
373
373
|
</section>
|
|
374
374
|
</div>
|
|
375
375
|
|
|
376
|
+
<section class="card" style="margin-top:16px" id="efforts-box" aria-labelledby="h-efforts" hidden>
|
|
377
|
+
<h2 id="h-efforts">By effort level</h2>
|
|
378
|
+
<p class="sub" id="efforts-sub">The effort level each reply ran at, and how much of its output was thinking.</p>
|
|
379
|
+
<div id="efforts"></div>
|
|
380
|
+
</section>
|
|
381
|
+
|
|
376
382
|
<section class="card" style="margin-top:16px" id="sessions" aria-labelledby="h-sessions">
|
|
377
383
|
<div class="card-head">
|
|
378
384
|
<div>
|
|
@@ -471,7 +477,7 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
471
477
|
const D = {
|
|
472
478
|
version: S(raw.version), generatedAt: S(raw.generatedAt), span: S(raw.span) || 'your sessions', anon: !!raw.anon,
|
|
473
479
|
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),
|
|
480
|
+
sessions: arr(raw.sessions), findings: arr(raw.findings), share: S(raw.share), efforts: arr(raw.efforts),
|
|
475
481
|
product: S(raw.product) || 'Claude Code', tools: arr(raw.tools),
|
|
476
482
|
}
|
|
477
483
|
const REASONS = ['cold', 'model', 'prefix']
|
|
@@ -796,15 +802,34 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
796
802
|
return el('tr', null,
|
|
797
803
|
el('td', { class: 'name mono', title: S(m.model), text: S(m.model) || '(unknown)' }),
|
|
798
804
|
el('td', { class: 'num opt', text: int(m.calls) }),
|
|
799
|
-
el('td', { class: 'num', text: usd(u) }),
|
|
805
|
+
m.priced === false ? el('td', { class: 'num muted', text: 'no price' }) : el('td', { class: 'num', text: usd(u) }),
|
|
800
806
|
el('td', { class: 'num' }, minibar([['read', r], ['rebuild', b]], pct(r + b, 0))))
|
|
801
807
|
})
|
|
802
|
-
|
|
808
|
+
const unpriced = sm.unpriced || {}
|
|
809
|
+
const note = N(unpriced.calls)
|
|
810
|
+
? el('p', { class: 'sub', style: 'margin:12px 0 0', text: `${plural(N(unpriced.calls), 'reply', 'replies')} from models with no known price are left out of every cost and share.` })
|
|
811
|
+
: ''
|
|
812
|
+
box.replaceChildren(simpleTable([['Model'], ['Replies', 1, 1], ['Cost', 1], ['Re-reading', 1]], rows), note,
|
|
803
813
|
el('div', { class: 'chart-legend', style: 'margin:12px 0 0' },
|
|
804
814
|
el('span', null, el('i', { class: 'sw read' }), 'Cache reads'),
|
|
805
815
|
el('span', null, el('i', { class: 'sw rebuild' }), 'Written again')))
|
|
806
816
|
}
|
|
807
817
|
|
|
818
|
+
function renderEfforts() {
|
|
819
|
+
if (!D.efforts.length) return
|
|
820
|
+
const th = sm.thinking || {}
|
|
821
|
+
$('efforts-box').hidden = false
|
|
822
|
+
if (N(th.tokens)) $('efforts-sub').textContent = `Thinking was ${pct(N(th.shareOfOutput), 0)} of all output tokens and cost ${usd(N(th.usd))}, ${pct(N(th.share), 1)} of your usage. Effort levels are each tool's own; a level in one tool is not the same as in another.`
|
|
823
|
+
const rows = D.efforts.map((e) => el('tr', null,
|
|
824
|
+
el('td', { class: 'name', text: S(e.name) }),
|
|
825
|
+
el('td', { class: 'name mono', text: S(e.effort) }),
|
|
826
|
+
el('td', { class: 'num opt', text: int(N(e.calls)) }),
|
|
827
|
+
el('td', { class: 'num', text: usd(N(e.usd)) }),
|
|
828
|
+
el('td', { class: 'num' }, minibar([['read', N(e.thinkingShareOfOutput)]], pct(N(e.thinkingShareOfOutput), 0))),
|
|
829
|
+
el('td', { class: 'num opt', text: usd(N(e.thinkingUsd)) })))
|
|
830
|
+
$('efforts').replaceChildren(simpleTable([['Tool'], ['Effort'], ['Replies', 1, 1], ['Cost', 1], ['Thinking', 1], ['Thinking cost', 1, 1]], rows))
|
|
831
|
+
}
|
|
832
|
+
|
|
808
833
|
// ---------- sessions ----------
|
|
809
834
|
const rebuildsOf = (s) => { const r = s.rebuilds || {}; return { cold: N(r.cold), model: N(r.model), prefix: N(r.prefix) } }
|
|
810
835
|
const rebuildTotal = (s) => { const r = rebuildsOf(s); return r.cold + r.model + r.prefix }
|
|
@@ -1100,7 +1125,15 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
1100
1125
|
ctx.fillStyle = '#f0efec'; ctx.fillText(cmd, W - P - cw - 18, H - 72)
|
|
1101
1126
|
return c
|
|
1102
1127
|
}
|
|
1128
|
+
// Inside the VS Code extension the page cannot download; it hands the file to the editor, which asks where to save it.
|
|
1129
|
+
const editor = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null
|
|
1103
1130
|
function download(name, blob) {
|
|
1131
|
+
if (editor) {
|
|
1132
|
+
const r = new FileReader()
|
|
1133
|
+
r.onload = () => editor.postMessage({ type: 'save', name, data: r.result })
|
|
1134
|
+
r.readAsDataURL(blob)
|
|
1135
|
+
return
|
|
1136
|
+
}
|
|
1104
1137
|
const url = URL.createObjectURL(blob)
|
|
1105
1138
|
const a = el('a', { href: url, download: name, style: 'display:none' })
|
|
1106
1139
|
document.body.append(a); a.click(); a.remove()
|
|
@@ -1126,7 +1159,7 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
1126
1159
|
$('copy-text').addEventListener('click', async () => say((await copy(shareText())) ? 'Copied.' : 'Could not copy. Select the text above instead.'))
|
|
1127
1160
|
$('download-image').addEventListener('click', () => {
|
|
1128
1161
|
const c = drawCard()
|
|
1129
|
-
if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); say('Image saved.') } }, 'image/png')
|
|
1162
|
+
if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); if (!editor) say('Image saved.') } }, 'image/png')
|
|
1130
1163
|
})
|
|
1131
1164
|
}
|
|
1132
1165
|
|
|
@@ -1174,9 +1207,11 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
|
|
|
1174
1207
|
renderWhy()
|
|
1175
1208
|
renderProjects()
|
|
1176
1209
|
renderModels()
|
|
1210
|
+
renderEfforts()
|
|
1177
1211
|
renderSessions()
|
|
1178
1212
|
renderFindings()
|
|
1179
1213
|
renderShare()
|
|
1214
|
+
if (editor) editor.postMessage({ type: 'ready' })
|
|
1180
1215
|
})()
|
|
1181
1216
|
</script>
|
|
1182
1217
|
</body>
|
package/src/statusline.js
CHANGED
|
@@ -20,9 +20,9 @@ import { tmpdir } from 'node:os'
|
|
|
20
20
|
import path from 'node:path'
|
|
21
21
|
import { walk } from './analyze.js'
|
|
22
22
|
import { pct, tokens } from './format.js'
|
|
23
|
-
import { contextOf, priceOf } from './prices.js'
|
|
24
23
|
import { callOf, collect } from './claude.js'
|
|
25
24
|
import { readLines } from './files.js'
|
|
25
|
+
import { contextOf, hasPrice, priceOf } from './prices.js'
|
|
26
26
|
|
|
27
27
|
/** @typedef {import('./prices.js').Call} Call */
|
|
28
28
|
|
|
@@ -45,25 +45,53 @@ export function statusline(input, { now = Date.now(), color = false, cacheDir =
|
|
|
45
45
|
}
|
|
46
46
|
if (!file || !existsSync(file)) return 'reread: no session yet'
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
if (!s.lastCall) return 'reread: no replies yet'
|
|
48
|
+
const s = sessionState(file, { cacheDir })
|
|
49
|
+
if (!s) return 'reread: no replies yet'
|
|
51
50
|
|
|
52
51
|
const paint = (/** @type {number} */ code) => (/** @type {string} */ t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
52
|
+
const left = s.warmUntil - now
|
|
53
|
+
const parts = [`ctx ${tokens(s.ctx)}`]
|
|
54
|
+
if (left > 0) parts.push(paint(left < 60e3 ? 33 : 32)(`cache warm ${clock(left)}`))
|
|
55
|
+
else {
|
|
56
|
+
const price = s.rewriteUsd === null ? '' : ` (~$${s.rewriteUsd.toFixed(2)})`
|
|
57
|
+
parts.push(paint(31)(`cache cold: next reply rewrites ${tokens(s.ctx)}${price}`))
|
|
58
|
+
}
|
|
59
|
+
if (s.rereadPct !== null) parts.push(`re-read ${s.rereadPct.toFixed(0)}%`)
|
|
60
|
+
return parts.join(' · ')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Minutes and seconds, as 3:41.
|
|
65
|
+
* @param {number} ms
|
|
66
|
+
*/
|
|
67
|
+
export const clock = (ms) => `${Math.floor(ms / 60e3)}:${String(Math.floor((ms % 60e3) / 1e3)).padStart(2, '0')}`
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where a session stands: the context its last reply sent, until when the cache stays warm, what writing the context
|
|
71
|
+
* to the cache again would cost, and how much of the session so far was re-reading. Null before the first reply.
|
|
72
|
+
* @param {string} file
|
|
73
|
+
* @param {{ cacheDir?: string | null }} [opts] cacheDir null: no cache
|
|
74
|
+
*/
|
|
75
|
+
export function sessionState(file, { cacheDir = defaultCacheDir() } = {}) {
|
|
76
|
+
const calls = readCalls(file, cacheDir)
|
|
77
|
+
const s = walk([...calls.values()].sort((a, b) => a.ts - b.ts))
|
|
78
|
+
if (!s.lastCall) return null
|
|
53
79
|
const ctx = contextOf(s.lastCall)
|
|
54
|
-
const
|
|
80
|
+
const priced = hasPrice(s.lastCall.model)
|
|
55
81
|
const p = priceOf(s.lastCall.model, ctx)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
return {
|
|
83
|
+
model: s.lastCall.model,
|
|
84
|
+
ctx,
|
|
85
|
+
lastReply: s.lastCall.ts,
|
|
86
|
+
/** the cache's life: 5 minutes, or 1 hour once the session has written to the 1-hour cache */
|
|
87
|
+
ttl: s.ttl,
|
|
88
|
+
warmUntil: s.lastCall.ts + s.ttl,
|
|
89
|
+
/** null when the model has no known price */
|
|
90
|
+
rewriteUsd: priced ? (ctx * (s.ttl > 300e3 ? p.write1h : p.write5m)) / 1e6 : null,
|
|
91
|
+
rereadPct: priced && s.cost > 0 ? pct(s.readCost + s.rebuildCost, s.cost) : null,
|
|
92
|
+
usd: s.cost,
|
|
93
|
+
replies: calls.size,
|
|
64
94
|
}
|
|
65
|
-
parts.push(`re-read ${pct(s.readCost + s.rebuildCost, s.cost).toFixed(0)}%`)
|
|
66
|
-
return parts.join(' · ')
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
/**
|
|
@@ -107,7 +135,7 @@ function readCalls(file, cacheDir) {
|
|
|
107
135
|
* A per-user folder for the cache, or null when one cannot be made safely.
|
|
108
136
|
* @returns {string | null}
|
|
109
137
|
*/
|
|
110
|
-
function defaultCacheDir() {
|
|
138
|
+
export function defaultCacheDir() {
|
|
111
139
|
const uid = typeof process.getuid === 'function' ? process.getuid() : null
|
|
112
140
|
const dir = path.join(tmpdir(), `reread-${uid ?? 'user'}`)
|
|
113
141
|
try {
|
package/src/tools.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { statSync } from 'node:fs'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { antigravityDirs, isAntigravityFile, isAntigravityOther, readAntigravityFile } from './antigravity.js'
|
|
7
|
-
import { claudeDirs, readClaudeFile } from './claude.js'
|
|
7
|
+
import { claudeDirs, claudeFolderName, readClaudeFile } from './claude.js'
|
|
8
8
|
import { codexDirs, isCodexFile, readCodexFile } from './codex.js'
|
|
9
9
|
import { existingDirs, filesUnder } from './files.js'
|
|
10
10
|
|
|
@@ -16,6 +16,7 @@ import { existingDirs, filesUnder } from './files.js'
|
|
|
16
16
|
* @property {string} cwd the folder it ran in, or ''
|
|
17
17
|
* @property {string} branch its git branch, or ''
|
|
18
18
|
* @property {import('./prices.js').Call[]} calls its replies, oldest first
|
|
19
|
+
* @property {boolean} [unread] a file this Node.js cannot read (a compressed one), so its replies are missing
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -28,6 +29,7 @@ import { existingDirs, filesUnder } from './files.js'
|
|
|
28
29
|
* @property {(file: string) => boolean} [ignores] a file in its folders that is not a session
|
|
29
30
|
* @property {(file: string, since: number, seen: Set<string>) => Read} read
|
|
30
31
|
* @property {boolean} [projectInPath] its folder names hold the project, so --project filters paths
|
|
32
|
+
* @property {(cwd: string) => string} [folderName] the name of the folder it keeps a project's sessions in
|
|
31
33
|
*/
|
|
32
34
|
|
|
33
35
|
/** In the order a file is matched: the more specific names first, Claude Code's plain .jsonl last. */
|
|
@@ -55,6 +57,7 @@ export const TOOLS = /** @type {const} */ ({
|
|
|
55
57
|
owns: (/** @type {string} */ f) => f.endsWith('.jsonl'),
|
|
56
58
|
read: readClaudeFile,
|
|
57
59
|
projectInPath: true,
|
|
60
|
+
folderName: claudeFolderName,
|
|
58
61
|
},
|
|
59
62
|
})
|
|
60
63
|
/** @typedef {keyof typeof TOOLS} Tool */
|
|
@@ -101,12 +104,19 @@ export function rootsFor(tool, dir) {
|
|
|
101
104
|
/**
|
|
102
105
|
* The session files to read: changed at or after `since`, of the chosen tool, oldest first, so a reply copied into a
|
|
103
106
|
* resumed session is counted in the session that made it. Where a tool's folder names hold the project, `project`
|
|
104
|
-
*
|
|
107
|
+
* and `folders` filter paths here; the other tools name the folder inside, so analyze() filters those.
|
|
105
108
|
* @param {string[]} roots
|
|
106
|
-
* @param {{ since: number, project: string, tool: 'all' | Tool }} filter
|
|
109
|
+
* @param {{ since: number, project: string, tool: 'all' | Tool, folders?: string[] }} filter
|
|
107
110
|
*/
|
|
108
|
-
export function sessionFiles(roots, { since, project, tool }) {
|
|
111
|
+
export function sessionFiles(roots, { since, project, tool, folders = [] }) {
|
|
109
112
|
const needle = project.toLowerCase()
|
|
113
|
+
/** @param {Tool} t @param {string} root @param {string} f */
|
|
114
|
+
const inFolders = (t, root, f) => {
|
|
115
|
+
const name = INFO[t].folderName
|
|
116
|
+
if (!folders.length || !name) return true
|
|
117
|
+
const top = path.relative(root, f).split(path.sep)[0]
|
|
118
|
+
return folders.some((d) => top === name(d) || top.startsWith(`${name(d)}-`))
|
|
119
|
+
}
|
|
110
120
|
/** @type {[string, number, number][]} */
|
|
111
121
|
const found = []
|
|
112
122
|
for (const root of roots) {
|
|
@@ -114,6 +124,7 @@ export function sessionFiles(roots, { since, project, tool }) {
|
|
|
114
124
|
const t = /** @type {Tool} */ (toolOf(f))
|
|
115
125
|
if (tool !== 'all' && t !== tool) continue
|
|
116
126
|
if (needle && INFO[t].projectInPath && !path.relative(root, f).toLowerCase().includes(needle)) continue
|
|
127
|
+
if (!inFolders(t, root, f)) continue
|
|
117
128
|
let st
|
|
118
129
|
try {
|
|
119
130
|
st = statSync(f)
|