reread-cli 0.2.0 → 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/README.md CHANGED
@@ -41,7 +41,7 @@ Every tool with sessions on your machine is read; `--tool claude`, `--tool codex
41
41
  - **By context size**: how much of your usage came from replies with a large context (over 150k is where it adds up).
42
42
  - **Why the cache was lost**: a break longer than its life (5 minutes or 1 hour), a model switch mid-session, or a
43
43
  change near the start of the context (tools, MCP servers, settings).
44
- - **By project and by model**, and by tool when you use both.
44
+ - **By project and by model**, and by tool when you use more than one.
45
45
  - **Sessions**: the 10 costliest, sortable and searchable across all of them, with their git branch. Open one to see
46
46
  its context grow reply by reply, with each rebuild and compaction marked.
47
47
  - **Share**: copy a line of text, or save an image card with no project names on it. Hide names in the whole report
@@ -76,6 +76,14 @@ Then add it to `~/.claude/settings.json`:
76
76
 
77
77
  More in [docs/statusline.md](docs/statusline.md).
78
78
 
79
+ ## In VS Code
80
+
81
+ The [ReRead extension](vscode/README.md) shows the same countdown in the editor's status bar, for the latest Claude Code
82
+ session in your workspace, whether Claude Code runs in the terminal or in the editor. Click it for the report of that
83
+ workspace, in an editor tab. Install it from the
84
+ [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=hamperpick.reread), or from
85
+ [Open VSX](https://open-vsx.org/extension/hamperpick/reread) for Cursor, Antigravity and other editors that use it.
86
+
79
87
  ## Options
80
88
 
81
89
  ```
@@ -132,8 +140,8 @@ A reply counts once, in the session that made it, even when a resumed session co
132
140
  toward the session that started it. "Writing history again" is estimated from token counts: a reply whose cache read
133
141
  fell below half of the previous reply's context while the context did not shrink.
134
142
 
135
- The details, with every rule and threshold: [docs/how-it-counts.md](docs/how-it-counts.md). Neither tool documents its
136
- session format, and either may change it; if ReRead reads yours wrong, please
143
+ The details, with every rule and threshold: [docs/how-it-counts.md](docs/how-it-counts.md). None of the tools documents
144
+ its session format, and any may change it; if ReRead reads yours wrong, please
137
145
  [open an issue](https://github.com/tecyfyai/reread/issues).
138
146
 
139
147
  Want to see what caching saves for a prompt of your own? The
package/bin/reread.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // ReRead: how much of your Claude Code usage was re-reading context.
2
+ // ReRead: how much of your coding agent's usage was re-reading context.
3
3
  import { main } from '../src/cli.js'
4
4
 
5
5
  process.exitCode = await main(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reread-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Token usage and prompt cache analyzer for Claude Code, Codex and Antigravity: how much of your coding agent's usage was re-reading context, and why. Visual report and a cache-countdown status line. Local only; uploads nothing.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/analyze.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Counting: what each reply cost, how much of it was re-reading history, and when the whole history had to be
3
3
  // written again.
4
4
  import path from 'node:path'
5
+ import { isInside } from './files.js'
5
6
  import { findingsOf } from './findings.js'
6
7
  import { dayOf, pct, round1, round2 } from './format.js'
7
8
  import { contextOf, costOf, priceOf } from './prices.js'
@@ -187,10 +188,11 @@ export function walk(calls, onCall = () => {}) {
187
188
  /**
188
189
  * Read and count session files.
189
190
  * @param {string[]} files in the order sessionFiles() gives
190
- * @param {{ since: number, project?: string, onProgress?: (done: number, total: number) => void }} opts
191
+ * @param {{ since: number, project?: string, folders?: string[], onProgress?: (done: number, total: number) => void }} opts
192
+ * folders: only sessions that ran in one of these folders or a folder inside one.
191
193
  * A file that cannot be read (removed meanwhile, no permission, a broken archive) is skipped and listed in `skipped`.
192
194
  */
193
- export function analyze(files, { since, project = '', onProgress }) {
195
+ export function analyze(files, { since, project = '', folders = [], onProgress }) {
194
196
  const needle = project.toLowerCase()
195
197
  /** @type {Set<string>} */
196
198
  const seen = new Set()
@@ -235,9 +237,12 @@ export function analyze(files, { since, project = '', onProgress }) {
235
237
  }
236
238
  const { id, sub, cwd, branch, calls } = read
237
239
  if (!calls.some((c) => !c.seen)) return
238
- if (!(/** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool]).projectInPath) && needle) {
240
+ const info = /** @type {import('./tools.js').ToolInfo} */ (TOOLS[tool])
241
+ if (!info.projectInPath && needle) {
239
242
  if (!cwd.toLowerCase().includes(needle)) return
240
243
  }
244
+ // A file with no folder in it (a subagent's) was already chosen by its path, where the path holds the project.
245
+ if (folders.length && !(cwd ? folders.some((f) => isInside(cwd, f)) : info.projectInPath)) return
241
246
  const cur = sessions.get(id) ?? { id, tool, project: '', branch: '', points: [], ...emptyTotals() }
242
247
  const s = walk(calls, (c, x) => {
243
248
  tally(daily, dayOf(c.ts), x)
package/src/claude.js CHANGED
@@ -2,12 +2,17 @@
2
2
  // Reading Claude Code's session files: ~/.claude/projects/<folder>/<session-id>.jsonl, a subagent's in
3
3
  // <session-id>/subagents/. Each line is one JSON event; ReRead keeps only the token counts, model, time and id of each
4
4
  // reply, plus the folder and git branch of the session. It never keeps or prints what was written in the conversation.
5
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
5
6
  import { homedir } from 'node:os'
6
7
  import path from 'node:path'
7
- import { eachLine } from './files.js'
8
+ import { eachLine, existingDirs, isInside, splitLines } from './files.js'
8
9
 
9
10
  /** @typedef {import('./prices.js').Call} Call */
10
11
 
12
+ const USAGE = Buffer.from('"usage"')
13
+ const CWD = Buffer.from('"cwd"')
14
+ const BRANCH = Buffer.from('"gitBranch"')
15
+
11
16
  /**
12
17
  * The folders Claude Code may keep sessions in, most specific first.
13
18
  * @param {NodeJS.ProcessEnv} [env]
@@ -21,6 +26,92 @@ export function claudeDirs(env = process.env, home = homedir()) {
21
26
  ].filter(Boolean)
22
27
  }
23
28
 
29
+ /**
30
+ * The folder name Claude Code gives a project: its path, with every character that is not a letter or digit as '-'.
31
+ * Different paths can share a name ('a b' and 'a-b'), so a session's own `cwd` is what decides.
32
+ * @param {string} cwd
33
+ */
34
+ export const claudeFolderName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, '-')
35
+
36
+ /**
37
+ * The folder a session ran in, from the first line that names it (near the top of the file), or ''.
38
+ * @param {string} file
39
+ */
40
+ export function cwdOf(file) {
41
+ let fd
42
+ try {
43
+ fd = openSync(file, 'r')
44
+ const buf = Buffer.alloc(256 * 1024)
45
+ const got = readSync(fd, buf, 0, buf.length, 0)
46
+ let cwd = ''
47
+ splitLines(buf.subarray(0, got), (line) => {
48
+ if (cwd || !line.includes(CWD)) return
49
+ try {
50
+ const v = JSON.parse(line.toString('utf8')).cwd
51
+ if (typeof v === 'string') cwd = v
52
+ } catch {
53
+ /* not JSON, or cut off at the end of the buffer */
54
+ }
55
+ })
56
+ return cwd
57
+ } catch {
58
+ return ''
59
+ } finally {
60
+ if (fd !== undefined) closeSync(fd)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The session file written to last, of the sessions that ran in one of these folders or a folder inside one. Subagent
66
+ * files are left out: the status of a session is its main file's.
67
+ * @param {string[]} folders
68
+ * @param {{ dirs?: string[], cwdCache?: Map<string, string> }} [opts] cwdCache: file to cwd, kept between calls
69
+ * @returns {string | null}
70
+ */
71
+ export function latestClaudeSession(folders, { dirs = claudeDirs(), cwdCache = new Map() } = {}) {
72
+ const root = existingDirs(dirs)[0]
73
+ if (!root || !folders.length) return null
74
+ const names = folders.map(claudeFolderName)
75
+ let best = null
76
+ let bestTime = -1
77
+ let entries
78
+ try {
79
+ entries = readdirSync(root, { withFileTypes: true })
80
+ } catch {
81
+ return null
82
+ }
83
+ for (const d of entries) {
84
+ if (!d.isDirectory() || !names.some((n) => d.name === n || d.name.startsWith(`${n}-`))) continue
85
+ const dir = path.join(root, d.name)
86
+ let files
87
+ try {
88
+ files = readdirSync(dir)
89
+ } catch {
90
+ continue
91
+ }
92
+ for (const name of files) {
93
+ if (!name.endsWith('.jsonl')) continue
94
+ const f = path.join(dir, name)
95
+ let t
96
+ try {
97
+ t = statSync(f).mtimeMs
98
+ } catch {
99
+ continue
100
+ }
101
+ if (t <= bestTime) continue
102
+ let cwd = cwdCache.get(f)
103
+ if (cwd === undefined) {
104
+ cwd = cwdOf(f)
105
+ if (cwd) cwdCache.set(f, cwd)
106
+ }
107
+ if (!cwd || !folders.some((folder) => isInside(cwd, folder))) continue
108
+ best = f
109
+ bestTime = t
110
+ }
111
+ }
112
+ return best
113
+ }
114
+
24
115
  /**
25
116
  * 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
117
  * the ids are kept.
@@ -69,10 +160,6 @@ export function collect(calls, call) {
69
160
  else calls.set(call.key, call)
70
161
  }
71
162
 
72
- const USAGE = Buffer.from('"usage"')
73
- const CWD = Buffer.from('"cwd"')
74
- const BRANCH = Buffer.from('"gitBranch"')
75
-
76
163
  /**
77
164
  * Read the replies of one session file. A resumed or forked session copies the replies it carries over into its new
78
165
  * file: those are marked `seen` (walked for the cache, counted once) and every key is added to `seen`.
package/src/files.js CHANGED
@@ -28,6 +28,20 @@ export function filesUnder(dir, keep) {
28
28
  return out
29
29
  }
30
30
 
31
+ /**
32
+ * Whether a path is a folder or inside it. Paths are compared as written, ignoring case on macOS and Windows, whose
33
+ * disks usually do.
34
+ * @param {string} p
35
+ * @param {string} folder
36
+ */
37
+ export function isInside(p, folder) {
38
+ const fold = process.platform === 'darwin' || process.platform === 'win32'
39
+ const a = path.resolve(p)
40
+ const b = path.resolve(folder)
41
+ const [x, y] = fold ? [a.toLowerCase(), b.toLowerCase()] : [a, b]
42
+ return x === y || x.startsWith(y.endsWith(path.sep) ? y : y + path.sep)
43
+ }
44
+
31
45
  /** The folders that exist. */
32
46
  export const existingDirs = (/** @type {string[]} */ dirs) => dirs.filter((d) => existsSync(d))
33
47
 
package/src/report.html CHANGED
@@ -1100,7 +1100,15 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
1100
1100
  ctx.fillStyle = '#f0efec'; ctx.fillText(cmd, W - P - cw - 18, H - 72)
1101
1101
  return c
1102
1102
  }
1103
+ // Inside the VS Code extension the page cannot download; it hands the file to the editor, which asks where to save it.
1104
+ const editor = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null
1103
1105
  function download(name, blob) {
1106
+ if (editor) {
1107
+ const r = new FileReader()
1108
+ r.onload = () => editor.postMessage({ type: 'save', name, data: r.result })
1109
+ r.readAsDataURL(blob)
1110
+ return
1111
+ }
1104
1112
  const url = URL.createObjectURL(blob)
1105
1113
  const a = el('a', { href: url, download: name, style: 'display:none' })
1106
1114
  document.body.append(a); a.click(); a.remove()
@@ -1126,7 +1134,7 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
1126
1134
  $('copy-text').addEventListener('click', async () => say((await copy(shareText())) ? 'Copied.' : 'Could not copy. Select the text above instead.'))
1127
1135
  $('download-image').addEventListener('click', () => {
1128
1136
  const c = drawCard()
1129
- if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); say('Image saved.') } }, 'image/png')
1137
+ if (c.toBlob) c.toBlob((b) => { if (b) { download(`reread-${stamp()}.png`, b); if (!editor) say('Image saved.') } }, 'image/png')
1130
1138
  })
1131
1139
  }
1132
1140
 
@@ -1177,6 +1185,7 @@ footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12
1177
1185
  renderSessions()
1178
1186
  renderFindings()
1179
1187
  renderShare()
1188
+ if (editor) editor.postMessage({ type: 'ready' })
1180
1189
  })()
1181
1190
  </script>
1182
1191
  </body>
package/src/statusline.js CHANGED
@@ -20,9 +20,9 @@ import { tmpdir } from 'node:os'
20
20
  import path from 'node:path'
21
21
  import { walk } from './analyze.js'
22
22
  import { pct, tokens } from './format.js'
23
- import { contextOf, priceOf } from './prices.js'
24
23
  import { callOf, collect } from './claude.js'
25
24
  import { readLines } from './files.js'
25
+ import { contextOf, priceOf } from './prices.js'
26
26
 
27
27
  /** @typedef {import('./prices.js').Call} Call */
28
28
 
@@ -45,25 +45,48 @@ export function statusline(input, { now = Date.now(), color = false, cacheDir =
45
45
  }
46
46
  if (!file || !existsSync(file)) return 'reread: no session yet'
47
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'
48
+ const s = sessionState(file, { cacheDir })
49
+ if (!s) return 'reread: no replies yet'
51
50
 
52
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
53
76
  const ctx = contextOf(s.lastCall)
54
- const left = s.ttl - (now - s.lastCall.ts)
55
77
  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)})`))
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,
64
89
  }
65
- parts.push(`re-read ${pct(s.readCost + s.rebuildCost, s.cost).toFixed(0)}%`)
66
- return parts.join(' · ')
67
90
  }
68
91
 
69
92
  /**
@@ -107,7 +130,7 @@ function readCalls(file, cacheDir) {
107
130
  * A per-user folder for the cache, or null when one cannot be made safely.
108
131
  * @returns {string | null}
109
132
  */
110
- function defaultCacheDir() {
133
+ export function defaultCacheDir() {
111
134
  const uid = typeof process.getuid === 'function' ? process.getuid() : null
112
135
  const dir = path.join(tmpdir(), `reread-${uid ?? 'user'}`)
113
136
  try {
package/src/tools.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { statSync } from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import { antigravityDirs, isAntigravityFile, isAntigravityOther, readAntigravityFile } from './antigravity.js'
7
- import { claudeDirs, readClaudeFile } from './claude.js'
7
+ import { claudeDirs, claudeFolderName, readClaudeFile } from './claude.js'
8
8
  import { codexDirs, isCodexFile, readCodexFile } from './codex.js'
9
9
  import { existingDirs, filesUnder } from './files.js'
10
10
 
@@ -28,6 +28,7 @@ import { existingDirs, filesUnder } from './files.js'
28
28
  * @property {(file: string) => boolean} [ignores] a file in its folders that is not a session
29
29
  * @property {(file: string, since: number, seen: Set<string>) => Read} read
30
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
31
32
  */
32
33
 
33
34
  /** In the order a file is matched: the more specific names first, Claude Code's plain .jsonl last. */
@@ -55,6 +56,7 @@ export const TOOLS = /** @type {const} */ ({
55
56
  owns: (/** @type {string} */ f) => f.endsWith('.jsonl'),
56
57
  read: readClaudeFile,
57
58
  projectInPath: true,
59
+ folderName: claudeFolderName,
58
60
  },
59
61
  })
60
62
  /** @typedef {keyof typeof TOOLS} Tool */
@@ -101,12 +103,19 @@ export function rootsFor(tool, dir) {
101
103
  /**
102
104
  * The session files to read: changed at or after `since`, of the chosen tool, oldest first, so a reply copied into a
103
105
  * 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.
106
+ * and `folders` filter paths here; the other tools name the folder inside, so analyze() filters those.
105
107
  * @param {string[]} roots
106
- * @param {{ since: number, project: string, tool: 'all' | Tool }} filter
108
+ * @param {{ since: number, project: string, tool: 'all' | Tool, folders?: string[] }} filter
107
109
  */
108
- export function sessionFiles(roots, { since, project, tool }) {
110
+ export function sessionFiles(roots, { since, project, tool, folders = [] }) {
109
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
+ }
110
119
  /** @type {[string, number, number][]} */
111
120
  const found = []
112
121
  for (const root of roots) {
@@ -114,6 +123,7 @@ export function sessionFiles(roots, { since, project, tool }) {
114
123
  const t = /** @type {Tool} */ (toolOf(f))
115
124
  if (tool !== 'all' && t !== tool) continue
116
125
  if (needle && INFO[t].projectInPath && !path.relative(root, f).toLowerCase().includes(needle)) continue
126
+ if (!inFolders(t, root, f)) continue
117
127
  let st
118
128
  try {
119
129
  st = statSync(f)