claude-code-session-manager 0.21.2 → 0.21.4
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/bin/cli.cjs +5 -0
- package/dist/assets/{TiptapBody-CepFtp62.js → TiptapBody-CZLSQ6pj.js} +2 -2
- package/dist/assets/cssMode-DfqZGMQs.js +1 -0
- package/dist/assets/{freemarker2-DqQlU_4i.js → freemarker2-XTPYh37h.js} +1 -1
- package/dist/assets/handlebars-DKUF5VyH.js +1 -0
- package/dist/assets/html-uqoqsIeI.js +1 -0
- package/dist/assets/htmlMode-aMTQs1su.js +1 -0
- package/dist/assets/index-BUrrcj7x.js +3525 -0
- package/dist/assets/index-DeQI4oVI.css +32 -0
- package/dist/assets/javascript-BVxRZMds.js +1 -0
- package/dist/assets/{jsonMode-CFEryxme.js → jsonMode-D04xP2s5.js} +4 -4
- package/dist/assets/liquid-BkQHTH2P.js +1 -0
- package/dist/assets/lspLanguageFeatures-By9uLznH.js +4 -0
- package/dist/assets/mdx-Du1IlbjV.js +1 -0
- package/dist/assets/{index-CrE67_1W.css → monaco-editor-BTnBOi8r.css} +1 -32
- package/dist/assets/monaco-editor-BW5C4Iv1.js +908 -0
- package/dist/assets/python-DSlImqXd.js +1 -0
- package/dist/assets/razor-BmUVyvSK.js +1 -0
- package/dist/assets/{tsMode-CNLm8WAZ.js → tsMode-Btj0TTH7.js} +1 -1
- package/dist/assets/typescript-Bzelq9vO.js +1 -0
- package/dist/assets/xml-Whd9EaSd.js +1 -0
- package/dist/assets/yaml-QYf0-IN8.js +1 -0
- package/dist/index.html +4 -2
- package/package.json +1 -1
- package/src/main/__tests__/runVerify.test.cjs +138 -0
- package/src/main/config.cjs +36 -4
- package/src/main/historyAggregator.cjs +400 -149
- package/src/main/index.cjs +8 -0
- package/src/main/ipcSchemas.cjs +42 -13
- package/src/main/kg.cjs +87 -30
- package/src/main/lib/credentials.cjs +7 -0
- package/src/main/lib/e2eStateMachine.cjs +39 -0
- package/src/main/runVerify.cjs +51 -5
- package/src/main/scheduler/prdParser.cjs +16 -1
- package/src/main/scheduler.cjs +171 -13
- package/src/main/transcripts.cjs +141 -19
- package/src/main/usageMatrix.cjs +7 -3
- package/src/main/webRemote.cjs +196 -31
- package/src/preload/api.d.ts +40 -0
- package/src/preload/index.cjs +7 -0
- package/dist/assets/cssMode-8hR_Zezu.js +0 -1
- package/dist/assets/handlebars-Ts2NzFcS.js +0 -1
- package/dist/assets/html-QjLxt2p6.js +0 -1
- package/dist/assets/htmlMode-Dst38sy3.js +0 -1
- package/dist/assets/index-XKsJ4Pk3.js +0 -4431
- package/dist/assets/javascript-CNxLjNGz.js +0 -1
- package/dist/assets/liquid-BBfKLTB_.js +0 -1
- package/dist/assets/lspLanguageFeatures-BNyh7ouG.js +0 -4
- package/dist/assets/mdx-SaTyS1xC.js +0 -1
- package/dist/assets/python-C84TNhMd.js +0 -1
- package/dist/assets/razor-BaVJM3L8.js +0 -1
- package/dist/assets/typescript-BdrDpzPy.js +0 -1
- package/dist/assets/xml-CHJ3Xjjj.js +0 -1
- package/dist/assets/yaml-Cg2-K8t3.js +0 -1
|
@@ -7,8 +7,48 @@ const os = require('node:os');
|
|
|
7
7
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
8
8
|
|
|
9
9
|
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
|
-
const
|
|
10
|
+
const PARSE_BUDGET_MS = 2_000;
|
|
11
11
|
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
const CACHE_MAX = 500;
|
|
13
|
+
|
|
14
|
+
// ── LRU cache ─────────────────────────────────────────────────────────────────
|
|
15
|
+
// Backed by an insertion-order Map: delete+re-insert on access = O(1) LRU.
|
|
16
|
+
class LRUCache {
|
|
17
|
+
constructor(max) {
|
|
18
|
+
this._max = max;
|
|
19
|
+
this._m = new Map();
|
|
20
|
+
}
|
|
21
|
+
get(k) {
|
|
22
|
+
if (!this._m.has(k)) return undefined;
|
|
23
|
+
const v = this._m.get(k);
|
|
24
|
+
this._m.delete(k);
|
|
25
|
+
this._m.set(k, v);
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
set(k, v) {
|
|
29
|
+
this._m.delete(k);
|
|
30
|
+
this._m.set(k, v);
|
|
31
|
+
if (this._m.size > this._max) this._m.delete(this._m.keys().next().value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cache for parseJSONL results.
|
|
37
|
+
* Entry shape: { mtimeMs: number, size: number, readOffset: number, inode: number, result: AggrResult }
|
|
38
|
+
* `size` mirrors stat.size (used for exact-hit comparison).
|
|
39
|
+
* `readOffset` is the byte position of the end of the last complete line
|
|
40
|
+
* (≤ size) — the start position for the next tail-read so we never start
|
|
41
|
+
* mid-line.
|
|
42
|
+
*/
|
|
43
|
+
const aggrCache = new LRUCache(CACHE_MAX);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cache for parseConversationMeta results.
|
|
47
|
+
* Entry shape: { mtimeMs: number, size: number, readOffset: number, inode: number, result: MetaResult }
|
|
48
|
+
*/
|
|
49
|
+
const metaCache = new LRUCache(CACHE_MAX);
|
|
50
|
+
|
|
51
|
+
// ── date helpers ──────────────────────────────────────────────────────────────
|
|
12
52
|
|
|
13
53
|
function decodeCwd(encoded) {
|
|
14
54
|
return '/' + encoded.replace(/-+/g, '/');
|
|
@@ -30,46 +70,37 @@ function subtractDays(dateStr, days) {
|
|
|
30
70
|
return localDate(d);
|
|
31
71
|
}
|
|
32
72
|
|
|
33
|
-
|
|
34
|
-
const acc = {
|
|
35
|
-
promptCount: 0,
|
|
36
|
-
inputTokens: 0,
|
|
37
|
-
outputTokens: 0,
|
|
38
|
-
cacheReadTokens: 0,
|
|
39
|
-
cacheCreationTokens: 0,
|
|
40
|
-
toolCallCount: 0,
|
|
41
|
-
toolBreakdown: {},
|
|
42
|
-
errorCount: 0,
|
|
43
|
-
sessionDate: null,
|
|
44
|
-
skipped: false,
|
|
45
|
-
};
|
|
73
|
+
// ── low-level I/O ─────────────────────────────────────────────────────────────
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let text;
|
|
75
|
+
/** Read bytes [from, to) from filePath and return as a UTF-8 string. */
|
|
76
|
+
async function readSlice(filePath, from, to) {
|
|
77
|
+
const len = to - from;
|
|
78
|
+
if (len <= 0) return '';
|
|
79
|
+
const fh = await fsp.open(filePath, 'r');
|
|
53
80
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
81
|
+
const buf = Buffer.alloc(len);
|
|
82
|
+
const { bytesRead } = await fh.read(buf, 0, len, from);
|
|
83
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
84
|
+
} finally {
|
|
85
|
+
await fh.close();
|
|
57
86
|
}
|
|
87
|
+
}
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
let firstTs = null;
|
|
89
|
+
// ── line scanners ─────────────────────────────────────────────────────────────
|
|
61
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Scan JSONL lines into an aggregate accumulator (mutates acc).
|
|
93
|
+
* Returns the first timestamp seen when captureFirst=true, else null.
|
|
94
|
+
*/
|
|
95
|
+
function scanAggrLines(lines, acc, captureFirst) {
|
|
96
|
+
let firstTs = null;
|
|
62
97
|
for (const raw of lines) {
|
|
63
98
|
const line = raw.trim();
|
|
64
99
|
if (!line) continue;
|
|
65
100
|
let obj;
|
|
66
|
-
try {
|
|
67
|
-
obj = JSON.parse(line);
|
|
68
|
-
} catch {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
101
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
71
102
|
|
|
72
|
-
if (firstTs === null) {
|
|
103
|
+
if (captureFirst && firstTs === null) {
|
|
73
104
|
const ts = obj.ts ?? obj.timestamp;
|
|
74
105
|
if (ts) firstTs = ts;
|
|
75
106
|
}
|
|
@@ -81,8 +112,6 @@ async function parseJSONL(filePath, stat) {
|
|
|
81
112
|
if (usage && typeof usage === 'object') {
|
|
82
113
|
// Claude Code JSONLs use snake_case (matching the Anthropic API). The
|
|
83
114
|
// previous camelCase-only check meant every token count read as 0.
|
|
84
|
-
// Accept both shapes for forward-compat with any future renderer-side
|
|
85
|
-
// emitter (live.ts already normalizes both).
|
|
86
115
|
const inT = usage.input_tokens ?? usage.inputTokens;
|
|
87
116
|
const outT = usage.output_tokens ?? usage.outputTokens;
|
|
88
117
|
const cacheR = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens;
|
|
@@ -111,27 +140,14 @@ async function parseJSONL(filePath, stat) {
|
|
|
111
140
|
acc.errorCount++;
|
|
112
141
|
}
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
acc.sessionDate = firstTs
|
|
117
|
-
? localDate(new Date(firstTs))
|
|
118
|
-
: localDate(new Date(stat.mtimeMs));
|
|
119
|
-
} catch {
|
|
120
|
-
acc.sessionDate = localDate(new Date(stat.mtimeMs));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return acc;
|
|
143
|
+
return firstTs;
|
|
124
144
|
}
|
|
125
145
|
|
|
126
|
-
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (stat.size > MAX_FILE_BYTES) { meta.skipped = true; return meta; }
|
|
132
|
-
let text;
|
|
133
|
-
try { text = await fsp.readFile(filePath, 'utf8'); } catch { return meta; }
|
|
134
|
-
const lines = text.split('\n');
|
|
146
|
+
/**
|
|
147
|
+
* Scan JSONL lines into a conversation-meta accumulator (mutates meta).
|
|
148
|
+
* captureFirst=true: record the first timestamp seen as meta.firstTs.
|
|
149
|
+
*/
|
|
150
|
+
function scanMetaLines(lines, meta, captureFirst) {
|
|
135
151
|
for (const raw of lines) {
|
|
136
152
|
const line = raw.trim();
|
|
137
153
|
if (!line) continue;
|
|
@@ -139,7 +155,7 @@ async function parseConversationMeta(filePath, stat) {
|
|
|
139
155
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
140
156
|
const ts = obj.ts ?? obj.timestamp;
|
|
141
157
|
if (ts) {
|
|
142
|
-
if (meta.firstTs === null) meta.firstTs = ts;
|
|
158
|
+
if (captureFirst && meta.firstTs === null) meta.firstTs = ts;
|
|
143
159
|
meta.lastTs = ts;
|
|
144
160
|
}
|
|
145
161
|
const usage = obj.usage ?? obj.message?.usage;
|
|
@@ -150,109 +166,286 @@ async function parseConversationMeta(filePath, stat) {
|
|
|
150
166
|
if (typeof outT === 'number') meta.outputTokens += outT;
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
|
-
return meta;
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
// ── cached file parsers ───────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a JSONL transcript for history aggregation.
|
|
175
|
+
* Returns { result, cacheHit } where cacheHit=true means no I/O was performed.
|
|
176
|
+
*
|
|
177
|
+
* Cache strategy:
|
|
178
|
+
* same (mtimeMs, size) → exact hit, no I/O
|
|
179
|
+
* size grown, same path → tail-parse new bytes from cached.size, merge
|
|
180
|
+
* otherwise → full reparse (file replaced or truncated)
|
|
181
|
+
*/
|
|
182
|
+
async function parseJSONL(filePath, stat) {
|
|
183
|
+
const emptyAcc = () => ({
|
|
184
|
+
promptCount: 0,
|
|
185
|
+
inputTokens: 0,
|
|
186
|
+
outputTokens: 0,
|
|
187
|
+
cacheReadTokens: 0,
|
|
188
|
+
cacheCreationTokens: 0,
|
|
189
|
+
toolCallCount: 0,
|
|
190
|
+
toolBreakdown: {},
|
|
191
|
+
errorCount: 0,
|
|
192
|
+
sessionDate: null,
|
|
193
|
+
skipped: false,
|
|
194
|
+
});
|
|
162
195
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
196
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
197
|
+
return { result: { ...emptyAcc(), skipped: true }, cacheHit: false };
|
|
198
|
+
}
|
|
166
199
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return { rows: [], partial: false, scannedMs: Date.now() - t0 };
|
|
200
|
+
const cached = aggrCache.get(filePath);
|
|
201
|
+
if (cached) {
|
|
202
|
+
if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
203
|
+
return { result: cached.result, cacheHit: true };
|
|
172
204
|
}
|
|
173
205
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
206
|
+
if (stat.size > cached.size) {
|
|
207
|
+
// Inode change means the file was replaced (e.g. claude --resume
|
|
208
|
+
// compaction). Don't tail-parse a stale byte range into new content.
|
|
209
|
+
if (cached.inode !== undefined && cached.inode !== stat.ino) {
|
|
210
|
+
// fall through to full parse
|
|
211
|
+
} else {
|
|
212
|
+
// Append-only tail parse: read only the new bytes. Use cached.readOffset
|
|
213
|
+
// (the end of the last complete line) as the start so we never begin
|
|
214
|
+
// mid-line. Falls back to cached.size for pre-fix cache entries.
|
|
215
|
+
try {
|
|
216
|
+
const readFrom = cached.readOffset ?? cached.size;
|
|
217
|
+
const tail = await readSlice(filePath, readFrom, stat.size);
|
|
218
|
+
const delta = emptyAcc();
|
|
219
|
+
scanAggrLines(tail.split('\n'), delta, false);
|
|
220
|
+
const prev = cached.result;
|
|
221
|
+
const merged = {
|
|
222
|
+
promptCount: prev.promptCount + delta.promptCount,
|
|
223
|
+
inputTokens: prev.inputTokens + delta.inputTokens,
|
|
224
|
+
outputTokens: prev.outputTokens + delta.outputTokens,
|
|
225
|
+
cacheReadTokens: prev.cacheReadTokens + delta.cacheReadTokens,
|
|
226
|
+
cacheCreationTokens: prev.cacheCreationTokens + delta.cacheCreationTokens,
|
|
227
|
+
toolCallCount: prev.toolCallCount + delta.toolCallCount,
|
|
228
|
+
toolBreakdown: { ...prev.toolBreakdown },
|
|
229
|
+
errorCount: prev.errorCount + delta.errorCount,
|
|
230
|
+
sessionDate: prev.sessionDate, // firstTs doesn't change on appends
|
|
231
|
+
skipped: false,
|
|
232
|
+
};
|
|
233
|
+
for (const [k, v] of Object.entries(delta.toolBreakdown)) {
|
|
234
|
+
merged.toolBreakdown[k] = (merged.toolBreakdown[k] ?? 0) + v;
|
|
235
|
+
}
|
|
236
|
+
// readOffset advances to the last complete newline so the next tail
|
|
237
|
+
// always starts at a line boundary. size stays at stat.size so the
|
|
238
|
+
// exact-hit check works correctly on the next call.
|
|
239
|
+
const lastNl = tail.lastIndexOf('\n');
|
|
240
|
+
const readOffset = lastNl >= 0 ? readFrom + lastNl + 1 : readFrom;
|
|
241
|
+
aggrCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset, inode: stat.ino, result: merged });
|
|
242
|
+
return { result: merged, cacheHit: false };
|
|
243
|
+
} catch {
|
|
244
|
+
// fall through to full parse
|
|
245
|
+
}
|
|
184
246
|
}
|
|
247
|
+
}
|
|
248
|
+
// size shrank, inode changed, or mtime changed → file was replaced; full reparse below
|
|
249
|
+
}
|
|
185
250
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
251
|
+
// Full parse
|
|
252
|
+
let text;
|
|
253
|
+
try { text = await fsp.readFile(filePath, 'utf8'); } catch {
|
|
254
|
+
return { result: emptyAcc(), cacheHit: false };
|
|
255
|
+
}
|
|
189
256
|
|
|
190
|
-
|
|
257
|
+
const acc = emptyAcc();
|
|
258
|
+
const firstTs = scanAggrLines(text.split('\n'), acc, true);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
acc.sessionDate = firstTs
|
|
262
|
+
? localDate(new Date(firstTs))
|
|
263
|
+
: localDate(new Date(stat.mtimeMs));
|
|
264
|
+
} catch {
|
|
265
|
+
acc.sessionDate = localDate(new Date(stat.mtimeMs));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const lastNlFull = text.lastIndexOf('\n');
|
|
269
|
+
const readOffsetFull = lastNlFull >= 0 ? lastNlFull + 1 : 0;
|
|
270
|
+
aggrCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset: readOffsetFull, inode: stat.ino, result: acc });
|
|
271
|
+
return { result: acc, cacheHit: false };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Parse a JSONL transcript for per-conversation metadata.
|
|
276
|
+
* Returns { result, cacheHit } — same caching strategy as parseJSONL.
|
|
277
|
+
*/
|
|
278
|
+
async function parseConversationMeta(filePath, stat) {
|
|
279
|
+
const emptyMeta = () => ({
|
|
280
|
+
firstTs: null,
|
|
281
|
+
lastTs: null,
|
|
282
|
+
inputTokens: 0,
|
|
283
|
+
outputTokens: 0,
|
|
284
|
+
skipped: false,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
288
|
+
return { result: { ...emptyMeta(), skipped: true }, cacheHit: false };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const cached = metaCache.get(filePath);
|
|
292
|
+
if (cached) {
|
|
293
|
+
if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
294
|
+
return { result: cached.result, cacheHit: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (stat.size > cached.size) {
|
|
298
|
+
if (cached.inode !== undefined && cached.inode !== stat.ino) {
|
|
299
|
+
// inode changed → file replaced; fall through to full parse
|
|
300
|
+
} else {
|
|
191
301
|
try {
|
|
192
|
-
|
|
302
|
+
const readFrom = cached.readOffset ?? cached.size;
|
|
303
|
+
const tail = await readSlice(filePath, readFrom, stat.size);
|
|
304
|
+
const delta = emptyMeta();
|
|
305
|
+
scanMetaLines(tail.split('\n'), delta, false);
|
|
306
|
+
const prev = cached.result;
|
|
307
|
+
const merged = {
|
|
308
|
+
firstTs: prev.firstTs, // first timestamp never changes on appends
|
|
309
|
+
lastTs: delta.lastTs ?? prev.lastTs,
|
|
310
|
+
inputTokens: prev.inputTokens + delta.inputTokens,
|
|
311
|
+
outputTokens: prev.outputTokens + delta.outputTokens,
|
|
312
|
+
skipped: false,
|
|
313
|
+
};
|
|
314
|
+
const lastNl = tail.lastIndexOf('\n');
|
|
315
|
+
const readOffset = lastNl >= 0 ? readFrom + lastNl + 1 : readFrom;
|
|
316
|
+
metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset, inode: stat.ino, result: merged });
|
|
317
|
+
return { result: merged, cacheHit: false };
|
|
193
318
|
} catch {
|
|
194
|
-
|
|
319
|
+
// fall through to full parse
|
|
195
320
|
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
196
324
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// always dropped", which combined with the (then-UTC) date bucket to
|
|
203
|
-
// hide a Pacific-time user's most recent activity entirely.
|
|
204
|
-
if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
|
|
205
|
-
|
|
206
|
-
const key = `${sessionDate}|${encodedCwd}`;
|
|
207
|
-
if (!buckets.has(key)) {
|
|
208
|
-
buckets.set(key, {
|
|
209
|
-
date: sessionDate,
|
|
210
|
-
projectCwd: decodeCwd(encodedCwd),
|
|
211
|
-
encodedCwd,
|
|
212
|
-
promptCount: 0,
|
|
213
|
-
inputTokens: 0,
|
|
214
|
-
outputTokens: 0,
|
|
215
|
-
cacheReadTokens: 0,
|
|
216
|
-
cacheCreationTokens: 0,
|
|
217
|
-
toolCallCount: 0,
|
|
218
|
-
toolBreakdown: {},
|
|
219
|
-
sessionCount: 0,
|
|
220
|
-
errorCount: 0,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
325
|
+
// Full parse
|
|
326
|
+
let text;
|
|
327
|
+
try { text = await fsp.readFile(filePath, 'utf8'); } catch {
|
|
328
|
+
return { result: emptyMeta(), cacheHit: false };
|
|
329
|
+
}
|
|
223
330
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
331
|
+
const meta = emptyMeta();
|
|
332
|
+
scanMetaLines(text.split('\n'), meta, true);
|
|
333
|
+
const lastNlMeta = text.lastIndexOf('\n');
|
|
334
|
+
const readOffsetMeta = lastNlMeta >= 0 ? lastNlMeta + 1 : 0;
|
|
335
|
+
metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset: readOffsetMeta, inode: stat.ino, result: meta });
|
|
336
|
+
return { result: meta, cacheHit: false };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── aggregate ─────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async function aggregate(req) {
|
|
342
|
+
const t0 = Date.now();
|
|
343
|
+
const today = localDate(new Date());
|
|
344
|
+
let effectiveTo = req?.toDate ? req.toDate : today;
|
|
345
|
+
if (effectiveTo > today) effectiveTo = today;
|
|
346
|
+
const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
|
|
347
|
+
|
|
348
|
+
const buckets = new Map();
|
|
349
|
+
let truncated = false;
|
|
350
|
+
let skippedLargeFiles = 0;
|
|
351
|
+
let skippedBudgetFiles = 0;
|
|
352
|
+
let parseBudgetSpentMs = 0;
|
|
353
|
+
|
|
354
|
+
let projectDirs;
|
|
355
|
+
try {
|
|
356
|
+
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
357
|
+
} catch {
|
|
358
|
+
return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
outer:
|
|
362
|
+
for (const projEntry of projectDirs) {
|
|
363
|
+
if (!projEntry.isDirectory()) continue;
|
|
364
|
+
const encodedCwd = projEntry.name;
|
|
365
|
+
const projectDir = path.join(PROJECTS_DIR, encodedCwd);
|
|
366
|
+
|
|
367
|
+
let files;
|
|
368
|
+
try {
|
|
369
|
+
files = await fsp.readdir(projectDir, { withFileTypes: true });
|
|
370
|
+
} catch {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const fileEntry of files) {
|
|
375
|
+
if (!fileEntry.name.endsWith('.jsonl')) continue;
|
|
376
|
+
const filePath = path.join(projectDir, fileEntry.name);
|
|
377
|
+
|
|
378
|
+
let stat;
|
|
379
|
+
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
380
|
+
|
|
381
|
+
const t1 = Date.now();
|
|
382
|
+
const { result: parsed, cacheHit } = await parseJSONL(filePath, stat);
|
|
383
|
+
if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
|
|
384
|
+
|
|
385
|
+
if (parsed.skipped) { skippedLargeFiles++; continue; }
|
|
386
|
+
|
|
387
|
+
const { sessionDate } = parsed;
|
|
388
|
+
// Inclusive upper bound — `>=` here previously meant "today's data is
|
|
389
|
+
// always dropped", which combined with the (then-UTC) date bucket to
|
|
390
|
+
// hide a Pacific-time user's most recent activity entirely.
|
|
391
|
+
if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
|
|
392
|
+
|
|
393
|
+
const key = `${sessionDate}|${encodedCwd}`;
|
|
394
|
+
if (!buckets.has(key)) {
|
|
395
|
+
buckets.set(key, {
|
|
396
|
+
date: sessionDate,
|
|
397
|
+
projectCwd: decodeCwd(encodedCwd),
|
|
398
|
+
encodedCwd,
|
|
399
|
+
promptCount: 0,
|
|
400
|
+
inputTokens: 0,
|
|
401
|
+
outputTokens: 0,
|
|
402
|
+
cacheReadTokens: 0,
|
|
403
|
+
cacheCreationTokens: 0,
|
|
404
|
+
toolCallCount: 0,
|
|
405
|
+
toolBreakdown: {},
|
|
406
|
+
sessionCount: 0,
|
|
407
|
+
errorCount: 0,
|
|
408
|
+
});
|
|
236
409
|
}
|
|
237
410
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
411
|
+
const b = buckets.get(key);
|
|
412
|
+
b.promptCount += parsed.promptCount;
|
|
413
|
+
b.inputTokens += parsed.inputTokens;
|
|
414
|
+
b.outputTokens += parsed.outputTokens;
|
|
415
|
+
b.cacheReadTokens += parsed.cacheReadTokens;
|
|
416
|
+
b.cacheCreationTokens += parsed.cacheCreationTokens;
|
|
417
|
+
b.toolCallCount += parsed.toolCallCount;
|
|
418
|
+
for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
|
|
419
|
+
b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
|
|
420
|
+
}
|
|
421
|
+
b.sessionCount++;
|
|
422
|
+
b.errorCount += parsed.errorCount;
|
|
423
|
+
|
|
424
|
+
if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
|
|
425
|
+
skippedBudgetFiles++;
|
|
426
|
+
truncated = true;
|
|
427
|
+
console.warn(
|
|
428
|
+
`[historyAggregator] aggregate: parse budget exhausted after ${parseBudgetSpentMs}ms; ` +
|
|
429
|
+
`at least ${skippedBudgetFiles} file(s) skipped`
|
|
430
|
+
);
|
|
431
|
+
break outer;
|
|
242
432
|
}
|
|
243
433
|
}
|
|
434
|
+
}
|
|
244
435
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
436
|
+
const rows = Array.from(buckets.values()).map((b) => ({
|
|
437
|
+
...b,
|
|
438
|
+
estimatedCostUsd: (b.inputTokens * 3 + b.outputTokens * 15) / 1_000_000,
|
|
439
|
+
}));
|
|
249
440
|
|
|
250
|
-
|
|
441
|
+
rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
|
|
251
442
|
|
|
252
|
-
|
|
253
|
-
|
|
443
|
+
const scannedMs = Date.now() - t0;
|
|
444
|
+
return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles };
|
|
254
445
|
}
|
|
255
446
|
|
|
447
|
+
// ── IPC registration ──────────────────────────────────────────────────────────
|
|
448
|
+
|
|
256
449
|
function registerHistoryAggregatorHandlers() {
|
|
257
450
|
ipcMain.handle('history:aggregate', async (_e, rawReq) => {
|
|
258
451
|
// Wire the historyAggregate schema (previously defined but never used).
|
|
@@ -264,46 +457,104 @@ function registerHistoryAggregatorHandlers() {
|
|
|
264
457
|
return aggregate(req);
|
|
265
458
|
});
|
|
266
459
|
|
|
460
|
+
/** Flat list of all JSONL session files — sessionId, project, mtime, size.
|
|
461
|
+
* Single main-side scan replaces the renderer's serial per-dir IPC loop. */
|
|
462
|
+
ipcMain.handle('history:scan-projects', async () => {
|
|
463
|
+
const t0 = Date.now();
|
|
464
|
+
const sessions = [];
|
|
465
|
+
let projectDirs;
|
|
466
|
+
try {
|
|
467
|
+
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
468
|
+
} catch {
|
|
469
|
+
return { sessions: [], scannedMs: 0 };
|
|
470
|
+
}
|
|
471
|
+
for (const proj of projectDirs) {
|
|
472
|
+
if (!proj.isDirectory()) continue;
|
|
473
|
+
const projectDir = path.join(PROJECTS_DIR, proj.name);
|
|
474
|
+
let files;
|
|
475
|
+
try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
|
|
476
|
+
for (const f of files) {
|
|
477
|
+
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
|
|
478
|
+
const filePath = path.join(projectDir, f.name);
|
|
479
|
+
let stat;
|
|
480
|
+
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
481
|
+
sessions.push({
|
|
482
|
+
sessionId: f.name.replace(/\.jsonl$/, ''),
|
|
483
|
+
projectEncoded: proj.name,
|
|
484
|
+
path: filePath,
|
|
485
|
+
mtimeMs: stat.mtimeMs,
|
|
486
|
+
sizeBytes: stat.size,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
sessions.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
491
|
+
return { sessions, scannedMs: Date.now() - t0 };
|
|
492
|
+
});
|
|
493
|
+
|
|
267
494
|
/** Per-conversation metadata: one row per JSONL with derived duration +
|
|
268
495
|
* token totals. Used by the Overview detailed-stats panel to compute
|
|
269
496
|
* hourly/daily distribution + top-projects. */
|
|
270
497
|
ipcMain.handle('history:list-conversations', async () => {
|
|
271
498
|
const t0 = Date.now();
|
|
272
499
|
const conversations = [];
|
|
500
|
+
let truncated = false;
|
|
501
|
+
let skippedBudgetFiles = 0;
|
|
502
|
+
let parseBudgetSpentMs = 0;
|
|
503
|
+
|
|
273
504
|
let projectEntries;
|
|
274
505
|
try {
|
|
275
506
|
projectEntries = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
276
507
|
} catch {
|
|
277
|
-
return { conversations: [], scannedMs: Date.now() - t0 };
|
|
508
|
+
return { conversations: [], truncated: false, scannedMs: Date.now() - t0 };
|
|
278
509
|
}
|
|
510
|
+
|
|
511
|
+
outer:
|
|
279
512
|
for (const ent of projectEntries) {
|
|
280
513
|
if (!ent.isDirectory()) continue;
|
|
281
514
|
const projectDir = path.join(PROJECTS_DIR, ent.name);
|
|
282
515
|
const projectFolder = '/' + ent.name.replace(/-/g, '/');
|
|
283
516
|
let files;
|
|
284
517
|
try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
|
|
518
|
+
|
|
285
519
|
for (const f of files) {
|
|
286
520
|
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
|
|
287
521
|
const filePath = path.join(projectDir, f.name);
|
|
288
522
|
let stat;
|
|
289
523
|
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
524
|
+
|
|
525
|
+
const t1 = Date.now();
|
|
526
|
+
const { result: meta, cacheHit } = await parseConversationMeta(filePath, stat);
|
|
527
|
+
if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
|
|
528
|
+
|
|
529
|
+
if (!meta.skipped) {
|
|
530
|
+
const firstTs = meta.firstTs || new Date(stat.mtimeMs).toISOString();
|
|
531
|
+
const duration =
|
|
532
|
+
meta.firstTs && meta.lastTs
|
|
533
|
+
? Math.max(0, Date.parse(meta.lastTs) - Date.parse(meta.firstTs))
|
|
534
|
+
: undefined;
|
|
535
|
+
conversations.push({
|
|
536
|
+
timestamp: firstTs,
|
|
537
|
+
projectFolder,
|
|
538
|
+
stats: {
|
|
539
|
+
...(duration !== undefined ? { duration } : {}),
|
|
540
|
+
estimatedTokens: meta.inputTokens + meta.outputTokens,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
|
|
546
|
+
skippedBudgetFiles++;
|
|
547
|
+
truncated = true;
|
|
548
|
+
console.warn(
|
|
549
|
+
`[historyAggregator] list-conversations: parse budget exhausted after ${parseBudgetSpentMs}ms; ` +
|
|
550
|
+
`at least ${skippedBudgetFiles} file(s) skipped`
|
|
551
|
+
);
|
|
552
|
+
break outer;
|
|
553
|
+
}
|
|
304
554
|
}
|
|
305
555
|
}
|
|
306
|
-
|
|
556
|
+
|
|
557
|
+
return { conversations, truncated, scannedMs: Date.now() - t0 };
|
|
307
558
|
});
|
|
308
559
|
}
|
|
309
560
|
|
package/src/main/index.cjs
CHANGED
|
@@ -319,6 +319,14 @@ function createWindow() {
|
|
|
319
319
|
// (and Monaco, and Tiptap) all participate in via the standard DOM
|
|
320
320
|
// selection API, so this single block covers Terminal + Doc Editor + plain
|
|
321
321
|
// text inputs without per-component wiring.
|
|
322
|
+
// Release any stale chokidar watchers that were opened by the previous
|
|
323
|
+
// renderer frame. Fires on Ctrl+R and HMR full-page reloads — the fresh
|
|
324
|
+
// renderer will re-register its own watches, so the old sender's contribution
|
|
325
|
+
// must be unwound first to avoid refcount ratcheting.
|
|
326
|
+
mainWindow.webContents.on('did-start-navigation', () => {
|
|
327
|
+
configMgr.releaseWatchesForSender(mainWindow.webContents.id);
|
|
328
|
+
});
|
|
329
|
+
|
|
322
330
|
mainWindow.webContents.on('context-menu', (_e, params) => {
|
|
323
331
|
const items = [];
|
|
324
332
|
if (params.editFlags.canCopy) items.push({ label: 'Copy', role: 'copy' });
|