reread-cli 0.0.0-stage → 0.2.1

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