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 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 archived sessions).
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 both.
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
- The details, with every rule and threshold: [docs/how-it-counts.md](docs/how-it-counts.md). Neither tool documents its
136
- session format, and either may change it; if ReRead reads yours wrong, please
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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // ReRead: how much of your Claude Code usage was re-reading context.
2
+ // ReRead: how much of your coding agent's usage was re-reading context.
3
3
  import { main } from '../src/cli.js'
4
4
 
5
5
  process.exitCode = await main(process.argv.slice(2))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reread-cli",
3
- "version": "0.2.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
- const p = priceOf(c.model, ctx)
141
- const cost = costOf(c)
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, { ctx, cost, readCost, rebuildCost, why, flag: why ? FLAG[why] : shrank ? FLAG.shrink : FLAG.none })
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
- if (!(/** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool]).projectInPath) && needle) {
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 { total, sessions: [...sessions.values()].sort((a, b) => b.cost - a.cost), daily, models, buckets, skipped }
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
- * One line of a session file as a reply, or null when it is not one. Only token counts, the model, the time and
26
- * the ids are kept.
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: n(u.output_tokens),
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) old.out = Math.max(old.out, call.out)
69
- else calls.set(call.key, call)
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
- box.replaceChildren(simpleTable([['Model'], ['Replies', 1, 1], ['Cost', 1], ['Re-reading', 1]], rows),
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 calls = readCalls(file, cacheDir)
49
- const s = walk([...calls.values()].sort((a, b) => a.ts - b.ts))
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 left = s.ttl - (now - s.lastCall.ts)
80
+ const priced = hasPrice(s.lastCall.model)
55
81
  const p = priceOf(s.lastCall.model, ctx)
56
- const parts = [`ctx ${tokens(ctx)}`]
57
- if (left > 0) {
58
- const m = Math.floor(left / 60e3)
59
- const sec = Math.floor((left % 60e3) / 1e3)
60
- parts.push(paint(left < 60e3 ? 33 : 32)(`cache warm ${m}:${String(sec).padStart(2, '0')}`))
61
- } else {
62
- const rewrite = (ctx * (s.ttl > 300e3 ? p.write1h : p.write5m)) / 1e6
63
- parts.push(paint(31)(`cache cold: next reply rewrites ${tokens(ctx)} (~$${rewrite.toFixed(2)})`))
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
- * filters paths here; the other tools name the folder inside, so analyze() filters those.
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)