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