reread-cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -2
- package/bin/reread.js +5 -0
- package/package.json +59 -4
- package/src/analyze.js +425 -0
- package/src/antigravity.js +190 -0
- package/src/args.js +155 -0
- package/src/claude.js +121 -0
- package/src/cli.js +127 -0
- package/src/codex.js +135 -0
- package/src/files.js +102 -0
- package/src/findings.js +122 -0
- package/src/format.js +23 -0
- package/src/meta.js +10 -0
- package/src/prices.js +172 -0
- package/src/render.js +213 -0
- package/src/report.html +1183 -0
- package/src/report.js +69 -0
- package/src/statusline.js +165 -0
- package/src/tools.js +127 -0
package/src/analyze.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Counting: what each reply cost, how much of it was re-reading history, and when the whole history had to be
|
|
3
|
+
// written again.
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { findingsOf } from './findings.js'
|
|
6
|
+
import { dayOf, pct, round1, round2 } from './format.js'
|
|
7
|
+
import { contextOf, costOf, priceOf } from './prices.js'
|
|
8
|
+
import { DISPLAY_ORDER, toolOf, TOOLS } from './tools.js'
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('./prices.js').Call} Call */
|
|
11
|
+
/** @typedef {'cold' | 'model' | 'prefix'} Reason */
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('./tools.js').Tool} Tool */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The tools in words: 'Claude Code', 'Codex', 'Claude Code and Codex'.
|
|
17
|
+
* @param {Tool[]} tools
|
|
18
|
+
*/
|
|
19
|
+
export function productOf(tools) {
|
|
20
|
+
const names = DISPLAY_ORDER.filter((t) => tools.includes(t)).map((t) => TOOLS[t].name)
|
|
21
|
+
if (names.length < 2) return names[0] ?? TOOLS.claude.name
|
|
22
|
+
return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Why the whole history was written again: the cache went cold, the model changed, or something early changed. */
|
|
26
|
+
export const REASONS = /** @type {const} */ (['cold', 'model', 'prefix'])
|
|
27
|
+
|
|
28
|
+
/** How each reply is marked in the report's session chart. */
|
|
29
|
+
export const FLAG = { none: 0, cold: 1, model: 2, prefix: 3, shrink: 4 }
|
|
30
|
+
|
|
31
|
+
/** A compaction counts when the context it replaced was at least this large. */
|
|
32
|
+
const COMPACT_MIN = 20e3
|
|
33
|
+
/** Below this the API caches nothing, so a small context is never a rebuild. */
|
|
34
|
+
const CACHE_MIN = 2048
|
|
35
|
+
/** A reply is "long context" from here. */
|
|
36
|
+
export const LONG_CONTEXT = 150e3
|
|
37
|
+
|
|
38
|
+
/** Context sizes for the "cost by context size" chart: [upper bound, label]. */
|
|
39
|
+
export const BUCKETS = /** @type {const} */ ([
|
|
40
|
+
[50e3, '0–50k'],
|
|
41
|
+
[100e3, '50–100k'],
|
|
42
|
+
[150e3, '100–150k'],
|
|
43
|
+
[200e3, '150–200k'],
|
|
44
|
+
[500e3, '200–500k'],
|
|
45
|
+
[Infinity, '500k+'],
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} Totals
|
|
50
|
+
* @property {number} calls
|
|
51
|
+
* @property {number} cost API price of everything, US dollars
|
|
52
|
+
* @property {number} readCost of which cache reads
|
|
53
|
+
* @property {number} rebuildCost of which history written again after the cache was lost
|
|
54
|
+
* @property {number} tokensIn
|
|
55
|
+
* @property {number} tokensRead
|
|
56
|
+
* @property {number} peak largest context
|
|
57
|
+
* @property {number} longCost cost of replies with a context over LONG_CONTEXT
|
|
58
|
+
* @property {Record<Reason, { n: number, cost: number }>} rebuilds
|
|
59
|
+
* @property {{ n: number, ctx: number }} compact compactions, and the sum of the contexts they replaced
|
|
60
|
+
* @property {number} [first]
|
|
61
|
+
* @property {number} [last]
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** @returns {Totals} */
|
|
65
|
+
export const emptyTotals = () => ({
|
|
66
|
+
calls: 0,
|
|
67
|
+
cost: 0,
|
|
68
|
+
readCost: 0,
|
|
69
|
+
rebuildCost: 0,
|
|
70
|
+
tokensIn: 0,
|
|
71
|
+
tokensRead: 0,
|
|
72
|
+
peak: 0,
|
|
73
|
+
longCost: 0,
|
|
74
|
+
rebuilds: { cold: { n: 0, cost: 0 }, model: { n: 0, cost: 0 }, prefix: { n: 0, cost: 0 } },
|
|
75
|
+
compact: { n: 0, ctx: 0 },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Add b into a.
|
|
80
|
+
* @template {Totals} T
|
|
81
|
+
* @param {T} a
|
|
82
|
+
* @param {Totals} b
|
|
83
|
+
* @returns {T}
|
|
84
|
+
*/
|
|
85
|
+
export function addTotals(a, b) {
|
|
86
|
+
a.calls += b.calls
|
|
87
|
+
a.cost += b.cost
|
|
88
|
+
a.readCost += b.readCost
|
|
89
|
+
a.rebuildCost += b.rebuildCost
|
|
90
|
+
a.tokensIn += b.tokensIn
|
|
91
|
+
a.tokensRead += b.tokensRead
|
|
92
|
+
a.longCost += b.longCost
|
|
93
|
+
a.peak = Math.max(a.peak, b.peak)
|
|
94
|
+
for (const w of REASONS) {
|
|
95
|
+
a.rebuilds[w].n += b.rebuilds[w].n
|
|
96
|
+
a.rebuilds[w].cost += b.rebuilds[w].cost
|
|
97
|
+
}
|
|
98
|
+
a.compact.n += b.compact.n
|
|
99
|
+
a.compact.ctx += b.compact.ctx
|
|
100
|
+
if (b.first !== undefined) a.first = Math.min(a.first ?? Infinity, b.first)
|
|
101
|
+
if (b.last !== undefined) a.last = Math.max(a.last ?? 0, b.last)
|
|
102
|
+
return a
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {object} Step what one counted reply cost and what happened
|
|
107
|
+
* @property {number} ctx
|
|
108
|
+
* @property {number} cost
|
|
109
|
+
* @property {number} readCost
|
|
110
|
+
* @property {number} rebuildCost
|
|
111
|
+
* @property {Reason | null} why set when the whole history was written again
|
|
112
|
+
* @property {number} flag FLAG value for the report
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Walk one stream of replies (a session file, or a subagent inside one) in time order.
|
|
117
|
+
*
|
|
118
|
+
* A reply is a rebuild when its cache read fell below half of the previous reply's context while the context did not
|
|
119
|
+
* shrink: the history the previous reply had cached was written again. The reason is `model` when the model changed,
|
|
120
|
+
* `cold` when the gap was longer than the cache's life (5 minutes, or 1 hour once a 1-hour write was seen), and
|
|
121
|
+
* otherwise `prefix` (something early in the context changed). A context that shrank below 60% is a compaction or
|
|
122
|
+
* /clear, not a rebuild.
|
|
123
|
+
*
|
|
124
|
+
* Replies marked `seen` were counted in an earlier file: they set the cache state but are not counted again.
|
|
125
|
+
* @param {Call[]} calls
|
|
126
|
+
* @param {(c: Call, step: Step) => void} [onCall] called for every counted reply
|
|
127
|
+
* @returns {Totals & { ttl: number, lastCall: Call | null }}
|
|
128
|
+
*/
|
|
129
|
+
export function walk(calls, onCall = () => {}) {
|
|
130
|
+
const s = emptyTotals()
|
|
131
|
+
/** @type {Call | null} */
|
|
132
|
+
let prev = null
|
|
133
|
+
let ttl = 300e3
|
|
134
|
+
for (const c of calls) {
|
|
135
|
+
if (c.seen) {
|
|
136
|
+
prev = c
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const ctx = contextOf(c)
|
|
140
|
+
const p = priceOf(c.model, ctx)
|
|
141
|
+
const cost = costOf(c)
|
|
142
|
+
const readCost = (c.read * p.read) / 1e6
|
|
143
|
+
/** @type {Reason | null} */
|
|
144
|
+
let why = null
|
|
145
|
+
let rebuildCost = 0
|
|
146
|
+
let shrank = false
|
|
147
|
+
if (prev) {
|
|
148
|
+
const before = contextOf(prev)
|
|
149
|
+
shrank = ctx < before * 0.6
|
|
150
|
+
if (shrank && before >= COMPACT_MIN) {
|
|
151
|
+
s.compact.n++
|
|
152
|
+
s.compact.ctx += before
|
|
153
|
+
}
|
|
154
|
+
if (!shrank && before >= CACHE_MIN && c.read < before * 0.5) {
|
|
155
|
+
const written = Math.max(0, Math.min(before, ctx) - c.read)
|
|
156
|
+
const rate = c.cw1 > 0 ? p.write1h : c.cw5 > 0 ? p.write5m : p.input
|
|
157
|
+
why = c.model !== prev.model ? 'model' : c.ts - prev.ts > ttl ? 'cold' : 'prefix'
|
|
158
|
+
rebuildCost = (written * rate) / 1e6
|
|
159
|
+
s.rebuilds[why].n++
|
|
160
|
+
s.rebuilds[why].cost += rebuildCost
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
s.calls++
|
|
164
|
+
s.cost += cost
|
|
165
|
+
s.readCost += readCost
|
|
166
|
+
s.rebuildCost += rebuildCost
|
|
167
|
+
s.tokensIn += ctx
|
|
168
|
+
s.tokensRead += c.read
|
|
169
|
+
s.peak = Math.max(s.peak, ctx)
|
|
170
|
+
if (ctx > LONG_CONTEXT) s.longCost += cost
|
|
171
|
+
s.first ??= c.ts
|
|
172
|
+
s.last = c.ts
|
|
173
|
+
onCall(c, { ctx, cost, readCost, rebuildCost, why, flag: why ? FLAG[why] : shrank ? FLAG.shrink : FLAG.none })
|
|
174
|
+
if (c.cw1 > 0) ttl = 3600e3
|
|
175
|
+
else if (c.cw5 > 0) ttl = 300e3
|
|
176
|
+
prev = c
|
|
177
|
+
}
|
|
178
|
+
return { ...s, ttl, lastCall: prev }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @typedef {Totals & { id: string, tool: Tool, project: string, branch: string, points: [number, number, number, number][] }} Session
|
|
183
|
+
* points: [time, context, cache read, FLAG] for each reply, for the report's chart
|
|
184
|
+
* @typedef {{ calls: number, usd: number, readUsd: number, rebuildUsd: number }} Money
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read and count session files.
|
|
189
|
+
* @param {string[]} files in the order sessionFiles() gives
|
|
190
|
+
* @param {{ since: number, project?: string, onProgress?: (done: number, total: number) => void }} opts
|
|
191
|
+
* A file that cannot be read (removed meanwhile, no permission, a broken archive) is skipped and listed in `skipped`.
|
|
192
|
+
*/
|
|
193
|
+
export function analyze(files, { since, project = '', onProgress }) {
|
|
194
|
+
const needle = project.toLowerCase()
|
|
195
|
+
/** @type {Set<string>} */
|
|
196
|
+
const seen = new Set()
|
|
197
|
+
/** @type {Map<string, Session>} */
|
|
198
|
+
const sessions = new Map()
|
|
199
|
+
const total = emptyTotals()
|
|
200
|
+
/** @type {Map<string, Money>} */
|
|
201
|
+
const daily = new Map()
|
|
202
|
+
/** @type {Map<string, Money>} */
|
|
203
|
+
const models = new Map()
|
|
204
|
+
/** @type {Map<string, Money>} */
|
|
205
|
+
const buckets = new Map()
|
|
206
|
+
const zero = () => ({ calls: 0, usd: 0, readUsd: 0, rebuildUsd: 0 })
|
|
207
|
+
/**
|
|
208
|
+
* @param {Map<string, Money>} map
|
|
209
|
+
* @param {string} key
|
|
210
|
+
* @param {Step} x
|
|
211
|
+
*/
|
|
212
|
+
const tally = (map, key, x) => {
|
|
213
|
+
const v = map.get(key) ?? zero()
|
|
214
|
+
v.calls++
|
|
215
|
+
v.usd += x.cost
|
|
216
|
+
v.readUsd += x.readCost
|
|
217
|
+
v.rebuildUsd += x.rebuildCost
|
|
218
|
+
map.set(key, v)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @type {{ file: string, error: string }[]} */
|
|
222
|
+
const skipped = []
|
|
223
|
+
|
|
224
|
+
files.forEach((file, i) => {
|
|
225
|
+
onProgress?.(i + 1, files.length)
|
|
226
|
+
const tool = toolOf(file)
|
|
227
|
+
if (!tool) return
|
|
228
|
+
/** @type {import('./tools.js').Read} */
|
|
229
|
+
let read
|
|
230
|
+
try {
|
|
231
|
+
read = TOOLS[tool].read(file, since, seen)
|
|
232
|
+
} catch (err) {
|
|
233
|
+
skipped.push({ file, error: /** @type {Error} */ (err).message })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const { id, sub, cwd, branch, calls } = read
|
|
237
|
+
if (!calls.some((c) => !c.seen)) return
|
|
238
|
+
if (!(/** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool]).projectInPath) && needle) {
|
|
239
|
+
if (!cwd.toLowerCase().includes(needle)) return
|
|
240
|
+
}
|
|
241
|
+
const cur = sessions.get(id) ?? { id, tool, project: '', branch: '', points: [], ...emptyTotals() }
|
|
242
|
+
const s = walk(calls, (c, x) => {
|
|
243
|
+
tally(daily, dayOf(c.ts), x)
|
|
244
|
+
tally(models, c.model, x)
|
|
245
|
+
const label = /** @type {(typeof BUCKETS)[number]} */ (BUCKETS.find(([top]) => x.ctx < top))[1]
|
|
246
|
+
tally(buckets, label, x)
|
|
247
|
+
if (!sub) cur.points.push([c.ts, x.ctx, c.read, x.flag])
|
|
248
|
+
})
|
|
249
|
+
if (cwd && !cur.project) cur.project = path.basename(cwd)
|
|
250
|
+
if (branch && !cur.branch) cur.branch = branch
|
|
251
|
+
sessions.set(id, addTotals(cur, s))
|
|
252
|
+
addTotals(total, s)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return { total, sessions: [...sessions.values()].sort((a, b) => b.cost - a.cost), daily, models, buckets, skipped }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @typedef {ReturnType<typeof analyze>} Analysis
|
|
260
|
+
* @typedef {ReturnType<typeof summarize>} Report
|
|
261
|
+
*/
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The numbers every output shows, as plain data (what --json prints).
|
|
265
|
+
* @param {Analysis} a
|
|
266
|
+
* @param {{ span: string, anon: boolean, version: string, now?: Date }} opts
|
|
267
|
+
*/
|
|
268
|
+
export function summarize(a, { span, anon, version, now = new Date() }) {
|
|
269
|
+
const { total, sessions: list } = a
|
|
270
|
+
|
|
271
|
+
/** @type {Map<string, { project: string, sessions: number, calls: number, cost: number, reread: number }>} */
|
|
272
|
+
const projectMap = new Map()
|
|
273
|
+
for (const s of list) {
|
|
274
|
+
const name = s.project || '(unknown)'
|
|
275
|
+
const p = projectMap.get(name) ?? { project: name, sessions: 0, calls: 0, cost: 0, reread: 0 }
|
|
276
|
+
p.sessions++
|
|
277
|
+
p.calls += s.calls
|
|
278
|
+
p.cost += s.cost
|
|
279
|
+
p.reread += s.readCost + s.rebuildCost
|
|
280
|
+
projectMap.set(name, p)
|
|
281
|
+
}
|
|
282
|
+
const projects = [...projectMap.values()].sort((a, b) => b.cost - a.cost)
|
|
283
|
+
// --anon: one name per project, in order of cost, used everywhere.
|
|
284
|
+
const alias = new Map(projects.map((p, i) => [p.project, anon ? `project ${i + 1}` : p.project]))
|
|
285
|
+
const nameOf = (/** @type {string} */ project) => alias.get(project || '(unknown)') ?? ''
|
|
286
|
+
|
|
287
|
+
const share = pct(total.readCost + total.rebuildCost, total.cost)
|
|
288
|
+
const rb = total.rebuilds
|
|
289
|
+
|
|
290
|
+
/** @type {Map<Tool, { sessions: number, calls: number, cost: number, reread: number }>} */
|
|
291
|
+
const toolMap = new Map()
|
|
292
|
+
for (const s of list) {
|
|
293
|
+
const t = toolMap.get(s.tool) ?? { sessions: 0, calls: 0, cost: 0, reread: 0 }
|
|
294
|
+
t.sessions++
|
|
295
|
+
t.calls += s.calls
|
|
296
|
+
t.cost += s.cost
|
|
297
|
+
t.reread += s.readCost + s.rebuildCost
|
|
298
|
+
toolMap.set(s.tool, t)
|
|
299
|
+
}
|
|
300
|
+
const tools = [...toolMap].sort((x, y) => y[1].cost - x[1].cost)
|
|
301
|
+
const product = productOf([...toolMap.keys()])
|
|
302
|
+
|
|
303
|
+
/** @type {{ day: string, calls: number, usd: number, readUsd: number, rebuildUsd: number }[]} */
|
|
304
|
+
const days = []
|
|
305
|
+
if (a.daily.size && total.first !== undefined && total.last !== undefined) {
|
|
306
|
+
// From the first day with a reply, so a short history does not leave the chart mostly empty.
|
|
307
|
+
const start = new Date(total.first)
|
|
308
|
+
const end = new Date(total.last)
|
|
309
|
+
for (
|
|
310
|
+
let d = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
|
311
|
+
d <= end;
|
|
312
|
+
d.setDate(d.getDate() + 1)
|
|
313
|
+
) {
|
|
314
|
+
const key = dayOf(d.getTime())
|
|
315
|
+
const v = a.daily.get(key) ?? { calls: 0, usd: 0, readUsd: 0, rebuildUsd: 0 }
|
|
316
|
+
days.push({
|
|
317
|
+
day: key,
|
|
318
|
+
calls: v.calls,
|
|
319
|
+
usd: round2(v.usd),
|
|
320
|
+
readUsd: round2(v.readUsd),
|
|
321
|
+
rebuildUsd: round2(v.rebuildUsd),
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const sessionRow = (/** @type {Session} */ s) => ({
|
|
327
|
+
id: s.id.slice(0, 8),
|
|
328
|
+
tool: s.tool,
|
|
329
|
+
project: nameOf(s.project),
|
|
330
|
+
branch: anon ? '' : s.branch,
|
|
331
|
+
started: new Date(s.first ?? 0).toISOString(),
|
|
332
|
+
ended: new Date(s.last ?? 0).toISOString(),
|
|
333
|
+
calls: s.calls,
|
|
334
|
+
peak: s.peak,
|
|
335
|
+
usd: round2(s.cost),
|
|
336
|
+
rereadShare: round1(pct(s.readCost + s.rebuildCost, s.cost)),
|
|
337
|
+
rebuilds: { cold: s.rebuilds.cold.n, model: s.rebuilds.model.n, prefix: s.rebuilds.prefix.n },
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const data = {
|
|
341
|
+
version,
|
|
342
|
+
generatedAt: now.toISOString(),
|
|
343
|
+
span,
|
|
344
|
+
anon,
|
|
345
|
+
product,
|
|
346
|
+
summary: {
|
|
347
|
+
sessions: list.length,
|
|
348
|
+
calls: total.calls,
|
|
349
|
+
rereadShare: round1(share),
|
|
350
|
+
cacheReadShare: round1(pct(total.readCost, total.cost)),
|
|
351
|
+
rebuildShare: round1(pct(total.rebuildCost, total.cost)),
|
|
352
|
+
otherShare: round1(100 - share),
|
|
353
|
+
tokensReadFromCache: round1(pct(total.tokensRead, total.tokensIn)),
|
|
354
|
+
longContextShare: round1(pct(total.longCost, total.cost)),
|
|
355
|
+
apiEquivalentUSD: round2(total.cost),
|
|
356
|
+
rebuilds: /** @type {Record<Reason, { count: number, usd: number, share: number }>} */ (
|
|
357
|
+
Object.fromEntries(
|
|
358
|
+
REASONS.map((w) => [
|
|
359
|
+
w,
|
|
360
|
+
{ count: rb[w].n, usd: round2(rb[w].cost), share: round1(pct(rb[w].cost, total.cost)) },
|
|
361
|
+
]),
|
|
362
|
+
)
|
|
363
|
+
),
|
|
364
|
+
},
|
|
365
|
+
tools: tools.map(([tool, t]) => ({
|
|
366
|
+
tool,
|
|
367
|
+
name: TOOLS[tool].name,
|
|
368
|
+
sessions: t.sessions,
|
|
369
|
+
calls: t.calls,
|
|
370
|
+
usd: round2(t.cost),
|
|
371
|
+
rereadShare: round1(pct(t.reread, t.cost)),
|
|
372
|
+
})),
|
|
373
|
+
daily: days,
|
|
374
|
+
buckets: BUCKETS.flatMap(([, label]) => {
|
|
375
|
+
const v = a.buckets.get(label)
|
|
376
|
+
return v ? [{ label, calls: v.calls, usd: round2(v.usd), readUsd: round2(v.readUsd) }] : []
|
|
377
|
+
}),
|
|
378
|
+
models: [...a.models]
|
|
379
|
+
.sort((x, y) => y[1].usd - x[1].usd)
|
|
380
|
+
.map(([model, v]) => ({
|
|
381
|
+
model,
|
|
382
|
+
calls: v.calls,
|
|
383
|
+
usd: round2(v.usd),
|
|
384
|
+
readUsd: round2(v.readUsd),
|
|
385
|
+
rebuildUsd: round2(v.rebuildUsd),
|
|
386
|
+
})),
|
|
387
|
+
projects: projects.map((p) => ({
|
|
388
|
+
project: nameOf(p.project),
|
|
389
|
+
sessions: p.sessions,
|
|
390
|
+
calls: p.calls,
|
|
391
|
+
usd: round2(p.cost),
|
|
392
|
+
rereadShare: round1(pct(p.reread, p.cost)),
|
|
393
|
+
})),
|
|
394
|
+
sessions: list.map(sessionRow),
|
|
395
|
+
findings: findingsOf(a, [...toolMap.keys()]),
|
|
396
|
+
share: `${share.toFixed(0)}% of my ${product} usage over ${span} was re-reading context. Check yours: npx reread-cli`,
|
|
397
|
+
}
|
|
398
|
+
return data
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The report's data: the costliest sessions, each with its replies as chart points (kept out of --json to keep it
|
|
403
|
+
* small).
|
|
404
|
+
* @param {Analysis} a
|
|
405
|
+
* @param {Report} data summarize(a, ...)
|
|
406
|
+
* @param {number} [max] sessions to keep
|
|
407
|
+
*/
|
|
408
|
+
export function withPoints(a, data, max = 100) {
|
|
409
|
+
return {
|
|
410
|
+
...data,
|
|
411
|
+
sessions: data.sessions.slice(0, max).map((row, i) => ({ ...row, points: thin(a.sessions[i].points) })),
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Keep at most `max` points, always keeping the ones where something happened and the last one.
|
|
417
|
+
* @template {[number, number, number, number]} P
|
|
418
|
+
* @param {P[]} points
|
|
419
|
+
* @param {number} [max]
|
|
420
|
+
*/
|
|
421
|
+
export function thin(points, max = 400) {
|
|
422
|
+
if (points.length <= max) return points
|
|
423
|
+
const step = Math.ceil(points.length / max)
|
|
424
|
+
return points.filter((pt, i) => i % step === 0 || pt[3] !== FLAG.none || i === points.length - 1)
|
|
425
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Reading Antigravity's conversations: ~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript.jsonl for
|
|
3
|
+
// the CLI (agy), ~/.gemini/antigravity/brain/... for the app. Each step the model took is one line; ReRead keeps only
|
|
4
|
+
// its token counts and time. The transcript does not name the model, so replies are priced at the model in
|
|
5
|
+
// Antigravity's settings. The conversation's folder is read from its database file (with Node.js's SQLite reader,
|
|
6
|
+
// where there is one) or from Antigravity's list of recent conversations.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { eachLine } from './files.js'
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('./prices.js').Call} Call */
|
|
14
|
+
|
|
15
|
+
/** The model Antigravity starts with, for when its settings name none. */
|
|
16
|
+
export const DEFAULT_MODEL = 'gemini-3.8-flash'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The folders Antigravity keeps conversations in: the CLI's and the app's.
|
|
20
|
+
* @param {string} [home]
|
|
21
|
+
*/
|
|
22
|
+
export const antigravityDirs = (home = homedir()) => [
|
|
23
|
+
path.join(home, '.gemini', 'antigravity-cli', 'brain'),
|
|
24
|
+
path.join(home, '.gemini', 'antigravity', 'brain'),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a file is an Antigravity conversation's transcript: brain/<id>/.system_generated/logs/transcript.jsonl.
|
|
29
|
+
* @param {string} file
|
|
30
|
+
*/
|
|
31
|
+
export function isAntigravityFile(file) {
|
|
32
|
+
const parts = file.split(path.sep)
|
|
33
|
+
return parts.at(-1) === 'transcript.jsonl' && parts.at(-2) === 'logs' && parts.at(-3) === '.system_generated'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a file is one of Antigravity's own, not a transcript to read (its other logs repeat the transcript).
|
|
38
|
+
* @param {string} file
|
|
39
|
+
*/
|
|
40
|
+
export const isAntigravityOther = (file) => file.split(path.sep).includes('.system_generated')
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A model's display name into the id ReRead prices: 'Gemini 3.8 Flash (High)' into 'gemini-3.8-flash',
|
|
44
|
+
* 'Claude Sonnet 4.6 (Thinking)' into 'claude-sonnet-4-6'.
|
|
45
|
+
* @param {unknown} name
|
|
46
|
+
*/
|
|
47
|
+
export function modelId(name) {
|
|
48
|
+
if (typeof name !== 'string' || !name.trim()) return DEFAULT_MODEL
|
|
49
|
+
const id = name
|
|
50
|
+
.replace(/\(.*?\)/g, '')
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/\s+/g, '-')
|
|
54
|
+
return id.startsWith('claude-') ? id.replace(/\./g, '-') : id
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {Map<string, { model: string, folders: Map<string, string> }>} */
|
|
58
|
+
const bases = new Map()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What one Antigravity folder knows: the model in its settings, and the last folder each conversation ran in.
|
|
62
|
+
* @param {string} base ~/.gemini/antigravity-cli or ~/.gemini/antigravity
|
|
63
|
+
*/
|
|
64
|
+
function baseOf(base) {
|
|
65
|
+
let b = bases.get(base)
|
|
66
|
+
if (b) return b
|
|
67
|
+
b = { model: DEFAULT_MODEL, folders: new Map() }
|
|
68
|
+
try {
|
|
69
|
+
b.model = modelId(JSON.parse(readFileSync(path.join(base, 'settings.json'), 'utf8')).model)
|
|
70
|
+
} catch {
|
|
71
|
+
/* no settings */
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const last = JSON.parse(readFileSync(path.join(base, 'cache', 'last_conversations.json'), 'utf8'))
|
|
75
|
+
for (const [folder, id] of Object.entries(last)) if (typeof id === 'string') b.folders.set(id, folder)
|
|
76
|
+
} catch {
|
|
77
|
+
/* no list */
|
|
78
|
+
}
|
|
79
|
+
bases.set(base, b)
|
|
80
|
+
return b
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FILE_URL = Buffer.from('file:///')
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The first file:// address in a protocol buffer: a string field, its length a varint of 1 or 2 bytes before it.
|
|
87
|
+
* @param {Uint8Array} bytes
|
|
88
|
+
*/
|
|
89
|
+
export function folderFromBytes(bytes) {
|
|
90
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
91
|
+
for (let i = buf.indexOf(FILE_URL); i > 0; i = buf.indexOf(FILE_URL, i + 1)) {
|
|
92
|
+
const lens = [buf[i - 1] < 0x80 ? buf[i - 1] : -1]
|
|
93
|
+
if (i > 1 && buf[i - 2] >= 0x80 && buf[i - 1] < 0x80) lens.push((buf[i - 2] & 0x7f) | (buf[i - 1] << 7))
|
|
94
|
+
for (const len of lens) {
|
|
95
|
+
if (len < FILE_URL.length || i + len > buf.length) continue
|
|
96
|
+
const text = buf.subarray(i, i + len)
|
|
97
|
+
if (text.some((c) => c < 0x20)) continue
|
|
98
|
+
try {
|
|
99
|
+
return fileURLToPath(text.toString('utf8'))
|
|
100
|
+
} catch {
|
|
101
|
+
/* not a file address */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Node.js's own SQLite reader, where this Node.js has one (22.13 and newer), without its experimental warning. */
|
|
109
|
+
const sqlite = (() => {
|
|
110
|
+
const get = /** @type {any} */ (process).getBuiltinModule
|
|
111
|
+
if (typeof get !== 'function') return null
|
|
112
|
+
const warn = process.emitWarning
|
|
113
|
+
try {
|
|
114
|
+
process.emitWarning = /** @type {any} */ (() => {})
|
|
115
|
+
return get('node:sqlite') ?? null
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
} finally {
|
|
119
|
+
process.emitWarning = warn
|
|
120
|
+
}
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The folder a conversation ran in, from the trajectory record in its database file.
|
|
125
|
+
* @param {string} db
|
|
126
|
+
*/
|
|
127
|
+
export function folderFromDb(db) {
|
|
128
|
+
if (!sqlite || !existsSync(db)) return ''
|
|
129
|
+
let conn
|
|
130
|
+
try {
|
|
131
|
+
conn = new sqlite.DatabaseSync(db, { readOnly: true })
|
|
132
|
+
const row = conn.prepare('SELECT data FROM trajectory_metadata_blob LIMIT 1').get()
|
|
133
|
+
return row?.data instanceof Uint8Array ? folderFromBytes(row.data) : ''
|
|
134
|
+
} catch {
|
|
135
|
+
return ''
|
|
136
|
+
} finally {
|
|
137
|
+
conn?.close()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the replies of one Antigravity conversation.
|
|
143
|
+
* @param {string} file .../brain/<id>/.system_generated/logs/transcript.jsonl
|
|
144
|
+
* @param {number} since
|
|
145
|
+
* @param {Set<string>} seen replies already counted in files read before
|
|
146
|
+
* @returns {import('./tools.js').Read}
|
|
147
|
+
*/
|
|
148
|
+
export function readAntigravityFile(file, since, seen) {
|
|
149
|
+
const convo = path.resolve(file, '..', '..', '..')
|
|
150
|
+
const id = path.basename(convo)
|
|
151
|
+
const base = path.dirname(path.dirname(convo))
|
|
152
|
+
const { model, folders } = baseOf(base)
|
|
153
|
+
/** @type {Map<string, Call>} */
|
|
154
|
+
const calls = new Map()
|
|
155
|
+
|
|
156
|
+
const MARK = Buffer.from('"input_tokens"')
|
|
157
|
+
/** @param {Buffer} buf */
|
|
158
|
+
const onLine = (buf) => {
|
|
159
|
+
if (!buf.includes(MARK)) return
|
|
160
|
+
let j
|
|
161
|
+
try {
|
|
162
|
+
j = JSON.parse(buf.toString('utf8'))
|
|
163
|
+
} catch {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const ts = Date.parse(j?.created_at)
|
|
167
|
+
if (!(ts >= since)) return
|
|
168
|
+
const n = (/** @type {unknown} */ v) => (typeof v === 'number' && v > 0 ? v : 0)
|
|
169
|
+
const key = `agy|${id}|${j.step_index}`
|
|
170
|
+
calls.set(key, {
|
|
171
|
+
key,
|
|
172
|
+
ts,
|
|
173
|
+
model,
|
|
174
|
+
input: n(j.input_tokens),
|
|
175
|
+
cw5: 0,
|
|
176
|
+
cw1: 0,
|
|
177
|
+
read: n(j.cache_read_tokens),
|
|
178
|
+
out: n(j.output_tokens),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
eachLine(file, onLine)
|
|
182
|
+
|
|
183
|
+
const list = [...calls.values()].sort((a, b) => a.ts - b.ts)
|
|
184
|
+
for (const c of list) {
|
|
185
|
+
c.seen = seen.has(c.key)
|
|
186
|
+
seen.add(c.key)
|
|
187
|
+
}
|
|
188
|
+
const cwd = list.length ? folderFromDb(path.join(base, 'conversations', `${id}.db`)) || folders.get(id) || '' : ''
|
|
189
|
+
return { id, sub: false, cwd, branch: '', calls: list }
|
|
190
|
+
}
|