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/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
+ }