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/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,121 @@
|
|
|
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 { homedir } from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { eachLine } from './files.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./prices.js').Call} Call */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The folders Claude Code may keep sessions in, most specific first.
|
|
13
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
14
|
+
* @param {string} [home]
|
|
15
|
+
*/
|
|
16
|
+
export function claudeDirs(env = process.env, home = homedir()) {
|
|
17
|
+
return [
|
|
18
|
+
env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, 'projects') : '',
|
|
19
|
+
path.join(home, '.claude', 'projects'),
|
|
20
|
+
path.join(home, '.config', 'claude', 'projects'),
|
|
21
|
+
].filter(Boolean)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One line of a session file as a reply, or null when it is not one. Only token counts, the model, the time and
|
|
26
|
+
* the ids are kept.
|
|
27
|
+
* @param {string} line
|
|
28
|
+
* @param {number} since replies before this time are left out
|
|
29
|
+
* @returns {Call | null}
|
|
30
|
+
*/
|
|
31
|
+
export function callOf(line, since) {
|
|
32
|
+
if (!line.includes('"usage"') || !line.includes('"assistant"')) return null
|
|
33
|
+
let j
|
|
34
|
+
try {
|
|
35
|
+
j = JSON.parse(line)
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
const m = j?.message
|
|
40
|
+
const u = m?.usage
|
|
41
|
+
if (j.type !== 'assistant' || !u || typeof u !== 'object' || m.model === '<synthetic>') return null
|
|
42
|
+
const ts = Date.parse(j.timestamp)
|
|
43
|
+
if (!(ts >= since)) return null
|
|
44
|
+
const n = (/** @type {unknown} */ v) => (typeof v === 'number' && v > 0 ? v : 0)
|
|
45
|
+
// Some replies list 1-hour writes in the breakdown while the total says 0; the breakdown is kept.
|
|
46
|
+
const cw = n(u.cache_creation_input_tokens)
|
|
47
|
+
const cw1 = n(u.cache_creation?.ephemeral_1h_input_tokens)
|
|
48
|
+
return {
|
|
49
|
+
key: `${m.id}|${j.requestId ?? ''}`,
|
|
50
|
+
ts,
|
|
51
|
+
model: typeof m.model === 'string' ? m.model : '',
|
|
52
|
+
input: n(u.input_tokens),
|
|
53
|
+
cw5: Math.max(0, cw - cw1),
|
|
54
|
+
cw1,
|
|
55
|
+
read: n(u.cache_read_input_tokens),
|
|
56
|
+
out: n(u.output_tokens),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Add a reply to a map of replies. Claude Code writes a reply once per content block with the same ids, so each is
|
|
62
|
+
* kept once, with the largest output count seen.
|
|
63
|
+
* @param {Map<string, Call>} calls
|
|
64
|
+
* @param {Call} call
|
|
65
|
+
*/
|
|
66
|
+
export function collect(calls, call) {
|
|
67
|
+
const old = calls.get(call.key)
|
|
68
|
+
if (old) old.out = Math.max(old.out, call.out)
|
|
69
|
+
else calls.set(call.key, call)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const USAGE = Buffer.from('"usage"')
|
|
73
|
+
const CWD = Buffer.from('"cwd"')
|
|
74
|
+
const BRANCH = Buffer.from('"gitBranch"')
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the replies of one session file. A resumed or forked session copies the replies it carries over into its new
|
|
78
|
+
* file: those are marked `seen` (walked for the cache, counted once) and every key is added to `seen`.
|
|
79
|
+
* @param {string} file
|
|
80
|
+
* @param {number} since
|
|
81
|
+
* @param {Set<string>} seen the replies already counted in files read before
|
|
82
|
+
* @returns {import('./tools.js').Read}
|
|
83
|
+
*/
|
|
84
|
+
export function readClaudeFile(file, since, seen) {
|
|
85
|
+
/** @type {Map<string, Call>} */
|
|
86
|
+
const calls = new Map()
|
|
87
|
+
let cwd = ''
|
|
88
|
+
let branch = ''
|
|
89
|
+
/** @param {Buffer} buf */
|
|
90
|
+
const onLine = (buf) => {
|
|
91
|
+
if (!cwd && buf.includes(CWD)) {
|
|
92
|
+
try {
|
|
93
|
+
const v = JSON.parse(buf.toString('utf8')).cwd
|
|
94
|
+
if (typeof v === 'string') cwd = v
|
|
95
|
+
} catch {
|
|
96
|
+
/* not JSON */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!branch && buf.includes(BRANCH)) {
|
|
100
|
+
try {
|
|
101
|
+
const b = JSON.parse(buf.toString('utf8')).gitBranch
|
|
102
|
+
if (typeof b === 'string' && b && b !== 'HEAD') branch = b
|
|
103
|
+
} catch {
|
|
104
|
+
/* not JSON */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!buf.includes(USAGE)) return
|
|
108
|
+
const call = callOf(buf.toString('utf8'), since)
|
|
109
|
+
if (call) collect(calls, call)
|
|
110
|
+
}
|
|
111
|
+
eachLine(file, onLine)
|
|
112
|
+
for (const [key, c] of calls) {
|
|
113
|
+
c.seen = seen.has(key)
|
|
114
|
+
seen.add(key)
|
|
115
|
+
}
|
|
116
|
+
// A subagent's file sits in <session>/subagents/; it counts toward that session.
|
|
117
|
+
const parts = file.split(path.sep)
|
|
118
|
+
const sub = parts.length > 2 && parts.at(-2) === 'subagents'
|
|
119
|
+
const id = sub ? parts[parts.length - 3] : path.basename(file, '.jsonl')
|
|
120
|
+
return { id, sub, cwd, branch, calls: [...calls.values()].sort((a, b) => a.ts - b.ts) }
|
|
121
|
+
}
|
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
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Walking folders for session files and reading them line by line, for every tool's reader.
|
|
3
|
+
import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every file under a folder that `keep` accepts. Symbolic links are not followed, so a link loop cannot hang the
|
|
8
|
+
* walk, and a folder that cannot be read is skipped.
|
|
9
|
+
* @param {string} dir
|
|
10
|
+
* @param {(file: string) => boolean} keep
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
export function filesUnder(dir, keep) {
|
|
14
|
+
/** @type {string[]} */
|
|
15
|
+
const out = []
|
|
16
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
17
|
+
let entries
|
|
18
|
+
try {
|
|
19
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
20
|
+
} catch {
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const p = path.join(dir, e.name)
|
|
25
|
+
if (e.isDirectory()) out.push(...filesUnder(p, keep))
|
|
26
|
+
else if (e.isFile() && keep(p)) out.push(p)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The folders that exist. */
|
|
32
|
+
export const existingDirs = (/** @type {string[]} */ dirs) => dirs.filter((d) => existsSync(d))
|
|
33
|
+
|
|
34
|
+
const CHUNK = 1 << 20
|
|
35
|
+
const NEWLINE = 10
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the complete lines of a file from a byte offset, synchronously and in 1 MB chunks. Lines are handed over as
|
|
39
|
+
* Buffers, so a long line that is not wanted is never turned into a string.
|
|
40
|
+
* @param {string} file
|
|
41
|
+
* @param {number} from the byte offset to start at; must be the start of a line
|
|
42
|
+
* @param {(line: Buffer) => void} onLine
|
|
43
|
+
* @returns {{ offset: number, rest: Buffer, size: number }} offset: where the unfinished last line starts; rest: that line
|
|
44
|
+
*/
|
|
45
|
+
export function readLines(file, from, onLine) {
|
|
46
|
+
const fd = openSync(file, 'r')
|
|
47
|
+
try {
|
|
48
|
+
const size = fstatSync(fd).size
|
|
49
|
+
/** @type {Buffer[]} */
|
|
50
|
+
let pieces = []
|
|
51
|
+
let pending = 0
|
|
52
|
+
let pos = from
|
|
53
|
+
while (pos < size) {
|
|
54
|
+
const chunk = Buffer.allocUnsafe(Math.min(CHUNK, size - pos))
|
|
55
|
+
const got = readSync(fd, chunk, 0, chunk.length, pos)
|
|
56
|
+
if (got <= 0) break
|
|
57
|
+
pos += got
|
|
58
|
+
const buf = got < chunk.length ? chunk.subarray(0, got) : chunk
|
|
59
|
+
let start = 0
|
|
60
|
+
let nl
|
|
61
|
+
while ((nl = buf.indexOf(NEWLINE, start)) !== -1) {
|
|
62
|
+
const part = buf.subarray(start, nl)
|
|
63
|
+
onLine(pieces.length ? Buffer.concat([...pieces, part]) : part)
|
|
64
|
+
pieces = []
|
|
65
|
+
pending = 0
|
|
66
|
+
start = nl + 1
|
|
67
|
+
}
|
|
68
|
+
if (start < buf.length) {
|
|
69
|
+
pieces.push(buf.subarray(start))
|
|
70
|
+
pending += buf.length - start
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { offset: pos - pending, rest: Buffer.concat(pieces), size }
|
|
74
|
+
} finally {
|
|
75
|
+
closeSync(fd)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hand each line of a buffer to onLine, the last one too.
|
|
81
|
+
* @param {Buffer} buf
|
|
82
|
+
* @param {(line: Buffer) => void} onLine
|
|
83
|
+
*/
|
|
84
|
+
export function splitLines(buf, onLine) {
|
|
85
|
+
let start = 0
|
|
86
|
+
let nl
|
|
87
|
+
while ((nl = buf.indexOf(NEWLINE, start)) !== -1) {
|
|
88
|
+
onLine(buf.subarray(start, nl))
|
|
89
|
+
start = nl + 1
|
|
90
|
+
}
|
|
91
|
+
if (start < buf.length) onLine(buf.subarray(start))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every line of a file, the last one too even with no newline after it.
|
|
96
|
+
* @param {string} file
|
|
97
|
+
* @param {(line: Buffer) => void} onLine
|
|
98
|
+
*/
|
|
99
|
+
export function eachLine(file, onLine) {
|
|
100
|
+
const { rest } = readLines(file, 0, onLine)
|
|
101
|
+
if (rest.length) onLine(rest)
|
|
102
|
+
}
|