claude-rpc 0.3.8

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/src/scanner.js ADDED
@@ -0,0 +1,721 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, dirname, basename } from 'node:path';
3
+ import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
4
+ import { languageOf } from './languages.js';
5
+ import { costFor, pricingKeyFor } from './pricing.js';
6
+
7
+ // Bumping this forces a full re-parse on next scan. Increment whenever the
8
+ // per-transcript summary schema changes in a way old caches can't satisfy.
9
+ const CACHE_VERSION = 2;
10
+
11
+ // Cap counted gap between consecutive timestamps. Anything larger is treated
12
+ // as the user walking away — we count only what's plausibly active time.
13
+ const ACTIVE_GAP_CAP_MS = 5 * 60 * 1000;
14
+
15
+ // Local-time YYYY-MM-DD key for bucketing.
16
+ function dayKey(ts) {
17
+ const d = new Date(ts);
18
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
19
+ }
20
+
21
+ // ISO week key like "2026-W21" using local time. Monday-start.
22
+ function weekKey(ts) {
23
+ const d = new Date(ts);
24
+ d.setHours(0, 0, 0, 0);
25
+ // ISO 8601: week starts Monday; week 1 contains Jan 4.
26
+ const day = (d.getDay() + 6) % 7; // Mon = 0
27
+ d.setDate(d.getDate() - day + 3); // move to Thursday of this week
28
+ const firstThursday = new Date(d.getFullYear(), 0, 4);
29
+ const week = 1 + Math.round(((d - firstThursday) / 86_400_000 - 3 + ((firstThursday.getDay() + 6) % 7)) / 7);
30
+ return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`;
31
+ }
32
+
33
+ function hourKey(ts) {
34
+ return new Date(ts).getHours();
35
+ }
36
+
37
+ const EDITING_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
38
+
39
+ // First non-env token of a shell command. `FOO=bar git status` → `git`.
40
+ // Strips `sudo`, `time`, and tee-style decorators that aren't the "real" command.
41
+ function firstShellToken(cmd) {
42
+ if (!cmd || typeof cmd !== 'string') return '';
43
+ // Strip leading whitespace + env assignments (VAR=value chains).
44
+ const stripped = cmd.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
45
+ // First token, then strip path: `/usr/bin/python3` → `python3`.
46
+ let first = stripped.split(/\s+/)[0] || '';
47
+ if (first === 'sudo' || first === 'time') {
48
+ const rest = stripped.slice(first.length).trim();
49
+ return firstShellToken(rest);
50
+ }
51
+ // Drop pipe/redirect prefix oddities and trailing chars.
52
+ first = first.replace(/^[`(]+|[`)]+$/g, '');
53
+ const slash = first.lastIndexOf('/');
54
+ if (slash !== -1) first = first.slice(slash + 1);
55
+ return first.toLowerCase();
56
+ }
57
+
58
+ function domainOf(url) {
59
+ if (!url || typeof url !== 'string') return '';
60
+ try {
61
+ const u = new URL(url);
62
+ return u.hostname.replace(/^www\./, '');
63
+ } catch {
64
+ const m = url.match(/^https?:\/\/([^/?#]+)/i);
65
+ return m ? m[1].replace(/^www\./, '') : '';
66
+ }
67
+ }
68
+
69
+ function countLines(text) {
70
+ if (!text || typeof text !== 'string') return 0;
71
+ // Treat empty trailing newline as not contributing — line count is the
72
+ // number of "\n"-separated chunks that actually have content, plus one for
73
+ // the final segment if non-empty.
74
+ if (text === '') return 0;
75
+ const lines = text.split('\n');
76
+ // Drop a single trailing empty string from a trailing newline.
77
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
78
+ return lines.length;
79
+ }
80
+
81
+ // Trailing ISO-ish datetime suffix (e.g. "-2026-04-25T185311Z"). When a cwd's
82
+ // basename ends with one of these, collapse it so all "archive-*" snapshots
83
+ // aggregate under a single project name.
84
+ export const DATE_SUFFIX_RE = /[-_.]\d{4}[-_.]?\d{2}[-_.]?\d{2}(?:[Tt._-]?\d{0,6})?Z?$/;
85
+ export function cleanProjectName(name) {
86
+ if (!name) return name;
87
+ return name.replace(DATE_SUFFIX_RE, '') || name;
88
+ }
89
+
90
+ function blankDay() {
91
+ return {
92
+ activeMs: 0,
93
+ userMessages: 0,
94
+ toolCalls: 0,
95
+ inputTokens: 0,
96
+ outputTokens: 0,
97
+ cacheReadTokens: 0,
98
+ cacheWriteTokens: 0,
99
+ sessions: 0,
100
+ linesAdded: 0,
101
+ linesRemoved: 0,
102
+ cost: 0,
103
+ notifications: 0,
104
+ firstTs: null,
105
+ lastTs: null,
106
+ };
107
+ }
108
+
109
+ function mergeDay(target, src) {
110
+ target.activeMs += src.activeMs || 0;
111
+ target.userMessages += src.userMessages || 0;
112
+ target.toolCalls += src.toolCalls || 0;
113
+ target.inputTokens += src.inputTokens || 0;
114
+ target.outputTokens += src.outputTokens || 0;
115
+ target.cacheReadTokens += src.cacheReadTokens || 0;
116
+ target.cacheWriteTokens += src.cacheWriteTokens || 0;
117
+ target.sessions += src.sessions || 0;
118
+ target.linesAdded += src.linesAdded || 0;
119
+ target.linesRemoved += src.linesRemoved || 0;
120
+ target.cost += src.cost || 0;
121
+ target.notifications += src.notifications || 0;
122
+ if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
123
+ if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
124
+ }
125
+
126
+ function ensureDataDir() {
127
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
128
+ }
129
+
130
+ function safeJson(line) {
131
+ try { return JSON.parse(line); } catch { return null; }
132
+ }
133
+
134
+ function isRealUserMessage(record) {
135
+ if (record.type !== 'user' || record.isMeta) return false;
136
+ const c = record.message?.content;
137
+ if (typeof c === 'string') {
138
+ if (c.startsWith('<local-command') || c.startsWith('<system-reminder') || c.startsWith('<command-')) return false;
139
+ return c.trim().length > 0;
140
+ }
141
+ if (Array.isArray(c)) {
142
+ const hasToolResult = c.some((b) => b.type === 'tool_result');
143
+ if (hasToolResult) return false;
144
+ return c.some((b) => b.type === 'text' && String(b.text || '').trim().length > 0);
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function collectFilePath(input = {}) {
150
+ return input.file_path || input.path || input.notebook_path || null;
151
+ }
152
+
153
+ // Parse a single transcript JSONL into a per-file summary.
154
+ export function parseTranscript(filePath) {
155
+ const raw = readFileSync(filePath, 'utf8');
156
+ const lines = raw.split('\n');
157
+ const summary = {
158
+ sessionId: null,
159
+ project: null,
160
+ cwd: null,
161
+ model: null,
162
+ inputTokens: 0,
163
+ outputTokens: 0,
164
+ cacheReadTokens: 0,
165
+ cacheWriteTokens: 0,
166
+ userMessages: 0,
167
+ toolCalls: 0,
168
+ toolBreakdown: {},
169
+ files: [],
170
+ firstTs: null,
171
+ lastTs: null,
172
+ activeMs: 0,
173
+ byDay: {}, // day-key → blankDay
174
+ byWeek: {}, // ISO week key → blankDay
175
+ byHour: {}, // hour-of-day (0..23) → blankDay
176
+ fileEdits: {}, // absolute path → edit count
177
+ // Phase 1 enrichments
178
+ linesAdded: 0,
179
+ linesRemoved: 0,
180
+ bashCommands: {}, // first token → count
181
+ webDomains: {}, // hostname → count
182
+ subagents: {}, // subagent_type → count
183
+ cost: 0, // estimated USD
184
+ costByModel: {}, // pricing key → USD
185
+ modelsUsed: {}, // raw model id → assistant turns
186
+ };
187
+ const fileSet = new Set();
188
+ // Records in their original order, retaining timestamps for per-day bucketing.
189
+ const records = [];
190
+
191
+ for (const line of lines) {
192
+ if (!line) continue;
193
+ const r = safeJson(line);
194
+ if (!r) continue;
195
+ if (r.sessionId && !summary.sessionId) summary.sessionId = r.sessionId;
196
+ if (r.cwd && !summary.cwd) {
197
+ summary.cwd = r.cwd;
198
+ summary.project = cleanProjectName(basename(r.cwd));
199
+ }
200
+ const ts = r.timestamp ? new Date(r.timestamp).getTime() : null;
201
+ const day = ts ? dayKey(ts) : null;
202
+ const week = ts ? weekKey(ts) : null;
203
+ const hour = ts ? hourKey(ts) : null;
204
+ const dayBucket = day ? (summary.byDay[day] ||= blankDay()) : null;
205
+ const weekBucket = week ? (summary.byWeek[week] ||= blankDay()) : null;
206
+ const hourBucket = hour !== null ? (summary.byHour[hour] ||= blankDay()) : null;
207
+ const allBuckets = [dayBucket, weekBucket, hourBucket].filter(Boolean);
208
+
209
+ if (r.type === 'assistant') {
210
+ const turnModel = r.message?.model || summary.model;
211
+ const u = r.message?.usage;
212
+ if (u) {
213
+ summary.inputTokens += u.input_tokens || 0;
214
+ summary.outputTokens += u.output_tokens || 0;
215
+ summary.cacheReadTokens += u.cache_read_input_tokens || 0;
216
+ summary.cacheWriteTokens += u.cache_creation_input_tokens || 0;
217
+ for (const bucket of allBuckets) {
218
+ bucket.inputTokens += u.input_tokens || 0;
219
+ bucket.outputTokens += u.output_tokens || 0;
220
+ bucket.cacheReadTokens += u.cache_read_input_tokens || 0;
221
+ bucket.cacheWriteTokens += u.cache_creation_input_tokens || 0;
222
+ }
223
+ // Per-turn cost — uses this turn's model id, not the session's first-seen one.
224
+ const turnCost = costFor({ model: turnModel, usage: u });
225
+ if (turnCost > 0) {
226
+ summary.cost += turnCost;
227
+ const key = pricingKeyFor(turnModel);
228
+ summary.costByModel[key] = (summary.costByModel[key] || 0) + turnCost;
229
+ for (const bucket of allBuckets) bucket.cost += turnCost;
230
+ }
231
+ }
232
+ if (turnModel) {
233
+ if (!summary.model) summary.model = turnModel;
234
+ summary.modelsUsed[turnModel] = (summary.modelsUsed[turnModel] || 0) + 1;
235
+ }
236
+ const blocks = r.message?.content || [];
237
+ for (const b of blocks) {
238
+ if (b.type === 'tool_use') {
239
+ summary.toolCalls += 1;
240
+ summary.toolBreakdown[b.name] = (summary.toolBreakdown[b.name] || 0) + 1;
241
+ for (const bucket of allBuckets) bucket.toolCalls += 1;
242
+ const input = b.input || {};
243
+ const f = collectFilePath(input);
244
+ if (f) {
245
+ fileSet.add(f);
246
+ if (EDITING_TOOLS.has(b.name)) {
247
+ summary.fileEdits[f] = (summary.fileEdits[f] || 0) + 1;
248
+ }
249
+ }
250
+ // Code churn — lines added/removed. For Edit, we count
251
+ // new_string / old_string lines once; `replace_all` would technically
252
+ // multiply by the number of occurrences in the target file, but we
253
+ // can't see file contents here, so we under-count those uniformly.
254
+ if (b.name === 'Edit') {
255
+ const adds = countLines(input.new_string);
256
+ const rems = countLines(input.old_string);
257
+ summary.linesAdded += adds;
258
+ summary.linesRemoved += rems;
259
+ for (const bucket of allBuckets) {
260
+ bucket.linesAdded += adds;
261
+ bucket.linesRemoved += rems;
262
+ }
263
+ } else if (b.name === 'Write') {
264
+ const adds = countLines(input.content);
265
+ summary.linesAdded += adds;
266
+ for (const bucket of allBuckets) bucket.linesAdded += adds;
267
+ } else if (b.name === 'NotebookEdit') {
268
+ const adds = countLines(input.new_source);
269
+ summary.linesAdded += adds;
270
+ for (const bucket of allBuckets) bucket.linesAdded += adds;
271
+ } else if (b.name === 'Bash') {
272
+ const cmd = firstShellToken(input.command);
273
+ if (cmd) summary.bashCommands[cmd] = (summary.bashCommands[cmd] || 0) + 1;
274
+ } else if (b.name === 'WebFetch' || b.name === 'WebSearch') {
275
+ const host = b.name === 'WebFetch' ? domainOf(input.url) : '';
276
+ if (host) summary.webDomains[host] = (summary.webDomains[host] || 0) + 1;
277
+ } else if (b.name === 'Agent' || b.name === 'Task') {
278
+ const kind = input.subagent_type || 'general-purpose';
279
+ summary.subagents[kind] = (summary.subagents[kind] || 0) + 1;
280
+ }
281
+ }
282
+ }
283
+ } else if (isRealUserMessage(r)) {
284
+ summary.userMessages += 1;
285
+ for (const bucket of allBuckets) bucket.userMessages += 1;
286
+ }
287
+ if (ts) records.push({ ts, day, week, hour });
288
+ }
289
+
290
+ summary.files = Array.from(fileSet);
291
+ if (records.length) {
292
+ records.sort((a, b) => a.ts - b.ts);
293
+ summary.firstTs = records[0].ts;
294
+ summary.lastTs = records[records.length - 1].ts;
295
+ let active = 0;
296
+ for (let i = 1; i < records.length; i++) {
297
+ const prev = records[i - 1];
298
+ const gap = records[i].ts - prev.ts;
299
+ if (gap > 0 && gap < ACTIVE_GAP_CAP_MS) {
300
+ active += gap;
301
+ // Charge the gap's active time to the day/week/hour of the earlier record.
302
+ if (prev.day) (summary.byDay[prev.day] ||= blankDay()).activeMs += gap;
303
+ if (prev.week) (summary.byWeek[prev.week] ||= blankDay()).activeMs += gap;
304
+ if (prev.hour !== null && prev.hour !== undefined) {
305
+ (summary.byHour[prev.hour] ||= blankDay()).activeMs += gap;
306
+ }
307
+ }
308
+ }
309
+ summary.activeMs = active;
310
+ }
311
+ return summary;
312
+ }
313
+
314
+ function listTranscripts(projectsDir) {
315
+ if (!existsSync(projectsDir)) return [];
316
+ const results = [];
317
+ const walk = (dir) => {
318
+ let entries;
319
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
320
+ for (const e of entries) {
321
+ const full = join(dir, e.name);
322
+ if (e.isDirectory()) walk(full);
323
+ else if (e.isFile() && e.name.endsWith('.jsonl')) results.push(full);
324
+ }
325
+ };
326
+ walk(projectsDir);
327
+ return results;
328
+ }
329
+
330
+ function isSubagentPath(p) {
331
+ return /[\\/]subagents[\\/]/.test(p);
332
+ }
333
+
334
+ // Pull the real cwd from the head of a transcript so live sessions can show
335
+ // "my-app" instead of the slugified directory name.
336
+ const cwdCache = new Map(); // path → { mtime, cwd }
337
+ function readTranscriptCwd(path, mtimeMs) {
338
+ const cached = cwdCache.get(path);
339
+ if (cached && cached.mtime === mtimeMs) return cached.cwd;
340
+ let cwd = null;
341
+ try {
342
+ const head = readFileSync(path, 'utf8').split('\n', 25);
343
+ for (const line of head) {
344
+ if (!line) continue;
345
+ const r = safeJson(line);
346
+ if (r?.cwd) { cwd = r.cwd; break; }
347
+ }
348
+ } catch {}
349
+ cwdCache.set(path, { mtime: mtimeMs, cwd });
350
+ return cwd;
351
+ }
352
+
353
+ // Detect live sessions by transcript mtime. Returns array of { path, project, cwd, mtime, ageSec }.
354
+ // A session is "live" if its .jsonl was modified within thresholdMs.
355
+ export function findLiveSessions({ projectsDir = CLAUDE_PROJECTS, thresholdMs = 90_000 } = {}) {
356
+ if (!existsSync(projectsDir)) return [];
357
+ const now = Date.now();
358
+ const live = [];
359
+ for (const proj of readdirSync(projectsDir)) {
360
+ const projPath = join(projectsDir, proj);
361
+ let entries;
362
+ try { entries = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
363
+ for (const e of entries) {
364
+ // Only top-level transcripts count as sessions, not subagent files.
365
+ if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
366
+ const full = join(projPath, e.name);
367
+ let st;
368
+ try { st = statSync(full); } catch { continue; }
369
+ const age = now - st.mtimeMs;
370
+ if (age <= thresholdMs) {
371
+ const cwd = readTranscriptCwd(full, st.mtimeMs);
372
+ const project = cleanProjectName(cwd ? basename(cwd) : proj);
373
+ live.push({ path: full, project, cwd: cwd || '', mtime: st.mtimeMs, ageSec: Math.round(age / 1000) });
374
+ }
375
+ }
376
+ }
377
+ live.sort((a, b) => b.mtime - a.mtime);
378
+ return live;
379
+ }
380
+
381
+ function readCache() {
382
+ ensureDataDir();
383
+ if (!existsSync(SCAN_CACHE_PATH)) return { _v: CACHE_VERSION, files: {} };
384
+ try {
385
+ const raw = JSON.parse(readFileSync(SCAN_CACHE_PATH, 'utf8'));
386
+ if (!raw || raw._v !== CACHE_VERSION) return { _v: CACHE_VERSION, files: {} };
387
+ return raw;
388
+ } catch { return { _v: CACHE_VERSION, files: {} }; }
389
+ }
390
+
391
+ function writeCache(cache) {
392
+ ensureDataDir();
393
+ cache._v = CACHE_VERSION;
394
+ const tmp = SCAN_CACHE_PATH + '.tmp';
395
+ writeFileSync(tmp, JSON.stringify(cache));
396
+ renameSync(tmp, SCAN_CACHE_PATH);
397
+ }
398
+
399
+ // Per-day notification counts come from a hook-side append log, since
400
+ // transcripts don't carry Notification events reliably.
401
+ function readNotificationsByDay() {
402
+ if (!existsSync(EVENTS_LOG_PATH)) return {};
403
+ const out = {};
404
+ try {
405
+ const raw = readFileSync(EVENTS_LOG_PATH, 'utf8');
406
+ for (const line of raw.split('\n')) {
407
+ if (!line) continue;
408
+ const e = safeJson(line);
409
+ if (!e || e.type !== 'notification' || !e.ts) continue;
410
+ const k = dayKey(e.ts);
411
+ out[k] = (out[k] || 0) + 1;
412
+ }
413
+ } catch {}
414
+ return out;
415
+ }
416
+
417
+ function writeAggregate(agg) {
418
+ ensureDataDir();
419
+ const tmp = AGGREGATE_PATH + '.tmp';
420
+ writeFileSync(tmp, JSON.stringify(agg, null, 2));
421
+ renameSync(tmp, AGGREGATE_PATH);
422
+ }
423
+
424
+ export function readAggregate() {
425
+ if (!existsSync(AGGREGATE_PATH)) return null;
426
+ try { return JSON.parse(readFileSync(AGGREGATE_PATH, 'utf8')); }
427
+ catch { return null; }
428
+ }
429
+
430
+ export { dayKey, weekKey, hourKey };
431
+
432
+ function aggregateFrom(cache) {
433
+ const agg = {
434
+ sessions: 0,
435
+ subagentRuns: 0,
436
+ inputTokens: 0,
437
+ outputTokens: 0,
438
+ cacheReadTokens: 0,
439
+ cacheWriteTokens: 0,
440
+ userMessages: 0,
441
+ toolCalls: 0,
442
+ toolBreakdown: {},
443
+ projects: {},
444
+ activeMs: 0,
445
+ wallMs: 0,
446
+ uniqueFiles: 0,
447
+ firstTs: null,
448
+ lastTs: null,
449
+ byDay: {},
450
+ byWeek: {},
451
+ byHour: {},
452
+ byWeekday: {},
453
+ fileEdits: {},
454
+ streak: 0,
455
+ longestStreak: 0,
456
+ daysSinceFirst: 0,
457
+ bestDay: null,
458
+ peakHour: null,
459
+ topEditedFiles: [],
460
+ // Phase 1 enrichments
461
+ linesAdded: 0,
462
+ linesRemoved: 0,
463
+ linesNet: 0,
464
+ bashCommands: {},
465
+ webDomains: {},
466
+ subagents: {},
467
+ languages: {},
468
+ mcpToolCalls: 0,
469
+ builtinToolCalls: 0,
470
+ estimatedCost: 0,
471
+ costByModel: {},
472
+ modelsUsed: {},
473
+ notifications: 0,
474
+ generatedAt: Date.now(),
475
+ _v: CACHE_VERSION,
476
+ };
477
+ const fileSet = new Set();
478
+ for (const [path, summary] of Object.entries(cache.files)) {
479
+ if (!summary) continue;
480
+ const isSub = summary.isSubagent ?? isSubagentPath(path);
481
+ // Tokens and tools always count.
482
+ agg.inputTokens += summary.inputTokens || 0;
483
+ agg.outputTokens += summary.outputTokens || 0;
484
+ agg.cacheReadTokens += summary.cacheReadTokens || 0;
485
+ agg.cacheWriteTokens += summary.cacheWriteTokens || 0;
486
+ agg.toolCalls += summary.toolCalls || 0;
487
+ for (const [name, count] of Object.entries(summary.toolBreakdown || {})) {
488
+ agg.toolBreakdown[name] = (agg.toolBreakdown[name] || 0) + count;
489
+ }
490
+ for (const f of summary.files || []) fileSet.add(f);
491
+ // Lines/cost from this transcript roll up regardless of subagent vs top-level
492
+ // — they represent real work done by Claude.
493
+ agg.linesAdded += summary.linesAdded || 0;
494
+ agg.linesRemoved += summary.linesRemoved || 0;
495
+ agg.estimatedCost += summary.cost || 0;
496
+ for (const [m, v] of Object.entries(summary.costByModel || {})) {
497
+ agg.costByModel[m] = (agg.costByModel[m] || 0) + v;
498
+ }
499
+ for (const [m, v] of Object.entries(summary.modelsUsed || {})) {
500
+ agg.modelsUsed[m] = (agg.modelsUsed[m] || 0) + v;
501
+ }
502
+ for (const [c, n] of Object.entries(summary.bashCommands || {})) {
503
+ agg.bashCommands[c] = (agg.bashCommands[c] || 0) + n;
504
+ }
505
+ for (const [d, n] of Object.entries(summary.webDomains || {})) {
506
+ agg.webDomains[d] = (agg.webDomains[d] || 0) + n;
507
+ }
508
+ for (const [k, n] of Object.entries(summary.subagents || {})) {
509
+ agg.subagents[k] = (agg.subagents[k] || 0) + n;
510
+ }
511
+ if (isSub) {
512
+ agg.subagentRuns += 1;
513
+ // Subagents still contribute tokens/tools/lines/cost to per-day/week/hour buckets.
514
+ const mergeSubBuckets = (srcMap, destMap) => {
515
+ for (const [k, src] of Object.entries(srcMap || {})) {
516
+ const target = destMap[k] ||= blankDay();
517
+ target.inputTokens += src.inputTokens || 0;
518
+ target.outputTokens += src.outputTokens || 0;
519
+ target.cacheReadTokens += src.cacheReadTokens || 0;
520
+ target.cacheWriteTokens += src.cacheWriteTokens || 0;
521
+ target.toolCalls += src.toolCalls || 0;
522
+ target.linesAdded += src.linesAdded || 0;
523
+ target.linesRemoved += src.linesRemoved || 0;
524
+ target.cost += src.cost || 0;
525
+ }
526
+ };
527
+ mergeSubBuckets(summary.byDay, agg.byDay);
528
+ mergeSubBuckets(summary.byWeek, agg.byWeek);
529
+ mergeSubBuckets(summary.byHour, agg.byHour);
530
+ // Subagent file edits also count toward hotspots.
531
+ for (const [f, n] of Object.entries(summary.fileEdits || {})) {
532
+ agg.fileEdits[f] = (agg.fileEdits[f] || 0) + n;
533
+ }
534
+ } else {
535
+ // Top-level sessions only — these are the real "chats".
536
+ agg.sessions += 1;
537
+ agg.userMessages += summary.userMessages || 0;
538
+ agg.activeMs += summary.activeMs || 0;
539
+ if (summary.firstTs && summary.lastTs) agg.wallMs += summary.lastTs - summary.firstTs;
540
+ if (summary.project) {
541
+ const p = agg.projects[summary.project] = agg.projects[summary.project] || {
542
+ sessions: 0, activeMs: 0, inputTokens: 0, outputTokens: 0, userMessages: 0, toolCalls: 0,
543
+ linesAdded: 0, linesRemoved: 0, cost: 0,
544
+ };
545
+ p.sessions += 1;
546
+ p.activeMs += summary.activeMs || 0;
547
+ p.inputTokens += summary.inputTokens || 0;
548
+ p.outputTokens += summary.outputTokens || 0;
549
+ p.userMessages += summary.userMessages || 0;
550
+ p.toolCalls += summary.toolCalls || 0;
551
+ p.linesAdded += summary.linesAdded || 0;
552
+ p.linesRemoved += summary.linesRemoved || 0;
553
+ p.cost += summary.cost || 0;
554
+ }
555
+ if (summary.firstTs) agg.firstTs = agg.firstTs ? Math.min(agg.firstTs, summary.firstTs) : summary.firstTs;
556
+ if (summary.lastTs) agg.lastTs = agg.lastTs ? Math.max(agg.lastTs, summary.lastTs) : summary.lastTs;
557
+ // Full per-day/week/hour merge for top-level sessions.
558
+ for (const [k, day] of Object.entries(summary.byDay || {})) {
559
+ mergeDay(agg.byDay[k] ||= blankDay(), day);
560
+ }
561
+ for (const [k, w] of Object.entries(summary.byWeek || {})) {
562
+ mergeDay(agg.byWeek[k] ||= blankDay(), w);
563
+ }
564
+ for (const [k, h] of Object.entries(summary.byHour || {})) {
565
+ mergeDay(agg.byHour[k] ||= blankDay(), h);
566
+ }
567
+ // Bump session count on the day, week, and hour where the session started.
568
+ if (summary.firstTs) {
569
+ (agg.byDay[dayKey(summary.firstTs)] ||= blankDay()).sessions += 1;
570
+ (agg.byWeek[weekKey(summary.firstTs)] ||= blankDay()).sessions += 1;
571
+ (agg.byHour[hourKey(summary.firstTs)] ||= blankDay()).sessions += 1;
572
+ }
573
+ // File hotspots — top-level sessions.
574
+ for (const [f, n] of Object.entries(summary.fileEdits || {})) {
575
+ agg.fileEdits[f] = (agg.fileEdits[f] || 0) + n;
576
+ }
577
+ }
578
+ }
579
+ agg.uniqueFiles = fileSet.size;
580
+
581
+ // Derived: streak (consecutive days with activity ending today or yesterday),
582
+ // longest streak, days since first, best day.
583
+ const days = Object.keys(agg.byDay).sort();
584
+ if (days.length) {
585
+ // Best day by activeMs.
586
+ let best = null;
587
+ for (const k of days) {
588
+ const d = agg.byDay[k];
589
+ if (!best || d.activeMs > best.activeMs) best = { day: k, ...d };
590
+ }
591
+ agg.bestDay = best;
592
+
593
+ // Days since first.
594
+ const firstDay = new Date(days[0] + 'T00:00:00');
595
+ const today = new Date();
596
+ today.setHours(0, 0, 0, 0);
597
+ agg.daysSinceFirst = Math.floor((today - firstDay) / 86_400_000) + 1;
598
+
599
+ // Current streak: walk back from today.
600
+ const has = (offset) => {
601
+ const d = new Date(today);
602
+ d.setDate(d.getDate() - offset);
603
+ return !!agg.byDay[dayKey(d)];
604
+ };
605
+ let streak = 0;
606
+ let offset = has(0) ? 0 : 1; // if no activity today, start from yesterday
607
+ while (has(offset)) { streak += 1; offset += 1; }
608
+ agg.streak = streak;
609
+
610
+ // Longest streak across all history.
611
+ let longest = 0;
612
+ let run = 0;
613
+ let prev = null;
614
+ for (const k of days) {
615
+ if (prev) {
616
+ const d1 = new Date(prev + 'T00:00:00');
617
+ const d2 = new Date(k + 'T00:00:00');
618
+ const diff = Math.round((d2 - d1) / 86_400_000);
619
+ run = diff === 1 ? run + 1 : 1;
620
+ } else {
621
+ run = 1;
622
+ }
623
+ if (run > longest) longest = run;
624
+ prev = k;
625
+ }
626
+ agg.longestStreak = longest;
627
+ }
628
+
629
+ // Peak hour-of-day across all-time (by activeMs).
630
+ const hourEntries = Object.entries(agg.byHour);
631
+ if (hourEntries.length) {
632
+ let bestHour = null;
633
+ for (const [h, data] of hourEntries) {
634
+ if (!bestHour || data.activeMs > bestHour.activeMs) bestHour = { hour: Number(h), ...data };
635
+ }
636
+ agg.peakHour = bestHour;
637
+ }
638
+
639
+ // Top edited files (paths + counts), descending.
640
+ agg.topEditedFiles = Object.entries(agg.fileEdits)
641
+ .sort((a, b) => b[1] - a[1])
642
+ .slice(0, 25)
643
+ .map(([path, count]) => ({ path, count }));
644
+
645
+ // Languages: bucket file edits by extension via languages.js.
646
+ for (const [path, count] of Object.entries(agg.fileEdits)) {
647
+ const lang = languageOf(path);
648
+ if (!lang) continue;
649
+ const bucket = agg.languages[lang] = agg.languages[lang] || { files: 0, edits: 0 };
650
+ bucket.files += 1;
651
+ bucket.edits += count;
652
+ }
653
+
654
+ // MCP vs built-in tool split. mcp__server__action → MCP, everything else built-in.
655
+ for (const [name, count] of Object.entries(agg.toolBreakdown || {})) {
656
+ if (name.startsWith('mcp__')) agg.mcpToolCalls += count;
657
+ else agg.builtinToolCalls += count;
658
+ }
659
+
660
+ // Day-of-week fold (Sun=0..Sat=6). Aggregates active time + prompts.
661
+ for (const [k, day] of Object.entries(agg.byDay)) {
662
+ const d = new Date(k + 'T00:00:00');
663
+ const wd = d.getDay();
664
+ const target = agg.byWeekday[wd] ||= blankDay();
665
+ mergeDay(target, day);
666
+ }
667
+
668
+ // Folded line totals.
669
+ agg.linesNet = (agg.linesAdded || 0) - (agg.linesRemoved || 0);
670
+
671
+ // Notifications from the hook-side append log.
672
+ const notifByDay = readNotificationsByDay();
673
+ let notifTotal = 0;
674
+ for (const [k, n] of Object.entries(notifByDay)) {
675
+ const d = agg.byDay[k] ||= blankDay();
676
+ d.notifications = (d.notifications || 0) + n;
677
+ notifTotal += n;
678
+ }
679
+ agg.notifications = notifTotal;
680
+
681
+ return agg;
682
+ }
683
+
684
+ // Incremental scan: re-parse only changed files. Returns {aggregate, scanned, skipped, removed}.
685
+ export function scan({ projectsDir = CLAUDE_PROJECTS, onProgress, force = false } = {}) {
686
+ const cache = readCache();
687
+ cache.files = cache.files || {};
688
+ const seen = new Set();
689
+ const transcripts = listTranscripts(projectsDir);
690
+ let scanned = 0;
691
+ let skipped = 0;
692
+ for (const fp of transcripts) {
693
+ seen.add(fp);
694
+ let st;
695
+ try { st = statSync(fp); } catch { continue; }
696
+ const sig = `${st.mtimeMs}:${st.size}`;
697
+ if (!force && cache.files[fp]?._sig === sig) {
698
+ skipped += 1;
699
+ continue;
700
+ }
701
+ try {
702
+ const summary = parseTranscript(fp);
703
+ summary._sig = sig;
704
+ summary.isSubagent = isSubagentPath(fp);
705
+ cache.files[fp] = summary;
706
+ scanned += 1;
707
+ if (onProgress) onProgress({ scanned, skipped, total: transcripts.length, file: fp });
708
+ } catch (e) {
709
+ // skip corrupt file but keep prior cache entry
710
+ }
711
+ }
712
+ // Remove cache entries for deleted transcripts
713
+ let removed = 0;
714
+ for (const key of Object.keys(cache.files)) {
715
+ if (!seen.has(key)) { delete cache.files[key]; removed += 1; }
716
+ }
717
+ writeCache(cache);
718
+ const aggregate = aggregateFrom(cache);
719
+ writeAggregate(aggregate);
720
+ return { aggregate, scanned, skipped, removed, total: transcripts.length };
721
+ }