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/report.js ADDED
@@ -0,0 +1,69 @@
1
+ // @ts-check
2
+ // The browser report: one self-contained HTML file. The page's own script reads the numbers from a JSON block.
3
+ import { spawn } from 'node:child_process'
4
+ import { createHash } from 'node:crypto'
5
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ export const TEMPLATE = new URL('./report.html', import.meta.url)
10
+ const PLACEHOLDER = '__REREAD_DATA__'
11
+
12
+ /**
13
+ * Put the data into the report page.
14
+ *
15
+ * The data goes into a `<script type="application/json">` block, with `<`, U+2028 and U+2029 escaped so no string in
16
+ * it can end that block. The page's security policy allows only its own script, by hash, so even a name that got
17
+ * past the escaping could not run as code.
18
+ * @param {string} template the contents of report.html
19
+ * @param {unknown} data
20
+ */
21
+ export function buildReport(template, data) {
22
+ if (!template.includes(PLACEHOLDER)) throw new Error(`the report template has no ${PLACEHOLDER} placeholder`)
23
+ const script = /<script>([\s\S]*?)<\/script>/.exec(template)
24
+ if (!script) throw new Error('the report template has no inline script')
25
+ const hash = createHash('sha256').update(script[1], 'utf8').digest('base64')
26
+ const csp = "script-src 'unsafe-inline'"
27
+ if (!template.includes(csp)) throw new Error(`the report template's security policy has no ${csp}`)
28
+ const json = JSON.stringify(data).replace(
29
+ /[<\u2028\u2029]/g,
30
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
31
+ )
32
+ return template.replace(csp, () => `script-src 'sha256-${hash}'`).replace(PLACEHOLDER, () => json)
33
+ }
34
+
35
+ /**
36
+ * Write the report. With no file, it goes in a new folder in the temp directory that only this user can read, so
37
+ * no other user can read it or put a file in its place.
38
+ * @param {string} html
39
+ * @param {string} [file]
40
+ * @returns {string} the path written
41
+ */
42
+ export function writeReport(html, file) {
43
+ const target = file ? path.resolve(file) : path.join(mkdtempSync(path.join(tmpdir(), 'reread-')), 'report.html')
44
+ writeFileSync(target, html, { mode: 0o600 })
45
+ return target
46
+ }
47
+
48
+ /** The report template shipped with ReRead. */
49
+ export const readTemplate = () => readFileSync(TEMPLATE, 'utf8')
50
+
51
+ /**
52
+ * Open a file in the default browser. No shell is involved, so no character in the path is read as a command.
53
+ * @param {string} file
54
+ */
55
+ export function openFile(file) {
56
+ const [cmd, args] =
57
+ process.platform === 'darwin'
58
+ ? ['open', [file]]
59
+ : process.platform === 'win32'
60
+ ? ['explorer.exe', [file]]
61
+ : ['xdg-open', [file]]
62
+ try {
63
+ spawn(cmd, args, { stdio: 'ignore', detached: true, shell: false })
64
+ .on('error', () => {})
65
+ .unref()
66
+ } catch {
67
+ /* no opener on this system; the path is printed anyway */
68
+ }
69
+ }
@@ -0,0 +1,188 @@
1
+ // @ts-check
2
+ // `reread statusline`: one line for Claude Code's status line. Claude Code runs the command on each redraw and
3
+ // pipes it a JSON object with `transcript_path`, the current session's file.
4
+ //
5
+ // A session file can grow to hundreds of megabytes, so the replies read so far are kept in a small cache file
6
+ // (token counts only) and each run reads only the bytes added since the last one.
7
+ import { createHash } from 'node:crypto'
8
+ import {
9
+ existsSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ renameSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import path from 'node:path'
21
+ import { walk } from './analyze.js'
22
+ import { pct, tokens } from './format.js'
23
+ import { callOf, collect } from './claude.js'
24
+ import { readLines } from './files.js'
25
+ import { contextOf, priceOf } from './prices.js'
26
+
27
+ /** @typedef {import('./prices.js').Call} Call */
28
+
29
+ const CACHE_VERSION = 1
30
+ const CACHE_MAX_AGE = 2 * 86400e3
31
+ const USAGE = Buffer.from('"usage"')
32
+
33
+ /**
34
+ * The status line for a session file.
35
+ * @param {string} input what Claude Code piped in
36
+ * @param {{ now?: number, color?: boolean, cacheDir?: string | null }} [opts] cacheDir null: no cache
37
+ */
38
+ export function statusline(input, { now = Date.now(), color = false, cacheDir = defaultCacheDir() } = {}) {
39
+ let file = ''
40
+ try {
41
+ const v = JSON.parse(input || '{}').transcript_path
42
+ if (typeof v === 'string') file = v
43
+ } catch {
44
+ /* not JSON */
45
+ }
46
+ if (!file || !existsSync(file)) return 'reread: no session yet'
47
+
48
+ const s = sessionState(file, { cacheDir })
49
+ if (!s) return 'reread: no replies yet'
50
+
51
+ const paint = (/** @type {number} */ code) => (/** @type {string} */ t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
52
+ const left = s.warmUntil - now
53
+ const parts = [`ctx ${tokens(s.ctx)}`]
54
+ if (left > 0) parts.push(paint(left < 60e3 ? 33 : 32)(`cache warm ${clock(left)}`))
55
+ else parts.push(paint(31)(`cache cold: next reply rewrites ${tokens(s.ctx)} (~$${s.rewriteUsd.toFixed(2)})`))
56
+ parts.push(`re-read ${s.rereadPct.toFixed(0)}%`)
57
+ return parts.join(' · ')
58
+ }
59
+
60
+ /**
61
+ * Minutes and seconds, as 3:41.
62
+ * @param {number} ms
63
+ */
64
+ export const clock = (ms) => `${Math.floor(ms / 60e3)}:${String(Math.floor((ms % 60e3) / 1e3)).padStart(2, '0')}`
65
+
66
+ /**
67
+ * Where a session stands: the context its last reply sent, until when the cache stays warm, what writing the context
68
+ * to the cache again would cost, and how much of the session so far was re-reading. Null before the first reply.
69
+ * @param {string} file
70
+ * @param {{ cacheDir?: string | null }} [opts] cacheDir null: no cache
71
+ */
72
+ export function sessionState(file, { cacheDir = defaultCacheDir() } = {}) {
73
+ const calls = readCalls(file, cacheDir)
74
+ const s = walk([...calls.values()].sort((a, b) => a.ts - b.ts))
75
+ if (!s.lastCall) return null
76
+ const ctx = contextOf(s.lastCall)
77
+ const p = priceOf(s.lastCall.model, ctx)
78
+ return {
79
+ model: s.lastCall.model,
80
+ ctx,
81
+ lastReply: s.lastCall.ts,
82
+ /** the cache's life: 5 minutes, or 1 hour once the session has written to the 1-hour cache */
83
+ ttl: s.ttl,
84
+ warmUntil: s.lastCall.ts + s.ttl,
85
+ rewriteUsd: (ctx * (s.ttl > 300e3 ? p.write1h : p.write5m)) / 1e6,
86
+ rereadPct: pct(s.readCost + s.rebuildCost, s.cost),
87
+ usd: s.cost,
88
+ replies: calls.size,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Every reply in a session file, reading only what was added since the cached state.
94
+ * @param {string} file
95
+ * @param {string | null} cacheDir
96
+ * @returns {Map<string, Call>}
97
+ */
98
+ function readCalls(file, cacheDir) {
99
+ const st = statSync(file)
100
+ const cacheFile = cacheDir
101
+ ? path.join(cacheDir, `${createHash('sha256').update(file).digest('hex').slice(0, 24)}.json`)
102
+ : ''
103
+ /** @type {Map<string, Call>} */
104
+ const calls = new Map()
105
+ let from = 0
106
+
107
+ const cached = cacheFile ? loadCache(cacheFile) : null
108
+ if (cached && cached.file === file && cached.ino === st.ino && cached.offset <= st.size) {
109
+ for (const c of cached.calls) calls.set(c.key, c)
110
+ from = cached.offset
111
+ }
112
+
113
+ /** @param {Buffer} buf */
114
+ const onLine = (buf) => {
115
+ if (!buf.includes(USAGE)) return
116
+ const c = callOf(buf.toString('utf8'), 0)
117
+ if (c) collect(calls, c)
118
+ }
119
+ const { offset, rest } = readLines(file, from, onLine)
120
+
121
+ if (cacheFile && (offset !== from || !cached)) {
122
+ saveCache(cacheFile, { v: CACHE_VERSION, file, ino: st.ino, offset, calls: [...calls.values()] })
123
+ }
124
+ // A last line still being written is counted now but read again next time.
125
+ if (rest.length) onLine(rest)
126
+ return calls
127
+ }
128
+
129
+ /**
130
+ * A per-user folder for the cache, or null when one cannot be made safely.
131
+ * @returns {string | null}
132
+ */
133
+ export function defaultCacheDir() {
134
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null
135
+ const dir = path.join(tmpdir(), `reread-${uid ?? 'user'}`)
136
+ try {
137
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
138
+ const st = lstatSync(dir)
139
+ // Someone else's folder, or a link to one, is never used.
140
+ if (!st.isDirectory() || st.isSymbolicLink() || (uid !== null && st.uid !== uid)) return null
141
+ return dir
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {string} file
149
+ * @returns {{ v: number, file: string, ino: number, offset: number, calls: Call[] } | null}
150
+ */
151
+ function loadCache(file) {
152
+ try {
153
+ const c = JSON.parse(readFileSync(file, 'utf8'))
154
+ return c && c.v === CACHE_VERSION && Array.isArray(c.calls) && Number.isInteger(c.offset) ? c : null
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Write the cache through a temp file and a rename, so a reader never sees half a file. Old caches are removed now
162
+ * and then.
163
+ * @param {string} file
164
+ * @param {object} value
165
+ */
166
+ function saveCache(file, value) {
167
+ try {
168
+ const tmp = `${file}.${process.pid}.tmp`
169
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 })
170
+ renameSync(tmp, file)
171
+ if (Math.random() < 0.05) prune(path.dirname(file))
172
+ } catch {
173
+ /* the cache only saves time; the line is right without it */
174
+ }
175
+ }
176
+
177
+ /** @param {string} dir */
178
+ function prune(dir) {
179
+ const cutoff = Date.now() - CACHE_MAX_AGE
180
+ for (const name of readdirSync(dir)) {
181
+ const f = path.join(dir, name)
182
+ try {
183
+ if (statSync(f).mtimeMs < cutoff) unlinkSync(f)
184
+ } catch {
185
+ /* gone already */
186
+ }
187
+ }
188
+ }
package/src/tools.js ADDED
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ // The coding agents ReRead reads. Each is one entry here plus a reader in its own file; adding a tool is a new entry and
3
+ // a new reader, and every option, folder list, breakdown and piece of advice picks it up from this list.
4
+ import { statSync } from 'node:fs'
5
+ import path from 'node:path'
6
+ import { antigravityDirs, isAntigravityFile, isAntigravityOther, readAntigravityFile } from './antigravity.js'
7
+ import { claudeDirs, claudeFolderName, readClaudeFile } from './claude.js'
8
+ import { codexDirs, isCodexFile, readCodexFile } from './codex.js'
9
+ import { existingDirs, filesUnder } from './files.js'
10
+
11
+ /**
12
+ * What a reader gives for one session file.
13
+ * @typedef {object} Read
14
+ * @property {string} id the session it belongs to
15
+ * @property {boolean} sub a subagent's file, counted toward its session but not drawn in its chart
16
+ * @property {string} cwd the folder it ran in, or ''
17
+ * @property {string} branch its git branch, or ''
18
+ * @property {import('./prices.js').Call[]} calls its replies, oldest first
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} ToolInfo
23
+ * @property {string} name for people
24
+ * @property {string} fresh how to start fresh in it, for the compaction advice
25
+ * @property {() => string[]} dirs where it keeps sessions
26
+ * @property {boolean} [firstDirOnly] the folders are alternatives: read the first that exists
27
+ * @property {(file: string) => boolean} owns whether a file is one of its sessions
28
+ * @property {(file: string) => boolean} [ignores] a file in its folders that is not a session
29
+ * @property {(file: string, since: number, seen: Set<string>) => Read} read
30
+ * @property {boolean} [projectInPath] its folder names hold the project, so --project filters paths
31
+ * @property {(cwd: string) => string} [folderName] the name of the folder it keeps a project's sessions in
32
+ */
33
+
34
+ /** In the order a file is matched: the more specific names first, Claude Code's plain .jsonl last. */
35
+ export const TOOLS = /** @type {const} */ ({
36
+ codex: {
37
+ name: 'Codex',
38
+ fresh: '/new in Codex',
39
+ dirs: () => codexDirs(),
40
+ owns: isCodexFile,
41
+ read: readCodexFile,
42
+ },
43
+ antigravity: {
44
+ name: 'Antigravity',
45
+ fresh: 'a new conversation in Antigravity',
46
+ dirs: () => antigravityDirs(),
47
+ owns: isAntigravityFile,
48
+ ignores: isAntigravityOther,
49
+ read: readAntigravityFile,
50
+ },
51
+ claude: {
52
+ name: 'Claude Code',
53
+ fresh: '/clear in Claude Code',
54
+ dirs: () => claudeDirs(),
55
+ firstDirOnly: true,
56
+ owns: (/** @type {string} */ f) => f.endsWith('.jsonl'),
57
+ read: readClaudeFile,
58
+ projectInPath: true,
59
+ folderName: claudeFolderName,
60
+ },
61
+ })
62
+ /** @typedef {keyof typeof TOOLS} Tool */
63
+
64
+ /** @type {Tool[]} */
65
+ export const TOOL_IDS = /** @type {Tool[]} */ (Object.keys(TOOLS))
66
+
67
+ /** @type {Record<Tool, ToolInfo>} */
68
+ const INFO = TOOLS
69
+
70
+ /** In the order people see them. */
71
+ export const DISPLAY_ORDER = /** @type {Tool[]} */ (['claude', 'codex', 'antigravity'])
72
+
73
+ /**
74
+ * Which tool wrote a session file, or null for a file that is not a session.
75
+ * @param {string} file
76
+ * @returns {Tool | null}
77
+ */
78
+ export function toolOf(file) {
79
+ for (const id of TOOL_IDS) {
80
+ if (INFO[id].owns(file)) return id
81
+ if (INFO[id].ignores?.(file)) return null
82
+ }
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * The session folders to read for the chosen tool, or the one folder given with --dir.
88
+ * @param {'all' | Tool} tool
89
+ * @param {string} [dir]
90
+ * @returns {{ roots: string[], looked: string[] }}
91
+ */
92
+ export function rootsFor(tool, dir) {
93
+ if (dir) return { roots: existingDirs([dir]), looked: [dir] }
94
+ const ids = tool === 'all' ? TOOL_IDS : [tool]
95
+ const looked = ids.flatMap((id) => INFO[id].dirs())
96
+ const roots = ids.flatMap((id) => {
97
+ const found = existingDirs(INFO[id].dirs())
98
+ return INFO[id].firstDirOnly ? found.slice(0, 1) : found
99
+ })
100
+ return { roots, looked }
101
+ }
102
+
103
+ /**
104
+ * The session files to read: changed at or after `since`, of the chosen tool, oldest first, so a reply copied into a
105
+ * resumed session is counted in the session that made it. Where a tool's folder names hold the project, `project`
106
+ * and `folders` filter paths here; the other tools name the folder inside, so analyze() filters those.
107
+ * @param {string[]} roots
108
+ * @param {{ since: number, project: string, tool: 'all' | Tool, folders?: string[] }} filter
109
+ */
110
+ export function sessionFiles(roots, { since, project, tool, folders = [] }) {
111
+ const needle = project.toLowerCase()
112
+ /** @param {Tool} t @param {string} root @param {string} f */
113
+ const inFolders = (t, root, f) => {
114
+ const name = INFO[t].folderName
115
+ if (!folders.length || !name) return true
116
+ const top = path.relative(root, f).split(path.sep)[0]
117
+ return folders.some((d) => top === name(d) || top.startsWith(`${name(d)}-`))
118
+ }
119
+ /** @type {[string, number, number][]} */
120
+ const found = []
121
+ for (const root of roots) {
122
+ for (const f of filesUnder(root, (p) => toolOf(p) !== null)) {
123
+ const t = /** @type {Tool} */ (toolOf(f))
124
+ if (tool !== 'all' && t !== tool) continue
125
+ if (needle && INFO[t].projectInPath && !path.relative(root, f).toLowerCase().includes(needle)) continue
126
+ if (!inFolders(t, root, f)) continue
127
+ let st
128
+ try {
129
+ st = statSync(f)
130
+ } catch {
131
+ continue // gone since the walk
132
+ }
133
+ if (st.mtimeMs >= since) found.push([f, st.birthtimeMs, st.mtimeMs])
134
+ }
135
+ }
136
+ return found.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0])).map(([f]) => f)
137
+ }