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/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,165 @@
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 { contextOf, priceOf } from './prices.js'
24
+ import { callOf, collect } from './claude.js'
25
+ import { readLines } from './files.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 calls = readCalls(file, cacheDir)
49
+ const s = walk([...calls.values()].sort((a, b) => a.ts - b.ts))
50
+ if (!s.lastCall) return 'reread: no replies yet'
51
+
52
+ const paint = (/** @type {number} */ code) => (/** @type {string} */ t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
53
+ const ctx = contextOf(s.lastCall)
54
+ const left = s.ttl - (now - s.lastCall.ts)
55
+ const p = priceOf(s.lastCall.model, ctx)
56
+ const parts = [`ctx ${tokens(ctx)}`]
57
+ if (left > 0) {
58
+ const m = Math.floor(left / 60e3)
59
+ const sec = Math.floor((left % 60e3) / 1e3)
60
+ parts.push(paint(left < 60e3 ? 33 : 32)(`cache warm ${m}:${String(sec).padStart(2, '0')}`))
61
+ } else {
62
+ const rewrite = (ctx * (s.ttl > 300e3 ? p.write1h : p.write5m)) / 1e6
63
+ parts.push(paint(31)(`cache cold: next reply rewrites ${tokens(ctx)} (~$${rewrite.toFixed(2)})`))
64
+ }
65
+ parts.push(`re-read ${pct(s.readCost + s.rebuildCost, s.cost).toFixed(0)}%`)
66
+ return parts.join(' · ')
67
+ }
68
+
69
+ /**
70
+ * Every reply in a session file, reading only what was added since the cached state.
71
+ * @param {string} file
72
+ * @param {string | null} cacheDir
73
+ * @returns {Map<string, Call>}
74
+ */
75
+ function readCalls(file, cacheDir) {
76
+ const st = statSync(file)
77
+ const cacheFile = cacheDir
78
+ ? path.join(cacheDir, `${createHash('sha256').update(file).digest('hex').slice(0, 24)}.json`)
79
+ : ''
80
+ /** @type {Map<string, Call>} */
81
+ const calls = new Map()
82
+ let from = 0
83
+
84
+ const cached = cacheFile ? loadCache(cacheFile) : null
85
+ if (cached && cached.file === file && cached.ino === st.ino && cached.offset <= st.size) {
86
+ for (const c of cached.calls) calls.set(c.key, c)
87
+ from = cached.offset
88
+ }
89
+
90
+ /** @param {Buffer} buf */
91
+ const onLine = (buf) => {
92
+ if (!buf.includes(USAGE)) return
93
+ const c = callOf(buf.toString('utf8'), 0)
94
+ if (c) collect(calls, c)
95
+ }
96
+ const { offset, rest } = readLines(file, from, onLine)
97
+
98
+ if (cacheFile && (offset !== from || !cached)) {
99
+ saveCache(cacheFile, { v: CACHE_VERSION, file, ino: st.ino, offset, calls: [...calls.values()] })
100
+ }
101
+ // A last line still being written is counted now but read again next time.
102
+ if (rest.length) onLine(rest)
103
+ return calls
104
+ }
105
+
106
+ /**
107
+ * A per-user folder for the cache, or null when one cannot be made safely.
108
+ * @returns {string | null}
109
+ */
110
+ function defaultCacheDir() {
111
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null
112
+ const dir = path.join(tmpdir(), `reread-${uid ?? 'user'}`)
113
+ try {
114
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
115
+ const st = lstatSync(dir)
116
+ // Someone else's folder, or a link to one, is never used.
117
+ if (!st.isDirectory() || st.isSymbolicLink() || (uid !== null && st.uid !== uid)) return null
118
+ return dir
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {string} file
126
+ * @returns {{ v: number, file: string, ino: number, offset: number, calls: Call[] } | null}
127
+ */
128
+ function loadCache(file) {
129
+ try {
130
+ const c = JSON.parse(readFileSync(file, 'utf8'))
131
+ return c && c.v === CACHE_VERSION && Array.isArray(c.calls) && Number.isInteger(c.offset) ? c : null
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Write the cache through a temp file and a rename, so a reader never sees half a file. Old caches are removed now
139
+ * and then.
140
+ * @param {string} file
141
+ * @param {object} value
142
+ */
143
+ function saveCache(file, value) {
144
+ try {
145
+ const tmp = `${file}.${process.pid}.tmp`
146
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 })
147
+ renameSync(tmp, file)
148
+ if (Math.random() < 0.05) prune(path.dirname(file))
149
+ } catch {
150
+ /* the cache only saves time; the line is right without it */
151
+ }
152
+ }
153
+
154
+ /** @param {string} dir */
155
+ function prune(dir) {
156
+ const cutoff = Date.now() - CACHE_MAX_AGE
157
+ for (const name of readdirSync(dir)) {
158
+ const f = path.join(dir, name)
159
+ try {
160
+ if (statSync(f).mtimeMs < cutoff) unlinkSync(f)
161
+ } catch {
162
+ /* gone already */
163
+ }
164
+ }
165
+ }
package/src/tools.js ADDED
@@ -0,0 +1,127 @@
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, 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
+ */
32
+
33
+ /** In the order a file is matched: the more specific names first, Claude Code's plain .jsonl last. */
34
+ export const TOOLS = /** @type {const} */ ({
35
+ codex: {
36
+ name: 'Codex',
37
+ fresh: '/new in Codex',
38
+ dirs: () => codexDirs(),
39
+ owns: isCodexFile,
40
+ read: readCodexFile,
41
+ },
42
+ antigravity: {
43
+ name: 'Antigravity',
44
+ fresh: 'a new conversation in Antigravity',
45
+ dirs: () => antigravityDirs(),
46
+ owns: isAntigravityFile,
47
+ ignores: isAntigravityOther,
48
+ read: readAntigravityFile,
49
+ },
50
+ claude: {
51
+ name: 'Claude Code',
52
+ fresh: '/clear in Claude Code',
53
+ dirs: () => claudeDirs(),
54
+ firstDirOnly: true,
55
+ owns: (/** @type {string} */ f) => f.endsWith('.jsonl'),
56
+ read: readClaudeFile,
57
+ projectInPath: true,
58
+ },
59
+ })
60
+ /** @typedef {keyof typeof TOOLS} Tool */
61
+
62
+ /** @type {Tool[]} */
63
+ export const TOOL_IDS = /** @type {Tool[]} */ (Object.keys(TOOLS))
64
+
65
+ /** @type {Record<Tool, ToolInfo>} */
66
+ const INFO = TOOLS
67
+
68
+ /** In the order people see them. */
69
+ export const DISPLAY_ORDER = /** @type {Tool[]} */ (['claude', 'codex', 'antigravity'])
70
+
71
+ /**
72
+ * Which tool wrote a session file, or null for a file that is not a session.
73
+ * @param {string} file
74
+ * @returns {Tool | null}
75
+ */
76
+ export function toolOf(file) {
77
+ for (const id of TOOL_IDS) {
78
+ if (INFO[id].owns(file)) return id
79
+ if (INFO[id].ignores?.(file)) return null
80
+ }
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * The session folders to read for the chosen tool, or the one folder given with --dir.
86
+ * @param {'all' | Tool} tool
87
+ * @param {string} [dir]
88
+ * @returns {{ roots: string[], looked: string[] }}
89
+ */
90
+ export function rootsFor(tool, dir) {
91
+ if (dir) return { roots: existingDirs([dir]), looked: [dir] }
92
+ const ids = tool === 'all' ? TOOL_IDS : [tool]
93
+ const looked = ids.flatMap((id) => INFO[id].dirs())
94
+ const roots = ids.flatMap((id) => {
95
+ const found = existingDirs(INFO[id].dirs())
96
+ return INFO[id].firstDirOnly ? found.slice(0, 1) : found
97
+ })
98
+ return { roots, looked }
99
+ }
100
+
101
+ /**
102
+ * The session files to read: changed at or after `since`, of the chosen tool, oldest first, so a reply copied into a
103
+ * resumed session is counted in the session that made it. Where a tool's folder names hold the project, `project`
104
+ * filters paths here; the other tools name the folder inside, so analyze() filters those.
105
+ * @param {string[]} roots
106
+ * @param {{ since: number, project: string, tool: 'all' | Tool }} filter
107
+ */
108
+ export function sessionFiles(roots, { since, project, tool }) {
109
+ const needle = project.toLowerCase()
110
+ /** @type {[string, number, number][]} */
111
+ const found = []
112
+ for (const root of roots) {
113
+ for (const f of filesUnder(root, (p) => toolOf(p) !== null)) {
114
+ const t = /** @type {Tool} */ (toolOf(f))
115
+ if (tool !== 'all' && t !== tool) continue
116
+ if (needle && INFO[t].projectInPath && !path.relative(root, f).toLowerCase().includes(needle)) continue
117
+ let st
118
+ try {
119
+ st = statSync(f)
120
+ } catch {
121
+ continue // gone since the walk
122
+ }
123
+ if (st.mtimeMs >= since) found.push([f, st.birthtimeMs, st.mtimeMs])
124
+ }
125
+ }
126
+ return found.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0])).map(([f]) => f)
127
+ }