claude-code-session-manager 0.36.0 → 0.37.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/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-DXQDiOUx.js} +58 -58
- package/dist/assets/index--a8m5_ET.js +3496 -0
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +699 -0
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
|
@@ -5,6 +5,7 @@ const fsp = require('node:fs/promises');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
8
|
+
const historyRollup = require('./lib/historyRollup.cjs');
|
|
8
9
|
|
|
9
10
|
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
11
|
const PARSE_BUDGET_MS = 2_000;
|
|
@@ -115,19 +116,23 @@ async function readSlice(filePath, from, to) {
|
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
118
|
* Scan JSONL lines into an aggregate accumulator (mutates acc).
|
|
118
|
-
* Returns
|
|
119
|
+
* Returns { firstTs, lastTs } — firstTs is only captured when
|
|
120
|
+
* captureFirst=true (else null); lastTs is the most recent event timestamp
|
|
121
|
+
* seen in this batch of lines (file order, so simply "last one wins").
|
|
119
122
|
*/
|
|
120
123
|
function scanAggrLines(lines, acc, captureFirst) {
|
|
121
124
|
let firstTs = null;
|
|
125
|
+
let lastTs = null;
|
|
122
126
|
for (const raw of lines) {
|
|
123
127
|
const line = raw.trim();
|
|
124
128
|
if (!line) continue;
|
|
125
129
|
let obj;
|
|
126
130
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
132
|
+
const ts = obj.ts ?? obj.timestamp;
|
|
133
|
+
if (ts) {
|
|
134
|
+
if (captureFirst && firstTs === null) firstTs = ts;
|
|
135
|
+
lastTs = ts;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
const role = obj.role ?? obj.message?.role;
|
|
@@ -178,7 +183,21 @@ function scanAggrLines(lines, acc, captureFirst) {
|
|
|
178
183
|
acc.errorCount++;
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
|
-
return firstTs;
|
|
186
|
+
return { firstTs, lastTs };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Per-file activeMinutes = elapsed wall-clock time between the file's first
|
|
191
|
+
* and last event, clamped to [0, 24h] so a corrupt/out-of-order timestamp
|
|
192
|
+
* pair can't blow up a day's total. O(1).
|
|
193
|
+
*/
|
|
194
|
+
function computeActiveMinutes(firstTs, lastTs) {
|
|
195
|
+
if (!firstTs || !lastTs) return 0;
|
|
196
|
+
const a = Date.parse(firstTs);
|
|
197
|
+
const b = Date.parse(lastTs);
|
|
198
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
|
|
199
|
+
const minutes = (b - a) / 60000;
|
|
200
|
+
return Math.max(0, Math.min(24 * 60, minutes));
|
|
182
201
|
}
|
|
183
202
|
|
|
184
203
|
// ── cached file parsers ───────────────────────────────────────────────────────
|
|
@@ -204,6 +223,8 @@ async function parseJSONL(filePath, stat) {
|
|
|
204
223
|
byModel: {},
|
|
205
224
|
errorCount: 0,
|
|
206
225
|
sessionDate: null,
|
|
226
|
+
firstEventTs: null,
|
|
227
|
+
lastEventTs: null,
|
|
207
228
|
skipped: false,
|
|
208
229
|
});
|
|
209
230
|
|
|
@@ -213,7 +234,11 @@ async function parseJSONL(filePath, stat) {
|
|
|
213
234
|
|
|
214
235
|
const cached = aggrCache.get(filePath);
|
|
215
236
|
if (cached) {
|
|
216
|
-
|
|
237
|
+
// cached.result.lastEventTs !== undefined guards against pre-activeMinutes
|
|
238
|
+
// cache entries (shape: no firstEventTs/lastEventTs fields) — those must
|
|
239
|
+
// fall through to a full reparse so activeMinutes gets backfilled, rather
|
|
240
|
+
// than being served as a stale exact hit.
|
|
241
|
+
if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.result.lastEventTs !== undefined) {
|
|
217
242
|
return { result: cached.result, cacheHit: true };
|
|
218
243
|
}
|
|
219
244
|
|
|
@@ -230,7 +255,7 @@ async function parseJSONL(filePath, stat) {
|
|
|
230
255
|
const readFrom = cached.readOffset ?? cached.size;
|
|
231
256
|
const tail = await readSlice(filePath, readFrom, stat.size);
|
|
232
257
|
const delta = emptyAcc();
|
|
233
|
-
scanAggrLines(tail.split('\n'), delta, false);
|
|
258
|
+
const { lastTs: deltaLastTs } = scanAggrLines(tail.split('\n'), delta, false);
|
|
234
259
|
const prev = cached.result;
|
|
235
260
|
const merged = {
|
|
236
261
|
promptCount: prev.promptCount + delta.promptCount,
|
|
@@ -243,6 +268,8 @@ async function parseJSONL(filePath, stat) {
|
|
|
243
268
|
byModel: {},
|
|
244
269
|
errorCount: prev.errorCount + delta.errorCount,
|
|
245
270
|
sessionDate: prev.sessionDate, // firstTs doesn't change on appends
|
|
271
|
+
firstEventTs: prev.firstEventTs ?? null,
|
|
272
|
+
lastEventTs: deltaLastTs ?? (prev.lastEventTs ?? null),
|
|
246
273
|
skipped: false,
|
|
247
274
|
};
|
|
248
275
|
for (const [k, v] of Object.entries(delta.toolBreakdown)) {
|
|
@@ -282,7 +309,9 @@ async function parseJSONL(filePath, stat) {
|
|
|
282
309
|
}
|
|
283
310
|
|
|
284
311
|
const acc = emptyAcc();
|
|
285
|
-
const firstTs = scanAggrLines(text.split('\n'), acc, true);
|
|
312
|
+
const { firstTs, lastTs } = scanAggrLines(text.split('\n'), acc, true);
|
|
313
|
+
acc.firstEventTs = firstTs;
|
|
314
|
+
acc.lastEventTs = lastTs;
|
|
286
315
|
|
|
287
316
|
try {
|
|
288
317
|
acc.sessionDate = firstTs
|
|
@@ -298,6 +327,296 @@ async function parseJSONL(filePath, stat) {
|
|
|
298
327
|
return { result: acc, cacheHit: false };
|
|
299
328
|
}
|
|
300
329
|
|
|
330
|
+
// ── rollup bridge ─────────────────────────────────────────────────────────────
|
|
331
|
+
// Converts between the in-memory per-(date,project) accumulator bucket shape
|
|
332
|
+
// used by aggregate()/finalizeClosedDays() and the on-disk rollup line shape
|
|
333
|
+
// (one line per (date, projectDir, modelId) — see historyRollup.cjs header).
|
|
334
|
+
|
|
335
|
+
function emptyBucket(sessionDate, encodedCwd) {
|
|
336
|
+
return {
|
|
337
|
+
date: sessionDate,
|
|
338
|
+
projectCwd: decodeCwd(encodedCwd),
|
|
339
|
+
encodedCwd,
|
|
340
|
+
promptCount: 0,
|
|
341
|
+
inputTokens: 0,
|
|
342
|
+
outputTokens: 0,
|
|
343
|
+
cacheReadTokens: 0,
|
|
344
|
+
cacheCreationTokens: 0,
|
|
345
|
+
toolCallCount: 0,
|
|
346
|
+
toolBreakdown: {},
|
|
347
|
+
byModel: {},
|
|
348
|
+
sessionCount: 0,
|
|
349
|
+
errorCount: 0,
|
|
350
|
+
activeMinutes: 0,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Fold one parsed-file result into an accumulator bucket (mutates b). */
|
|
355
|
+
function foldParsedIntoBucket(b, parsed) {
|
|
356
|
+
b.promptCount += parsed.promptCount;
|
|
357
|
+
b.inputTokens += parsed.inputTokens;
|
|
358
|
+
b.outputTokens += parsed.outputTokens;
|
|
359
|
+
b.cacheReadTokens += parsed.cacheReadTokens;
|
|
360
|
+
b.cacheCreationTokens += parsed.cacheCreationTokens;
|
|
361
|
+
b.toolCallCount += parsed.toolCallCount;
|
|
362
|
+
for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
|
|
363
|
+
b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
|
|
364
|
+
}
|
|
365
|
+
for (const [modelId, srcBucket] of Object.entries(parsed.byModel ?? {})) {
|
|
366
|
+
const dst = b.byModel[modelId] ?? (b.byModel[modelId] = {
|
|
367
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
368
|
+
});
|
|
369
|
+
dst.inputTokens += srcBucket.inputTokens;
|
|
370
|
+
dst.outputTokens += srcBucket.outputTokens;
|
|
371
|
+
dst.cacheReadTokens += srcBucket.cacheReadTokens;
|
|
372
|
+
dst.cacheCreationTokens += srcBucket.cacheCreationTokens;
|
|
373
|
+
}
|
|
374
|
+
b.sessionCount++;
|
|
375
|
+
b.errorCount += parsed.errorCount;
|
|
376
|
+
b.activeMinutes += computeActiveMinutes(parsed.firstEventTs, parsed.lastEventTs);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** One accumulator bucket → its rollup lines (one totals line + one per model). */
|
|
380
|
+
function bucketToRollupLines(b) {
|
|
381
|
+
const lines = [{
|
|
382
|
+
date: b.date,
|
|
383
|
+
projectDir: b.encodedCwd,
|
|
384
|
+
modelId: historyRollup.TOTALS_MODEL_ID,
|
|
385
|
+
promptCount: b.promptCount,
|
|
386
|
+
inputTokens: 0,
|
|
387
|
+
outputTokens: 0,
|
|
388
|
+
cacheReadTokens: 0,
|
|
389
|
+
cacheCreationTokens: 0,
|
|
390
|
+
toolCallCount: b.toolCallCount,
|
|
391
|
+
toolBreakdown: b.toolBreakdown,
|
|
392
|
+
errorCount: b.errorCount,
|
|
393
|
+
sessionCount: b.sessionCount,
|
|
394
|
+
activeMinutes: b.activeMinutes,
|
|
395
|
+
v: historyRollup.ROLLUP_VERSION,
|
|
396
|
+
}];
|
|
397
|
+
for (const [modelId, mb] of Object.entries(b.byModel)) {
|
|
398
|
+
lines.push({
|
|
399
|
+
date: b.date,
|
|
400
|
+
projectDir: b.encodedCwd,
|
|
401
|
+
modelId,
|
|
402
|
+
promptCount: 0,
|
|
403
|
+
inputTokens: mb.inputTokens,
|
|
404
|
+
outputTokens: mb.outputTokens,
|
|
405
|
+
cacheReadTokens: mb.cacheReadTokens,
|
|
406
|
+
cacheCreationTokens: mb.cacheCreationTokens,
|
|
407
|
+
toolCallCount: 0,
|
|
408
|
+
toolBreakdown: {},
|
|
409
|
+
errorCount: 0,
|
|
410
|
+
sessionCount: 0,
|
|
411
|
+
activeMinutes: 0,
|
|
412
|
+
v: historyRollup.ROLLUP_VERSION,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return lines;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function finalizedMarkerLine(date) {
|
|
419
|
+
return {
|
|
420
|
+
date,
|
|
421
|
+
projectDir: historyRollup.FINALIZED_PROJECT_ID,
|
|
422
|
+
modelId: historyRollup.FINALIZED_MODEL_ID,
|
|
423
|
+
finalizedAt: Date.now(),
|
|
424
|
+
v: historyRollup.ROLLUP_VERSION,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Reconstruct accumulator-shaped rows from rollup lines, restricted to dates
|
|
430
|
+
* in `finalizedDates` — only finalized days are trusted as complete enough
|
|
431
|
+
* to serve without a live re-scan.
|
|
432
|
+
*/
|
|
433
|
+
function rollupMapToRows(map, finalizedDates) {
|
|
434
|
+
const grouped = new Map();
|
|
435
|
+
for (const bucket of map.values()) {
|
|
436
|
+
if (bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID && bucket.modelId === historyRollup.FINALIZED_MODEL_ID) continue;
|
|
437
|
+
if (!finalizedDates.has(bucket.date)) continue;
|
|
438
|
+
const groupKey = `${bucket.date}|${bucket.projectDir}`;
|
|
439
|
+
let row = grouped.get(groupKey);
|
|
440
|
+
if (!row) {
|
|
441
|
+
row = emptyBucket(bucket.date, bucket.projectDir);
|
|
442
|
+
grouped.set(groupKey, row);
|
|
443
|
+
}
|
|
444
|
+
if (bucket.modelId === historyRollup.TOTALS_MODEL_ID) {
|
|
445
|
+
row.promptCount += bucket.promptCount || 0;
|
|
446
|
+
row.toolCallCount += bucket.toolCallCount || 0;
|
|
447
|
+
row.errorCount += bucket.errorCount || 0;
|
|
448
|
+
row.sessionCount += bucket.sessionCount || 0;
|
|
449
|
+
row.activeMinutes += bucket.activeMinutes || 0;
|
|
450
|
+
for (const [tool, cnt] of Object.entries(bucket.toolBreakdown || {})) {
|
|
451
|
+
row.toolBreakdown[tool] = (row.toolBreakdown[tool] ?? 0) + cnt;
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
const dst = row.byModel[bucket.modelId] ?? (row.byModel[bucket.modelId] = {
|
|
455
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
456
|
+
});
|
|
457
|
+
dst.inputTokens += bucket.inputTokens || 0;
|
|
458
|
+
dst.outputTokens += bucket.outputTokens || 0;
|
|
459
|
+
dst.cacheReadTokens += bucket.cacheReadTokens || 0;
|
|
460
|
+
dst.cacheCreationTokens += bucket.cacheCreationTokens || 0;
|
|
461
|
+
row.inputTokens += bucket.inputTokens || 0;
|
|
462
|
+
row.outputTokens += bucket.outputTokens || 0;
|
|
463
|
+
row.cacheReadTokens += bucket.cacheReadTokens || 0;
|
|
464
|
+
row.cacheCreationTokens += bucket.cacheCreationTokens || 0;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return grouped;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Boot-time (and self-healing) finalize pass: walks ALL transcripts, buckets
|
|
472
|
+
* every day strictly before today, and persists them to the rollup with a
|
|
473
|
+
* `finalizedAt` stamp. Once a day is finalized, aggregate() trusts the rollup
|
|
474
|
+
* for it — never re-touching the underlying .jsonl files — so it survives
|
|
475
|
+
* both process restarts and transcript deletion/retention cleanup.
|
|
476
|
+
*
|
|
477
|
+
* O(total transcript bytes) — bounded by the shared parseJSONL LRU cache.
|
|
478
|
+
*
|
|
479
|
+
* opts.budgetMs (optional): caps the wall-clock time spent walking project
|
|
480
|
+
* directories. Project directories are visited in a stable sorted order and
|
|
481
|
+
* are each read to completion once started (never split mid-directory) so a
|
|
482
|
+
* date's bucket is only ever built from directories that were fully walked.
|
|
483
|
+
* If the budget runs out before every project directory has been visited,
|
|
484
|
+
* NO date is stamped `finalizedAt` this call (a date isn't safe to trust as
|
|
485
|
+
* complete until every directory has contributed) — the caller can invoke
|
|
486
|
+
* finalizeClosedDays() again later and it will redo the (idempotent) walk.
|
|
487
|
+
* Partial results are still persisted as plain (non-finalized) rollup lines
|
|
488
|
+
* so aggregate() benefits from them immediately.
|
|
489
|
+
*
|
|
490
|
+
* opts.dryRun (optional): computes what would be finalized without writing
|
|
491
|
+
* anything to the rollup file.
|
|
492
|
+
*/
|
|
493
|
+
async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
|
|
494
|
+
const deadline = typeof budgetMs === 'number' ? Date.now() + budgetMs : null;
|
|
495
|
+
const today = localDate(new Date());
|
|
496
|
+
|
|
497
|
+
let projectDirs;
|
|
498
|
+
try {
|
|
499
|
+
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
500
|
+
} catch {
|
|
501
|
+
return { finalizedDates: [], partial: false };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
projectDirs = projectDirs
|
|
505
|
+
.filter((e) => e.isDirectory())
|
|
506
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
507
|
+
|
|
508
|
+
const buckets = new Map();
|
|
509
|
+
let partial = false;
|
|
510
|
+
|
|
511
|
+
for (const projEntry of projectDirs) {
|
|
512
|
+
if (deadline !== null && Date.now() >= deadline) {
|
|
513
|
+
partial = true;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const encodedCwd = projEntry.name;
|
|
518
|
+
const projectDir = path.join(PROJECTS_DIR, encodedCwd);
|
|
519
|
+
|
|
520
|
+
let files;
|
|
521
|
+
try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
|
|
522
|
+
|
|
523
|
+
for (const fileEntry of files) {
|
|
524
|
+
if (!fileEntry.name.endsWith('.jsonl')) continue;
|
|
525
|
+
const filePath = path.join(projectDir, fileEntry.name);
|
|
526
|
+
|
|
527
|
+
let stat;
|
|
528
|
+
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
529
|
+
|
|
530
|
+
const { result: parsed } = await parseJSONL(filePath, stat);
|
|
531
|
+
if (parsed.skipped) continue;
|
|
532
|
+
|
|
533
|
+
const { sessionDate } = parsed;
|
|
534
|
+
if (!sessionDate || sessionDate >= today) continue;
|
|
535
|
+
|
|
536
|
+
const key = `${sessionDate}|${encodedCwd}`;
|
|
537
|
+
if (!buckets.has(key)) buckets.set(key, emptyBucket(sessionDate, encodedCwd));
|
|
538
|
+
foldParsedIntoBucket(buckets.get(key), parsed);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const dates = new Set();
|
|
543
|
+
const entries = [];
|
|
544
|
+
for (const b of buckets.values()) {
|
|
545
|
+
dates.add(b.date);
|
|
546
|
+
entries.push(...bucketToRollupLines(b));
|
|
547
|
+
}
|
|
548
|
+
// Only stamp dates as finalized (safe to trust with no live re-scan) when
|
|
549
|
+
// the walk visited every project directory within budget — a partial walk
|
|
550
|
+
// may be missing contributions from directories not yet visited.
|
|
551
|
+
if (!partial) {
|
|
552
|
+
for (const date of dates) entries.push(finalizedMarkerLine(date));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!dryRun) {
|
|
556
|
+
await historyRollup.appendRollupDays(entries);
|
|
557
|
+
}
|
|
558
|
+
return { finalizedDates: partial ? [] : Array.from(dates), partial };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Refresh TODAY's rollup buckets from a live (LRU-warm, cheap) parse of every
|
|
563
|
+
* transcript touched today, and upsert them as provisional lines (v2, no
|
|
564
|
+
* finalizedAt) so the History dashboard's "today" row stays current without
|
|
565
|
+
* the renderer ever triggering a transcript scan itself. Meant to be called
|
|
566
|
+
* on a timer (HISTORY_INTRADAY_REFRESH_MS) plus once at app start.
|
|
567
|
+
*
|
|
568
|
+
* readRollup/appendRollupDays last-write-wins per (date, projectDir, modelId)
|
|
569
|
+
* key, so re-running this simply replaces today's previous provisional lines
|
|
570
|
+
* with fresher ones — no separate "clear provisional" step needed.
|
|
571
|
+
*/
|
|
572
|
+
async function refreshIntradayToday() {
|
|
573
|
+
const today = localDate(new Date());
|
|
574
|
+
|
|
575
|
+
let projectDirs;
|
|
576
|
+
try {
|
|
577
|
+
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
578
|
+
} catch {
|
|
579
|
+
return { date: today, projectsUpdated: 0 };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const buckets = new Map();
|
|
583
|
+
for (const projEntry of projectDirs) {
|
|
584
|
+
if (!projEntry.isDirectory()) continue;
|
|
585
|
+
const encodedCwd = projEntry.name;
|
|
586
|
+
const projectDir = path.join(PROJECTS_DIR, encodedCwd);
|
|
587
|
+
|
|
588
|
+
let files;
|
|
589
|
+
try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
|
|
590
|
+
|
|
591
|
+
for (const fileEntry of files) {
|
|
592
|
+
if (!fileEntry.name.endsWith('.jsonl')) continue;
|
|
593
|
+
const filePath = path.join(projectDir, fileEntry.name);
|
|
594
|
+
|
|
595
|
+
let stat;
|
|
596
|
+
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
597
|
+
|
|
598
|
+
// A file whose mtime is before today can't have been touched today —
|
|
599
|
+
// skip it without even reading it (matches aggregate()'s fast-skip).
|
|
600
|
+
const mtimeDate = localDate(new Date(stat.mtimeMs));
|
|
601
|
+
if (mtimeDate < today) continue;
|
|
602
|
+
|
|
603
|
+
const { result: parsed } = await parseJSONL(filePath, stat);
|
|
604
|
+
if (parsed.skipped) continue;
|
|
605
|
+
if (parsed.sessionDate !== today) continue;
|
|
606
|
+
|
|
607
|
+
const key = `${today}|${encodedCwd}`;
|
|
608
|
+
if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
|
|
609
|
+
foldParsedIntoBucket(buckets.get(key), parsed);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const entries = [];
|
|
614
|
+
for (const b of buckets.values()) entries.push(...bucketToRollupLines(b));
|
|
615
|
+
if (entries.length) await historyRollup.appendRollupDays(entries);
|
|
616
|
+
|
|
617
|
+
return { date: today, projectsUpdated: buckets.size };
|
|
618
|
+
}
|
|
619
|
+
|
|
301
620
|
// ── aggregate ─────────────────────────────────────────────────────────────────
|
|
302
621
|
|
|
303
622
|
async function aggregate(req) {
|
|
@@ -307,6 +626,28 @@ async function aggregate(req) {
|
|
|
307
626
|
if (effectiveTo > today) effectiveTo = today;
|
|
308
627
|
const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
|
|
309
628
|
|
|
629
|
+
// Closed (pre-today) days: consult the durable rollup first. A day only
|
|
630
|
+
// short-circuits the live file walk once it's finalized — see
|
|
631
|
+
// finalizeClosedDays(). Unfinalized past days still get live-parsed below
|
|
632
|
+
// (and folded back into the rollup as a self-healing backfill) so results
|
|
633
|
+
// stay correct even before the boot finalize pass has run.
|
|
634
|
+
const yesterday = subtractDays(today, 1);
|
|
635
|
+
const rollupToDate = effectiveTo < today ? effectiveTo : yesterday;
|
|
636
|
+
let rollupMap = new Map();
|
|
637
|
+
if (effectiveFrom <= rollupToDate) {
|
|
638
|
+
rollupMap = await historyRollup.readRollup(effectiveFrom, rollupToDate);
|
|
639
|
+
}
|
|
640
|
+
const finalizedDates = new Set();
|
|
641
|
+
for (const bucket of rollupMap.values()) {
|
|
642
|
+
if (
|
|
643
|
+
bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID &&
|
|
644
|
+
bucket.modelId === historyRollup.FINALIZED_MODEL_ID &&
|
|
645
|
+
bucket.finalizedAt
|
|
646
|
+
) {
|
|
647
|
+
finalizedDates.add(bucket.date);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
310
651
|
const buckets = new Map();
|
|
311
652
|
let truncated = false;
|
|
312
653
|
let skippedLargeFiles = 0;
|
|
@@ -317,7 +658,7 @@ async function aggregate(req) {
|
|
|
317
658
|
try {
|
|
318
659
|
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
319
660
|
} catch {
|
|
320
|
-
return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0, cacheSavingsUsd: 0 };
|
|
661
|
+
return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0, cacheSavingsUsd: 0, rollupDays: 0, liveDays: 0 };
|
|
321
662
|
}
|
|
322
663
|
|
|
323
664
|
outer:
|
|
@@ -340,6 +681,14 @@ async function aggregate(req) {
|
|
|
340
681
|
let stat;
|
|
341
682
|
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
342
683
|
|
|
684
|
+
// Fast skip: a file last modified on an already-finalized past day is
|
|
685
|
+
// trusted from the rollup and never touched. A file whose true content
|
|
686
|
+
// belongs to an OLDER, not-yet-finalized day gets caught by
|
|
687
|
+
// finalizeClosedDays() the next time it runs (that pass reads full
|
|
688
|
+
// content, not mtime, and finalizes every day it discovers).
|
|
689
|
+
const mtimeDate = localDate(new Date(stat.mtimeMs));
|
|
690
|
+
if (mtimeDate < today && finalizedDates.has(mtimeDate)) continue;
|
|
691
|
+
|
|
343
692
|
const t1 = Date.now();
|
|
344
693
|
const { result: parsed, cacheHit } = await parseJSONL(filePath, stat);
|
|
345
694
|
if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
|
|
@@ -352,46 +701,13 @@ async function aggregate(req) {
|
|
|
352
701
|
// hide a Pacific-time user's most recent activity entirely.
|
|
353
702
|
if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
|
|
354
703
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
date: sessionDate,
|
|
359
|
-
projectCwd: decodeCwd(encodedCwd),
|
|
360
|
-
encodedCwd,
|
|
361
|
-
promptCount: 0,
|
|
362
|
-
inputTokens: 0,
|
|
363
|
-
outputTokens: 0,
|
|
364
|
-
cacheReadTokens: 0,
|
|
365
|
-
cacheCreationTokens: 0,
|
|
366
|
-
toolCallCount: 0,
|
|
367
|
-
toolBreakdown: {},
|
|
368
|
-
byModel: {},
|
|
369
|
-
sessionCount: 0,
|
|
370
|
-
errorCount: 0,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
704
|
+
// Content-confirmed finalized day (mtime heuristic above missed it) —
|
|
705
|
+
// the rollup is authoritative; don't double count.
|
|
706
|
+
if (sessionDate < today && finalizedDates.has(sessionDate)) continue;
|
|
373
707
|
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
b.outputTokens += parsed.outputTokens;
|
|
378
|
-
b.cacheReadTokens += parsed.cacheReadTokens;
|
|
379
|
-
b.cacheCreationTokens += parsed.cacheCreationTokens;
|
|
380
|
-
b.toolCallCount += parsed.toolCallCount;
|
|
381
|
-
for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
|
|
382
|
-
b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
|
|
383
|
-
}
|
|
384
|
-
for (const [modelId, srcBucket] of Object.entries(parsed.byModel ?? {})) {
|
|
385
|
-
const dst = b.byModel[modelId] ?? (b.byModel[modelId] = {
|
|
386
|
-
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
387
|
-
});
|
|
388
|
-
dst.inputTokens += srcBucket.inputTokens;
|
|
389
|
-
dst.outputTokens += srcBucket.outputTokens;
|
|
390
|
-
dst.cacheReadTokens += srcBucket.cacheReadTokens;
|
|
391
|
-
dst.cacheCreationTokens += srcBucket.cacheCreationTokens;
|
|
392
|
-
}
|
|
393
|
-
b.sessionCount++;
|
|
394
|
-
b.errorCount += parsed.errorCount;
|
|
708
|
+
const key = `${sessionDate}|${encodedCwd}`;
|
|
709
|
+
if (!buckets.has(key)) buckets.set(key, emptyBucket(sessionDate, encodedCwd));
|
|
710
|
+
foldParsedIntoBucket(buckets.get(key), parsed);
|
|
395
711
|
|
|
396
712
|
if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
|
|
397
713
|
skippedBudgetFiles++;
|
|
@@ -405,8 +721,23 @@ async function aggregate(req) {
|
|
|
405
721
|
}
|
|
406
722
|
}
|
|
407
723
|
|
|
724
|
+
// Self-healing backfill: any past (pre-today) day we just live-parsed was
|
|
725
|
+
// walked in full above (the file walk isn't restricted to a single day), so
|
|
726
|
+
// fold it into the rollup now — it'll be trusted outright once the next
|
|
727
|
+
// finalize pass stamps it `finalizedAt`.
|
|
728
|
+
const backfillEntries = [];
|
|
729
|
+
for (const b of buckets.values()) {
|
|
730
|
+
if (b.date < today) backfillEntries.push(...bucketToRollupLines(b));
|
|
731
|
+
}
|
|
732
|
+
if (backfillEntries.length) {
|
|
733
|
+
await historyRollup.appendRollupDays(backfillEntries);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const rollupRows = rollupMapToRows(rollupMap, finalizedDates);
|
|
737
|
+
const allBucketEntries = [...rollupRows.values(), ...buckets.values()];
|
|
738
|
+
|
|
408
739
|
let cacheSavingsUsd = 0;
|
|
409
|
-
const rows =
|
|
740
|
+
const rows = allBucketEntries.map((b) => {
|
|
410
741
|
const byModel = {};
|
|
411
742
|
let estimatedCostUsd = 0;
|
|
412
743
|
for (const [modelId, bucket] of Object.entries(b.byModel)) {
|
|
@@ -427,8 +758,11 @@ async function aggregate(req) {
|
|
|
427
758
|
|
|
428
759
|
rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
|
|
429
760
|
|
|
761
|
+
const rollupDays = new Set(Array.from(rollupRows.values()).map((r) => r.date)).size;
|
|
762
|
+
const liveDays = new Set(Array.from(buckets.values()).map((r) => r.date)).size;
|
|
763
|
+
|
|
430
764
|
const scannedMs = Date.now() - t0;
|
|
431
|
-
return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles, cacheSavingsUsd };
|
|
765
|
+
return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles, cacheSavingsUsd, rollupDays, liveDays };
|
|
432
766
|
}
|
|
433
767
|
|
|
434
768
|
// ── IPC registration ──────────────────────────────────────────────────────────
|
|
@@ -485,10 +819,17 @@ module.exports = {
|
|
|
485
819
|
registerHistoryAggregatorHandlers,
|
|
486
820
|
remote,
|
|
487
821
|
MODEL_PRICING,
|
|
822
|
+
finalizeClosedDays,
|
|
823
|
+
refreshIntradayToday,
|
|
488
824
|
// exported for tests
|
|
489
825
|
scanAggrLines,
|
|
490
826
|
parseJSONL,
|
|
491
827
|
resolvePricingKey,
|
|
492
828
|
CACHE_MAX,
|
|
493
829
|
LRUCache,
|
|
830
|
+
computeActiveMinutes,
|
|
831
|
+
// exported for historyDashboard.cjs (pure, no I/O at require-time; reused
|
|
832
|
+
// rather than re-implemented so date-range math stays in one place)
|
|
833
|
+
localDate,
|
|
834
|
+
subtractDays,
|
|
494
835
|
};
|