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 +11 -3
- package/bin/reread.js +1 -1
- package/package.json +1 -1
- package/src/analyze.js +8 -3
- package/src/claude.js +92 -5
- package/src/files.js +14 -0
- package/src/report.html +10 -1
- package/src/statusline.js +39 -16
- package/src/tools.js +14 -4
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
|
|
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).
|
|
136
|
-
session format, and
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reread-cli",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
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)
|