reread-cli 0.2.1 → 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
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
@@ -103,7 +105,7 @@ Output
103
105
  --json all the numbers as JSON
104
106
  --csv every session as CSV
105
107
  --md a Markdown summary, to paste into an issue or a pull request
106
- --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
107
109
  --top <n> sessions in the summary (default 10)
108
110
  --anon replace project names with "project 1", "project 2", ... and leave branches out
109
111
  ```
@@ -112,6 +114,7 @@ Examples:
112
114
 
113
115
  ```
114
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
115
118
  npx reread-cli --tool codex # Codex sessions only
116
119
  npx reread-cli --project api --text # one project, terminal only
117
120
  npx reread-cli --json | jq .summary # the numbers, for your own scripts
@@ -140,6 +143,11 @@ A reply counts once, in the session that made it, even when a resumed session co
140
143
  toward the session that started it. "Writing history again" is estimated from token counts: a reply whose cache read
141
144
  fell below half of the previous reply's context while the context did not shrink.
142
145
 
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
+
143
151
  The details, with every rule and threshold: [docs/how-it-counts.md](docs/how-it-counts.md). None of the tools documents
144
152
  its session format, and any may change it; if ReRead reads yours wrong, please
145
153
  [open an issue](https://github.com/tecyfyai/reread/issues).
@@ -181,6 +189,20 @@ it, so ReRead prices its replies at the model in Antigravity's settings (Gemini
181
189
  stay right whatever the model. Gemini CLI was retired for individual users in June 2026 in favour of Antigravity CLI,
182
190
  so ReRead does not read it.
183
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
+
184
206
  ### How is ReRead different from ccusage?
185
207
 
186
208
  [ccusage](https://github.com/ccusage/ccusage) reports how many tokens you used and what they cost, by day, month or
@@ -197,6 +219,13 @@ their own way, so the dollar figures are what the same tokens would cost on the
197
219
  No. It reads only token counts from local files, makes no network requests and has no dependencies. See
198
220
  [Private by design](#private-by-design).
199
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
+
200
229
  ## Contributing
201
230
 
202
231
  Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reread-cli",
3
- "version": "0.2.1",
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
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { isInside } from './files.js'
6
6
  import { findingsOf } from './findings.js'
7
7
  import { dayOf, pct, round1, round2 } from './format.js'
8
- import { contextOf, costOf, priceOf } from './prices.js'
8
+ import { contextOf, costOf, hasPrice, priceOf } from './prices.js'
9
9
  import { DISPLAY_ORDER, toolOf, TOOLS } from './tools.js'
10
10
 
11
11
  /** @typedef {import('./prices.js').Call} Call */
@@ -56,6 +56,11 @@ export const BUCKETS = /** @type {const} */ ([
56
56
  * @property {number} tokensRead
57
57
  * @property {number} peak largest context
58
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
59
64
  * @property {Record<Reason, { n: number, cost: number }>} rebuilds
60
65
  * @property {{ n: number, ctx: number }} compact compactions, and the sum of the contexts they replaced
61
66
  * @property {number} [first]
@@ -72,6 +77,11 @@ export const emptyTotals = () => ({
72
77
  tokensRead: 0,
73
78
  peak: 0,
74
79
  longCost: 0,
80
+ out: 0,
81
+ think: 0,
82
+ thinkCost: 0,
83
+ unpricedCalls: 0,
84
+ unpricedTokens: 0,
75
85
  rebuilds: { cold: { n: 0, cost: 0 }, model: { n: 0, cost: 0 }, prefix: { n: 0, cost: 0 } },
76
86
  compact: { n: 0, ctx: 0 },
77
87
  })
@@ -91,6 +101,11 @@ export function addTotals(a, b) {
91
101
  a.tokensIn += b.tokensIn
92
102
  a.tokensRead += b.tokensRead
93
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
94
109
  a.peak = Math.max(a.peak, b.peak)
95
110
  for (const w of REASONS) {
96
111
  a.rebuilds[w].n += b.rebuilds[w].n
@@ -109,6 +124,7 @@ export function addTotals(a, b) {
109
124
  * @property {number} cost
110
125
  * @property {number} readCost
111
126
  * @property {number} rebuildCost
127
+ * @property {number} thinkCost
112
128
  * @property {Reason | null} why set when the whole history was written again
113
129
  * @property {number} flag FLAG value for the report
114
130
  */
@@ -138,9 +154,17 @@ export function walk(calls, onCall = () => {}) {
138
154
  continue
139
155
  }
140
156
  const ctx = contextOf(c)
141
- const p = priceOf(c.model, ctx)
142
- 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
143
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
+ }
144
168
  /** @type {Reason | null} */
145
169
  let why = null
146
170
  let rebuildCost = 0
@@ -167,11 +191,22 @@ export function walk(calls, onCall = () => {}) {
167
191
  s.rebuildCost += rebuildCost
168
192
  s.tokensIn += ctx
169
193
  s.tokensRead += c.read
194
+ s.out += c.out
195
+ s.think += think
196
+ s.thinkCost += thinkCost
170
197
  s.peak = Math.max(s.peak, ctx)
171
198
  if (ctx > LONG_CONTEXT) s.longCost += cost
172
199
  s.first ??= c.ts
173
200
  s.last = c.ts
174
- 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
+ })
175
210
  if (c.cw1 > 0) ttl = 3600e3
176
211
  else if (c.cw5 > 0) ttl = 300e3
177
212
  prev = c
@@ -183,6 +218,7 @@ export function walk(calls, onCall = () => {}) {
183
218
  * @typedef {Totals & { id: string, tool: Tool, project: string, branch: string, points: [number, number, number, number][] }} Session
184
219
  * points: [time, context, cache read, FLAG] for each reply, for the report's chart
185
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
186
222
  */
187
223
 
188
224
  /**
@@ -205,6 +241,8 @@ export function analyze(files, { since, project = '', folders = [], onProgress }
205
241
  const models = new Map()
206
242
  /** @type {Map<string, Money>} */
207
243
  const buckets = new Map()
244
+ /** @type {Map<string, EffortRow>} */
245
+ const efforts = new Map()
208
246
  const zero = () => ({ calls: 0, usd: 0, readUsd: 0, rebuildUsd: 0 })
209
247
  /**
210
248
  * @param {Map<string, Money>} map
@@ -222,6 +260,8 @@ export function analyze(files, { since, project = '', folders = [], onProgress }
222
260
 
223
261
  /** @type {{ file: string, error: string }[]} */
224
262
  const skipped = []
263
+ /** Compressed files this Node.js cannot read. */
264
+ const unread = /** @type {string[]} */ ([])
225
265
 
226
266
  files.forEach((file, i) => {
227
267
  onProgress?.(i + 1, files.length)
@@ -236,6 +276,7 @@ export function analyze(files, { since, project = '', folders = [], onProgress }
236
276
  return
237
277
  }
238
278
  const { id, sub, cwd, branch, calls } = read
279
+ if (read.unread) unread.push(file)
239
280
  if (!calls.some((c) => !c.seen)) return
240
281
  const info = /** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool])
241
282
  if (!info.projectInPath && needle) {
@@ -249,6 +290,16 @@ export function analyze(files, { since, project = '', folders = [], onProgress }
249
290
  tally(models, c.model, x)
250
291
  const label = /** @type {(typeof BUCKETS)[number]} */ (BUCKETS.find(([top]) => x.ctx < top))[1]
251
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
+ }
252
303
  if (!sub) cur.points.push([c.ts, x.ctx, c.read, x.flag])
253
304
  })
254
305
  if (cwd && !cur.project) cur.project = path.basename(cwd)
@@ -257,7 +308,16 @@ export function analyze(files, { since, project = '', folders = [], onProgress }
257
308
  addTotals(total, s)
258
309
  })
259
310
 
260
- 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
+ }
261
321
  }
262
322
 
263
323
  /**
@@ -357,6 +417,18 @@ export function summarize(a, { span, anon, version, now = new Date() }) {
357
417
  otherShare: round1(100 - share),
358
418
  tokensReadFromCache: round1(pct(total.tokensRead, total.tokensIn)),
359
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
+ },
360
432
  apiEquivalentUSD: round2(total.cost),
361
433
  rebuilds: /** @type {Record<Reason, { count: number, usd: number, share: number }>} */ (
362
434
  Object.fromEntries(
@@ -384,11 +456,23 @@ export function summarize(a, { span, anon, version, now = new Date() }) {
384
456
  .sort((x, y) => y[1].usd - x[1].usd)
385
457
  .map(([model, v]) => ({
386
458
  model,
459
+ priced: hasPrice(model),
387
460
  calls: v.calls,
388
461
  usd: round2(v.usd),
389
462
  readUsd: round2(v.readUsd),
390
463
  rebuildUsd: round2(v.rebuildUsd),
391
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
+ })),
392
476
  projects: projects.map((p) => ({
393
477
  project: nameOf(p.project),
394
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
@@ -113,8 +113,8 @@ export function latestClaudeSession(folders, { dirs = claudeDirs(), cwdCache = n
113
113
  }
114
114
 
115
115
  /**
116
- * One line of a session file as a reply, or null when it is not one. Only token counts, the model, the time and
117
- * the ids are kept.
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.
118
118
  * @param {string} line
119
119
  * @param {number} since replies before this time are left out
120
120
  * @returns {Call | null}
@@ -136,6 +136,7 @@ export function callOf(line, since) {
136
136
  // Some replies list 1-hour writes in the breakdown while the total says 0; the breakdown is kept.
137
137
  const cw = n(u.cache_creation_input_tokens)
138
138
  const cw1 = n(u.cache_creation?.ephemeral_1h_input_tokens)
139
+ const out = n(u.output_tokens)
139
140
  return {
140
141
  key: `${m.id}|${j.requestId ?? ''}`,
141
142
  ts,
@@ -144,7 +145,9 @@ export function callOf(line, since) {
144
145
  cw5: Math.max(0, cw - cw1),
145
146
  cw1,
146
147
  read: n(u.cache_read_input_tokens),
147
- out: n(u.output_tokens),
148
+ out,
149
+ think: n(u.output_tokens_details?.thinking_tokens),
150
+ effort: typeof j.effort === 'string' ? j.effort : '',
148
151
  }
149
152
  }
150
153
 
@@ -156,8 +159,10 @@ export function callOf(line, since) {
156
159
  */
157
160
  export function collect(calls, call) {
158
161
  const old = calls.get(call.key)
159
- if (old) old.out = Math.max(old.out, call.out)
160
- 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)
161
166
  }
162
167
 
163
168
  /**
@@ -197,6 +202,8 @@ export function readClaudeFile(file, since, seen) {
197
202
  }
198
203
  eachLine(file, onLine)
199
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)
200
207
  c.seen = seen.has(key)
201
208
  seen.add(key)
202
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/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 }
@@ -1182,6 +1207,7 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
1182
1207
  renderWhy()
1183
1208
  renderProjects()
1184
1209
  renderModels()
1210
+ renderEfforts()
1185
1211
  renderSessions()
1186
1212
  renderFindings()
1187
1213
  renderShare()
package/src/statusline.js CHANGED
@@ -22,7 +22,7 @@ import { walk } from './analyze.js'
22
22
  import { pct, tokens } from './format.js'
23
23
  import { callOf, collect } from './claude.js'
24
24
  import { readLines } from './files.js'
25
- import { contextOf, priceOf } from './prices.js'
25
+ import { contextOf, hasPrice, priceOf } from './prices.js'
26
26
 
27
27
  /** @typedef {import('./prices.js').Call} Call */
28
28
 
@@ -52,8 +52,11 @@ export function statusline(input, { now = Date.now(), color = false, cacheDir =
52
52
  const left = s.warmUntil - now
53
53
  const parts = [`ctx ${tokens(s.ctx)}`]
54
54
  if (left > 0) parts.push(paint(left < 60e3 ? 33 : 32)(`cache warm ${clock(left)}`))
55
- else parts.push(paint(31)(`cache cold: next reply rewrites ${tokens(s.ctx)} (~$${s.rewriteUsd.toFixed(2)})`))
56
- parts.push(`re-read ${s.rereadPct.toFixed(0)}%`)
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)}%`)
57
60
  return parts.join(' · ')
58
61
  }
59
62
 
@@ -74,6 +77,7 @@ export function sessionState(file, { cacheDir = defaultCacheDir() } = {}) {
74
77
  const s = walk([...calls.values()].sort((a, b) => a.ts - b.ts))
75
78
  if (!s.lastCall) return null
76
79
  const ctx = contextOf(s.lastCall)
80
+ const priced = hasPrice(s.lastCall.model)
77
81
  const p = priceOf(s.lastCall.model, ctx)
78
82
  return {
79
83
  model: s.lastCall.model,
@@ -82,8 +86,9 @@ export function sessionState(file, { cacheDir = defaultCacheDir() } = {}) {
82
86
  /** the cache's life: 5 minutes, or 1 hour once the session has written to the 1-hour cache */
83
87
  ttl: s.ttl,
84
88
  warmUntil: s.lastCall.ts + s.ttl,
85
- rewriteUsd: (ctx * (s.ttl > 300e3 ? p.write1h : p.write5m)) / 1e6,
86
- rereadPct: pct(s.readCost + s.rebuildCost, s.cost),
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,
87
92
  usd: s.cost,
88
93
  replies: calls.size,
89
94
  }
package/src/tools.js CHANGED
@@ -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
  /**