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 +32 -3
- package/package.json +4 -2
- package/src/analyze.js +89 -5
- package/src/args.js +3 -3
- package/src/claude.js +12 -5
- package/src/cli.js +6 -1
- package/src/codex.js +10 -5
- package/src/findings.js +30 -0
- package/src/prices.js +12 -0
- package/src/render.js +37 -2
- package/src/report.html +29 -3
- package/src/statusline.js +10 -5
- package/src/tools.js +1 -0
package/README.md
CHANGED
|
@@ -21,13 +21,14 @@ Code, Codex and Antigravity already keep. Nothing is uploaded, and it has no dep
|
|
|
21
21
|
Reading history from the cache 33.6% 95.2% of all tokens read
|
|
22
22
|
Writing history again 18.7% after the cache was lost
|
|
23
23
|
Everything else 47.7% new input, first cache writes, output
|
|
24
|
+
Of which thinking 5.9% 27% of output tokens
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Every reply in a session sends the whole conversation to the model again. Prompt caching makes those re-reads
|
|
27
28
|
cheaper, but in a long session they still add up, and when the cache is lost the whole history is written again at
|
|
28
29
|
full price. ReRead shows you the split, when and why the cache was lost, and which sessions cost the most.
|
|
29
30
|
|
|
30
|
-
Every tool with sessions on your machine is read; `--tool claude`, `--tool codex` or `--tool antigravity` picks one. Needs Node.js 20 or newer (22.15 or newer also reads Codex's
|
|
31
|
+
Every tool with sessions on your machine is read; `--tool claude`, `--tool codex` or `--tool antigravity` picks one. Needs Node.js 20 or newer (22.15 or newer also reads Codex's compressed sessions; on an older Node.js, ReRead says how many it had to skip).
|
|
31
32
|
|
|
32
33
|
## The report
|
|
33
34
|
|
|
@@ -35,13 +36,14 @@ Every tool with sessions on your machine is read; `--tool claude`, `--tool codex
|
|
|
35
36
|
|
|
36
37
|
- **Key findings** first: a few cards, the costliest first, each with one number, what it means and what to do
|
|
37
38
|
(how much of your usage came from long contexts, how concentrated it is in a few sessions, what losing the cache
|
|
38
|
-
cost and why, how large your context grew before each compaction).
|
|
39
|
+
cost and why, how large your context grew before each compaction, how much of your usage was thinking).
|
|
39
40
|
- **The split**: re-reading from the cache, history written again, and everything else.
|
|
40
41
|
- **By day**: what each day cost and how much of it was re-reading.
|
|
41
42
|
- **By context size**: how much of your usage came from replies with a large context (over 150k is where it adds up).
|
|
42
43
|
- **Why the cache was lost**: a break longer than its life (5 minutes or 1 hour), a model switch mid-session, or a
|
|
43
44
|
change near the start of the context (tools, MCP servers, settings).
|
|
44
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.
|
|
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
|
-
|
|
142
|
-
const
|
|
157
|
+
// A model with no known price costs nothing here, rather than a guessed price.
|
|
158
|
+
const priced = hasPrice(c.model)
|
|
159
|
+
const p = priced ? priceOf(c.model, ctx) : { input: 0, write5m: 0, write1h: 0, read: 0, output: 0 }
|
|
160
|
+
const cost = priced ? costOf(c) : 0
|
|
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, {
|
|
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 {
|
|
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
|
|
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
|
|
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)
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
/**
|