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