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/args.js ADDED
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ // The command line: parsed strictly, so a typo fails with a message instead of being ignored.
3
+
4
+ import { DISPLAY_ORDER, TOOL_IDS } from './tools.js'
5
+
6
+ /** A mistake in the command line. The CLI prints its message and exits with status 1. */
7
+ export class UsageError extends Error {}
8
+
9
+ /**
10
+ * @typedef {object} Options
11
+ * @property {'report' | 'statusline' | 'help' | 'version'} command
12
+ * @property {number} since only replies at or after this time (ms since 1970); 0 for all
13
+ * @property {string} span the period in words: 'the last 30 days', 'since 2026-10-01', 'all your sessions'
14
+ * @property {string} project only sessions whose project folder contains this text
15
+ * @property {string} dir the sessions folder given with --dir, or ''
16
+ * @property {'all' | import('./tools.js').Tool} tool whose sessions to read
17
+ * @property {'text' | 'json' | 'csv' | 'md'} format
18
+ * @property {'' | 'project' | 'model' | 'day' | 'tool'} by
19
+ * @property {number} top sessions in the summary
20
+ * @property {string | null} html null: decide by the terminal; '': a file in a private temp folder; else this path
21
+ * @property {boolean} text --text: the summary only
22
+ * @property {boolean} open open the report in the browser
23
+ * @property {boolean} anon replace project names and leave branches out
24
+ */
25
+
26
+ const WITH_VALUE = new Set(['--days', '--since', '--project', '--dir', '--by', '--top', '--tool'])
27
+ const SWITCHES = new Set(['--text', '--no-open', '--json', '--csv', '--md', '--anon', '--html'])
28
+
29
+ /**
30
+ * @param {string[]} argv the arguments after the script name
31
+ * @param {number} [now] the current time, for --days
32
+ * @returns {Options}
33
+ */
34
+ export function parseArgs(argv, now = Date.now()) {
35
+ /** @type {Map<string, string>} */
36
+ const values = new Map()
37
+ /** @type {Set<string>} */
38
+ const switches = new Set()
39
+ /** @type {string[]} */
40
+ const positional = []
41
+
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const arg = argv[i]
44
+ if (arg === '-h' || arg === '--help') return { ...defaults(), command: 'help' }
45
+ if (arg === '-v' || arg === '--version') return { ...defaults(), command: 'version' }
46
+ if (!arg.startsWith('-') || arg === '-') {
47
+ positional.push(arg)
48
+ continue
49
+ }
50
+ const eq = arg.indexOf('=')
51
+ const name = eq > 0 ? arg.slice(0, eq) : arg
52
+ const inline = eq > 0 ? arg.slice(eq + 1) : undefined
53
+ if (WITH_VALUE.has(name)) {
54
+ const value = inline ?? argv[i + 1]
55
+ if (value === undefined || (inline === undefined && value.startsWith('--'))) {
56
+ throw new UsageError(`${name} needs a value (see --help)`)
57
+ }
58
+ if (inline === undefined) i++
59
+ values.set(name, value)
60
+ } else if (name === '--html') {
61
+ // --html takes an optional file: --html, --html report.html, --html=report.html
62
+ switches.add(name)
63
+ if (inline !== undefined) values.set(name, inline)
64
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) values.set(name, argv[++i])
65
+ } else if (SWITCHES.has(name) && inline === undefined) {
66
+ switches.add(name)
67
+ } else {
68
+ throw new UsageError(`unknown option ${name} (see --help)`)
69
+ }
70
+ }
71
+
72
+ let command = /** @type {Options['command']} */ ('report')
73
+ if (positional[0] === 'statusline') {
74
+ command = 'statusline'
75
+ positional.shift()
76
+ }
77
+ if (positional.length) throw new UsageError(`unexpected argument ${positional[0]} (see --help)`)
78
+
79
+ const formats = /** @type {const} */ (['json', 'csv', 'md']).filter((f) => switches.has(`--${f}`))
80
+ if (formats.length > 1) throw new UsageError(`choose one of --json, --csv or --md`)
81
+
82
+ const days = values.get('--days')
83
+ const sinceArg = values.get('--since')
84
+ if (days !== undefined && sinceArg !== undefined) throw new UsageError('use --days or --since, not both')
85
+
86
+ let since = 0
87
+ let span = 'all your sessions'
88
+ if (sinceArg !== undefined) {
89
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(sinceArg)
90
+ const date = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null
91
+ if (!m || !date || date.getMonth() !== +m[2] - 1 || date.getDate() !== +m[3]) {
92
+ throw new UsageError('--since takes a date like 2026-10-01')
93
+ }
94
+ since = date.getTime()
95
+ span = `since ${sinceArg}`
96
+ } else {
97
+ const n = days === undefined ? 30 : Number(days)
98
+ if (days !== undefined && (days.trim() === '' || !Number.isFinite(n) || n < 0)) {
99
+ throw new UsageError('--days takes a number of days, such as --days 7')
100
+ }
101
+ if (n > 0) {
102
+ since = now - n * 86400e3
103
+ span = n === 1 ? 'the last day' : `the last ${n} days`
104
+ }
105
+ }
106
+
107
+ const topArg = values.get('--top')
108
+ const top = topArg === undefined ? 10 : Number(topArg)
109
+ if (!Number.isInteger(top) || top < 0) throw new UsageError('--top takes a whole number, such as --top 20')
110
+
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')
114
+ }
115
+
116
+ const tool = /** @type {Options['tool']} */ (values.get('--tool') ?? 'all')
117
+ if (tool !== 'all' && !TOOL_IDS.includes(tool)) {
118
+ throw new UsageError(`--tool takes ${DISPLAY_ORDER.join(', ')} or all`)
119
+ }
120
+
121
+ return {
122
+ command,
123
+ since,
124
+ span,
125
+ project: values.get('--project') ?? '',
126
+ dir: values.get('--dir') ?? '',
127
+ tool,
128
+ format: formats[0] ?? 'text',
129
+ by,
130
+ top,
131
+ html: switches.has('--html') ? (values.get('--html') ?? '') : null,
132
+ text: switches.has('--text'),
133
+ open: !switches.has('--no-open'),
134
+ anon: switches.has('--anon'),
135
+ }
136
+ }
137
+
138
+ /** @returns {Options} */
139
+ function defaults() {
140
+ return {
141
+ command: 'report',
142
+ since: 0,
143
+ span: '',
144
+ project: '',
145
+ dir: '',
146
+ tool: 'all',
147
+ format: 'text',
148
+ by: '',
149
+ top: 10,
150
+ html: null,
151
+ text: false,
152
+ open: true,
153
+ anon: false,
154
+ }
155
+ }
package/src/claude.js ADDED
@@ -0,0 +1,208 @@
1
+ // @ts-check
2
+ // Reading Claude Code's session files: ~/.claude/projects/<folder>/<session-id>.jsonl, a subagent's in
3
+ // <session-id>/subagents/. Each line is one JSON event; ReRead keeps only the token counts, model, time and id of each
4
+ // reply, plus the folder and git branch of the session. It never keeps or prints what was written in the conversation.
5
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import path from 'node:path'
8
+ import { eachLine, existingDirs, isInside, splitLines } from './files.js'
9
+
10
+ /** @typedef {import('./prices.js').Call} Call */
11
+
12
+ const USAGE = Buffer.from('"usage"')
13
+ const CWD = Buffer.from('"cwd"')
14
+ const BRANCH = Buffer.from('"gitBranch"')
15
+
16
+ /**
17
+ * The folders Claude Code may keep sessions in, most specific first.
18
+ * @param {NodeJS.ProcessEnv} [env]
19
+ * @param {string} [home]
20
+ */
21
+ export function claudeDirs(env = process.env, home = homedir()) {
22
+ return [
23
+ env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, 'projects') : '',
24
+ path.join(home, '.claude', 'projects'),
25
+ path.join(home, '.config', 'claude', 'projects'),
26
+ ].filter(Boolean)
27
+ }
28
+
29
+ /**
30
+ * The folder name Claude Code gives a project: its path, with every character that is not a letter or digit as '-'.
31
+ * Different paths can share a name ('a b' and 'a-b'), so a session's own `cwd` is what decides.
32
+ * @param {string} cwd
33
+ */
34
+ export const claudeFolderName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, '-')
35
+
36
+ /**
37
+ * The folder a session ran in, from the first line that names it (near the top of the file), or ''.
38
+ * @param {string} file
39
+ */
40
+ export function cwdOf(file) {
41
+ let fd
42
+ try {
43
+ fd = openSync(file, 'r')
44
+ const buf = Buffer.alloc(256 * 1024)
45
+ const got = readSync(fd, buf, 0, buf.length, 0)
46
+ let cwd = ''
47
+ splitLines(buf.subarray(0, got), (line) => {
48
+ if (cwd || !line.includes(CWD)) return
49
+ try {
50
+ const v = JSON.parse(line.toString('utf8')).cwd
51
+ if (typeof v === 'string') cwd = v
52
+ } catch {
53
+ /* not JSON, or cut off at the end of the buffer */
54
+ }
55
+ })
56
+ return cwd
57
+ } catch {
58
+ return ''
59
+ } finally {
60
+ if (fd !== undefined) closeSync(fd)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The session file written to last, of the sessions that ran in one of these folders or a folder inside one. Subagent
66
+ * files are left out: the status of a session is its main file's.
67
+ * @param {string[]} folders
68
+ * @param {{ dirs?: string[], cwdCache?: Map<string, string> }} [opts] cwdCache: file to cwd, kept between calls
69
+ * @returns {string | null}
70
+ */
71
+ export function latestClaudeSession(folders, { dirs = claudeDirs(), cwdCache = new Map() } = {}) {
72
+ const root = existingDirs(dirs)[0]
73
+ if (!root || !folders.length) return null
74
+ const names = folders.map(claudeFolderName)
75
+ let best = null
76
+ let bestTime = -1
77
+ let entries
78
+ try {
79
+ entries = readdirSync(root, { withFileTypes: true })
80
+ } catch {
81
+ return null
82
+ }
83
+ for (const d of entries) {
84
+ if (!d.isDirectory() || !names.some((n) => d.name === n || d.name.startsWith(`${n}-`))) continue
85
+ const dir = path.join(root, d.name)
86
+ let files
87
+ try {
88
+ files = readdirSync(dir)
89
+ } catch {
90
+ continue
91
+ }
92
+ for (const name of files) {
93
+ if (!name.endsWith('.jsonl')) continue
94
+ const f = path.join(dir, name)
95
+ let t
96
+ try {
97
+ t = statSync(f).mtimeMs
98
+ } catch {
99
+ continue
100
+ }
101
+ if (t <= bestTime) continue
102
+ let cwd = cwdCache.get(f)
103
+ if (cwd === undefined) {
104
+ cwd = cwdOf(f)
105
+ if (cwd) cwdCache.set(f, cwd)
106
+ }
107
+ if (!cwd || !folders.some((folder) => isInside(cwd, folder))) continue
108
+ best = f
109
+ bestTime = t
110
+ }
111
+ }
112
+ return best
113
+ }
114
+
115
+ /**
116
+ * One line of a session file as a reply, or null when it is not one. Only token counts, the model, the time and
117
+ * the ids are kept.
118
+ * @param {string} line
119
+ * @param {number} since replies before this time are left out
120
+ * @returns {Call | null}
121
+ */
122
+ export function callOf(line, since) {
123
+ if (!line.includes('"usage"') || !line.includes('"assistant"')) return null
124
+ let j
125
+ try {
126
+ j = JSON.parse(line)
127
+ } catch {
128
+ return null
129
+ }
130
+ const m = j?.message
131
+ const u = m?.usage
132
+ if (j.type !== 'assistant' || !u || typeof u !== 'object' || m.model === '<synthetic>') return null
133
+ const ts = Date.parse(j.timestamp)
134
+ if (!(ts >= since)) return null
135
+ const n = (/** @type {unknown} */ v) => (typeof v === 'number' && v > 0 ? v : 0)
136
+ // Some replies list 1-hour writes in the breakdown while the total says 0; the breakdown is kept.
137
+ const cw = n(u.cache_creation_input_tokens)
138
+ const cw1 = n(u.cache_creation?.ephemeral_1h_input_tokens)
139
+ return {
140
+ key: `${m.id}|${j.requestId ?? ''}`,
141
+ ts,
142
+ model: typeof m.model === 'string' ? m.model : '',
143
+ input: n(u.input_tokens),
144
+ cw5: Math.max(0, cw - cw1),
145
+ cw1,
146
+ read: n(u.cache_read_input_tokens),
147
+ out: n(u.output_tokens),
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Add a reply to a map of replies. Claude Code writes a reply once per content block with the same ids, so each is
153
+ * kept once, with the largest output count seen.
154
+ * @param {Map<string, Call>} calls
155
+ * @param {Call} call
156
+ */
157
+ export function collect(calls, call) {
158
+ const old = calls.get(call.key)
159
+ if (old) old.out = Math.max(old.out, call.out)
160
+ else calls.set(call.key, call)
161
+ }
162
+
163
+ /**
164
+ * Read the replies of one session file. A resumed or forked session copies the replies it carries over into its new
165
+ * file: those are marked `seen` (walked for the cache, counted once) and every key is added to `seen`.
166
+ * @param {string} file
167
+ * @param {number} since
168
+ * @param {Set<string>} seen the replies already counted in files read before
169
+ * @returns {import('./tools.js').Read}
170
+ */
171
+ export function readClaudeFile(file, since, seen) {
172
+ /** @type {Map<string, Call>} */
173
+ const calls = new Map()
174
+ let cwd = ''
175
+ let branch = ''
176
+ /** @param {Buffer} buf */
177
+ const onLine = (buf) => {
178
+ if (!cwd && buf.includes(CWD)) {
179
+ try {
180
+ const v = JSON.parse(buf.toString('utf8')).cwd
181
+ if (typeof v === 'string') cwd = v
182
+ } catch {
183
+ /* not JSON */
184
+ }
185
+ }
186
+ if (!branch && buf.includes(BRANCH)) {
187
+ try {
188
+ const b = JSON.parse(buf.toString('utf8')).gitBranch
189
+ if (typeof b === 'string' && b && b !== 'HEAD') branch = b
190
+ } catch {
191
+ /* not JSON */
192
+ }
193
+ }
194
+ if (!buf.includes(USAGE)) return
195
+ const call = callOf(buf.toString('utf8'), since)
196
+ if (call) collect(calls, call)
197
+ }
198
+ eachLine(file, onLine)
199
+ for (const [key, c] of calls) {
200
+ c.seen = seen.has(key)
201
+ seen.add(key)
202
+ }
203
+ // A subagent's file sits in <session>/subagents/; it counts toward that session.
204
+ const parts = file.split(path.sep)
205
+ const sub = parts.length > 2 && parts.at(-2) === 'subagents'
206
+ const id = sub ? parts[parts.length - 3] : path.basename(file, '.jsonl')
207
+ return { id, sub, cwd, branch, calls: [...calls.values()].sort((a, b) => a.ts - b.ts) }
208
+ }
package/src/cli.js ADDED
@@ -0,0 +1,127 @@
1
+ // @ts-check
2
+ // The command: parse the options, read the sessions, print the chosen output, write the report.
3
+ import { existsSync } from 'node:fs'
4
+ import { analyze, summarize, withPoints } from './analyze.js'
5
+ import { parseArgs, UsageError } from './args.js'
6
+ import { VERSION } from './meta.js'
7
+ import { renderCsv, renderMarkdown, renderText } from './render.js'
8
+ import { buildReport, openFile, readTemplate, TEMPLATE, writeReport } from './report.js'
9
+ import { rootsFor, sessionFiles } from './tools.js'
10
+ import { statusline } from './statusline.js'
11
+
12
+ export const HELP = `reread ${VERSION}: how much of your coding agent's usage was re-reading context (Claude Code, Codex, Antigravity)
13
+
14
+ Usage
15
+ npx reread-cli [options] summary here, full report in your browser
16
+ reread statusline one line for Claude Code's status line (reads its JSON on stdin)
17
+
18
+ Range
19
+ --days <n> look back this many days (default 30; 0 = every session file still kept)
20
+ --since <date> start on this day instead (YYYY-MM-DD, local time)
21
+ --project <text> only sessions whose project folder contains this text
22
+ --tool <name> claude, codex, antigravity or all (default all: every tool with sessions here)
23
+ --dir <path> read the sessions in this folder only
24
+
25
+ Output
26
+ --text the summary only; no browser report
27
+ --html [file] write the report (default: a new private folder in your temp directory)
28
+ --no-open write the report but do not open it
29
+ --json all the numbers as JSON
30
+ --csv every session as CSV
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
33
+ --top <n> sessions in the summary (default 10)
34
+ --anon replace project names with "project 1", "project 2", ... and leave branches out
35
+
36
+ -v, --version print the version
37
+ -h, --help this help
38
+
39
+ Reads only the token counts in your local session files. Nothing is uploaded.`
40
+
41
+ /**
42
+ * @param {string[]} argv
43
+ * @returns {Promise<number>} the exit status
44
+ */
45
+ export async function main(argv) {
46
+ // A closed pipe (reread --json | head) is not an error.
47
+ process.stdout.on('error', (err) => {
48
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EPIPE') process.exit(0)
49
+ throw err
50
+ })
51
+ try {
52
+ return await run(argv)
53
+ } catch (err) {
54
+ if (err instanceof UsageError) {
55
+ process.stderr.write(`reread: ${err.message}\n`)
56
+ return 1
57
+ }
58
+ const e = /** @type {Error} */ (err)
59
+ process.stderr.write(`reread: ${e.message}\n`)
60
+ if (process.env.REREAD_DEBUG) process.stderr.write(`${e.stack}\n`)
61
+ return 1
62
+ }
63
+ }
64
+
65
+ /** @param {string[]} argv */
66
+ async function run(argv) {
67
+ const opts = parseArgs(argv)
68
+ const print = (/** @type {string} */ s) => process.stdout.write(s)
69
+
70
+ if (opts.command === 'help') return (print(`${HELP}\n`), 0)
71
+ if (opts.command === 'version') return (print(`${VERSION}\n`), 0)
72
+ if (opts.command === 'statusline') {
73
+ let input = ''
74
+ for await (const chunk of process.stdin) input += chunk
75
+ print(`${statusline(input, { color: !process.env.NO_COLOR })}\n`)
76
+ return 0
77
+ }
78
+
79
+ const { roots, looked } = rootsFor(opts.tool, opts.dir)
80
+ if (!roots.length) {
81
+ throw new UsageError(
82
+ `no Claude Code, Codex or Antigravity sessions found. Looked in:\n ${looked.join('\n ')}\nUse --dir to point at the folder.`,
83
+ )
84
+ }
85
+
86
+ const files = sessionFiles(roots, opts)
87
+ const progress = process.stderr.isTTY && opts.format === 'text'
88
+ const a = analyze(files, {
89
+ since: opts.since,
90
+ project: opts.project,
91
+ onProgress: progress ? (n, total) => process.stderr.write(`\rReading ${n} of ${total} session files…`) : undefined,
92
+ })
93
+ if (progress) process.stderr.write('\r\x1b[K')
94
+ if (a.skipped.length) {
95
+ const [first] = a.skipped
96
+ const more = a.skipped.length > 1 ? ` and ${a.skipped.length - 1} more` : ''
97
+ process.stderr.write(`reread: skipped ${first.file} (${first.error})${more}: could not be read\n`)
98
+ }
99
+ if (!a.total.calls) {
100
+ print(`No replies found in ${roots.join(', ')}${opts.since ? ` for ${opts.span}` : ''}.\n`)
101
+ return 0
102
+ }
103
+
104
+ const data = summarize(a, { span: opts.span, anon: opts.anon, version: VERSION })
105
+ if (opts.format === 'json') print(`${JSON.stringify(data, null, 2)}\n`)
106
+ else if (opts.format === 'csv') print(renderCsv(data))
107
+ else if (opts.format === 'md') print(renderMarkdown(data, opts))
108
+ else print(renderText(data, { ...opts, color: !!process.stdout.isTTY && !process.env.NO_COLOR }))
109
+
110
+ // The browser report: on by default for the summary at a terminal, or asked for with --html.
111
+ const auto = opts.format === 'text' && !opts.text && !!process.stdout.isTTY
112
+ if (opts.html === null && !auto) return 0
113
+ if (!existsSync(TEMPLATE)) {
114
+ if (opts.html !== null) throw new Error(`the report template is missing: ${TEMPLATE.pathname}`)
115
+ return 0
116
+ }
117
+ const file = writeReport(buildReport(readTemplate(), withPoints(a, data)), opts.html || undefined)
118
+ // Next to the summary in the terminal; on stderr when stdout is data.
119
+ const note = (/** @type {string} */ s) => (opts.format === 'text' ? print(s) : process.stderr.write(s))
120
+ const dim = (/** @type {string} */ t) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[2m${t}\x1b[0m` : t)
121
+ const remote = process.env.SSH_CONNECTION || process.env.CI
122
+ if (opts.open && !remote) {
123
+ openFile(file)
124
+ note(` ${dim('Full report (opened in your browser):')} ${file}\n\n`)
125
+ } else note(` ${dim('Full report:')} ${file}\n\n`)
126
+ return 0
127
+ }
package/src/codex.js ADDED
@@ -0,0 +1,135 @@
1
+ // @ts-check
2
+ // Reading Codex's session files ("rollouts"): ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, and archived ones in
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.
5
+ import { readFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import path from 'node:path'
8
+ import zlib from 'node:zlib'
9
+ import { eachLine, splitLines } from './files.js'
10
+
11
+ /** @typedef {import('./prices.js').Call} Call */
12
+
13
+ /**
14
+ * The folders Codex keeps sessions in.
15
+ * @param {NodeJS.ProcessEnv} [env]
16
+ * @param {string} [home]
17
+ */
18
+ export function codexDirs(env = process.env, home = homedir()) {
19
+ const base = env.CODEX_HOME || path.join(home, '.codex')
20
+ return [path.join(base, 'sessions'), path.join(base, 'archived_sessions')]
21
+ }
22
+
23
+ /** Whether a file is a Codex session file: rollout-<time>-<id>.jsonl, or .jsonl.zst when archived. */
24
+ export function isCodexFile(/** @type {string} */ file) {
25
+ const name = path.basename(file)
26
+ return name.startsWith('rollout-') && (name.endsWith('.jsonl') || name.endsWith('.jsonl.zst'))
27
+ }
28
+
29
+ /** Whether this Node.js can read compressed (archived) Codex sessions. */
30
+ export const canReadZstd = typeof (/** @type {any} */ (zlib).zstdDecompressSync) === 'function'
31
+
32
+ const MARKS = ['"session_meta"', '"turn_context"', '"token_usage_record"', '"token_count"'].map((m) => Buffer.from(m))
33
+
34
+ /**
35
+ * @param {unknown} v
36
+ * @returns {number}
37
+ */
38
+ const n = (v) => (typeof v === 'number' && v > 0 ? v : 0)
39
+
40
+ /**
41
+ * A usage object into a reply. OpenAI counts cached and cache-written tokens inside input_tokens.
42
+ * @param {any} u
43
+ * @param {string} key
44
+ * @param {number} ts
45
+ * @param {string} model
46
+ * @returns {Call}
47
+ */
48
+ function callFrom(u, key, ts, model) {
49
+ const read = n(u.cached_input_tokens)
50
+ const cw = n(u.cache_write_input_tokens)
51
+ return {
52
+ key,
53
+ ts,
54
+ model,
55
+ input: Math.max(0, n(u.input_tokens) - read - cw),
56
+ cw5: cw,
57
+ cw1: 0,
58
+ read,
59
+ out: n(u.output_tokens),
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Read the replies of one Codex session file.
65
+ *
66
+ * Newer Codex versions write a `token_usage_record` for each model response, with its id. Older ones write only
67
+ * `token_count` events, repeated when nothing changed; there each new running total is one response.
68
+ * @param {string} file
69
+ * @param {number} since
70
+ * @param {Set<string>} seen replies already counted in files read before
71
+ * @returns {import('./tools.js').Read}
72
+ */
73
+ export function readCodexFile(file, since, seen) {
74
+ let id = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(path.basename(file))?.[1] ?? ''
75
+ let cwd = ''
76
+ let branch = ''
77
+ let model = ''
78
+ /** @type {Map<string, Call>} */
79
+ const records = new Map()
80
+ /** @type {Map<string, Call>} */
81
+ const counts = new Map()
82
+ let lastTotal = -1
83
+
84
+ /** @param {Buffer} buf */
85
+ const onLine = (buf) => {
86
+ if (!MARKS.some((m) => buf.includes(m))) return
87
+ let j
88
+ try {
89
+ j = JSON.parse(buf.toString('utf8'))
90
+ } catch {
91
+ return
92
+ }
93
+ const p = j?.payload
94
+ if (!p || typeof p !== 'object') return
95
+ if (j.type === 'session_meta') {
96
+ if (typeof p.id === 'string' && p.id) id = p.id
97
+ if (typeof p.cwd === 'string') cwd = p.cwd
98
+ if (typeof p.git?.branch === 'string') branch = p.git.branch
99
+ return
100
+ }
101
+ if (j.type === 'turn_context') {
102
+ if (typeof p.model === 'string' && p.model) model = p.model
103
+ return
104
+ }
105
+ const ts = Date.parse(j.timestamp)
106
+ if (j.type === 'token_usage_record' && p.usage && typeof p.usage === 'object') {
107
+ 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))
109
+ return
110
+ }
111
+ if (j.type === 'event_msg' && p.type === 'token_count') {
112
+ const total = p.info?.total_token_usage?.total_tokens
113
+ const last = p.info?.last_token_usage
114
+ if (typeof total !== 'number' || total === lastTotal || !last) return
115
+ lastTotal = total
116
+ const key = `codex|${id}|${total}`
117
+ if (ts >= since) counts.set(key, callFrom(last, key, ts, model))
118
+ }
119
+ }
120
+
121
+ if (file.endsWith('.zst')) {
122
+ if (!canReadZstd) return { id, sub: false, cwd, branch, calls: [] }
123
+ const data = /** @type {any} */ (zlib).zstdDecompressSync(readFileSync(file))
124
+ splitLines(data, onLine)
125
+ } else {
126
+ eachLine(file, onLine)
127
+ }
128
+
129
+ const calls = [...(records.size ? records : counts).values()].sort((a, b) => a.ts - b.ts)
130
+ for (const c of calls) {
131
+ c.seen = seen.has(c.key)
132
+ seen.add(c.key)
133
+ }
134
+ return { id, sub: false, cwd, branch, calls }
135
+ }