@echomem/mcp 1.3.1 → 1.3.2

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.
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Local "Context Doctor" forensic report — the pre-auth onboarding payload.
3
+ *
4
+ * Scans the user's LOCAL coding-agent transcripts (Claude Code under ~/.claude/projects AND Codex
5
+ * rollouts under ~/.codex/sessions) and turns them into a single MERGED workspace physical-exam:
6
+ * accomplishment (days/repos/tokens/cost/attention), the cost split (cold/cache/output per model),
7
+ * and the waste (token-reread forensics + a Context Cleanliness score). Everything is local; nothing
8
+ * leaves the machine and no model is called.
9
+ *
10
+ * Output shape mirrors the yeahecho `onboarding-two` ContextDoctorTwo report so the same UI renders it.
11
+ * Two source adapters feed ONE source-agnostic engine, so the reread/cleanliness logic is written once.
12
+ *
13
+ * Honesty (the team sanity-checks this): cost is an API-EQUIVALENT estimate at list prices, NOT a
14
+ * subscription bill; `time.*`/avoidableWait are ESTIMATES labeled as such; re-read % is a TOKEN share.
15
+ * Time-of-day buckets use the USER's LOCAL timezone (not a hardcoded one).
16
+ */
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { eachLine, walk } from "./report.js";
21
+ // ---------------------------------------------------------------------------
22
+ // Constants (named on purpose — no magic numbers downstream)
23
+ // ---------------------------------------------------------------------------
24
+ const WORK_BLOCK_GAP_MS = 30 * 60 * 1000; // >30min gap between real user prompts => new work block
25
+ const FOCUS_DAY_HOURS = 5; // one "focused work day" == 5h of attention, for day-equivalent math
26
+ const CONTEXT_REACQUISITION_FRACTION = 0.5; // ESTIMATE: share of stale-reread time felt as avoidable wait
27
+ const NIGHT_HOURS = new Set([22, 23, 0, 1, 2, 3]); // late-night grind window (local tz)
28
+ const CHARS_PER_TOKEN = 4; // rough token estimate for read (tool-result) output size
29
+ // Per-million-token USD. API-equivalent ESTIMATE at list prices.
30
+ const CLAUDE_PRICES = {
31
+ "claude-opus-4-8": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
32
+ "claude-opus-4-7": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
33
+ "claude-sonnet-4-6": { input: 3, cacheWrite: 3.75, cacheRead: 0.3, output: 15 },
34
+ "claude-haiku-4-5": { input: 1, cacheWrite: 1.25, cacheRead: 0.1, output: 5 },
35
+ "claude-fable-5": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
36
+ };
37
+ const CLAUDE_DEFAULT = CLAUDE_PRICES["claude-opus-4-8"];
38
+ // OpenAI/Codex list-price ESTIMATE (no cache-write line; cached input billed ~10% of input).
39
+ const OPENAI_PRICES = {
40
+ "gpt-5.5": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
41
+ "gpt-5": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
42
+ "gpt-5-codex": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
43
+ };
44
+ const OPENAI_DEFAULT = OPENAI_PRICES["gpt-5"];
45
+ function priceFor(model) {
46
+ const table = model.startsWith("gpt") || model.startsWith("o1") || model.startsWith("o3") ? OPENAI_PRICES : CLAUDE_PRICES;
47
+ const def = table === OPENAI_PRICES ? OPENAI_DEFAULT : CLAUDE_DEFAULT;
48
+ if (table[model])
49
+ return table[model];
50
+ // Prefix match so dated ids (claude-haiku-4-5-20251001) get the family price.
51
+ for (const k of Object.keys(table))
52
+ if (model.startsWith(k))
53
+ return table[k];
54
+ return def;
55
+ }
56
+ function costOf(t, price) {
57
+ return (((t.cold || 0) * price.input +
58
+ (t.cacheWrite || 0) * price.cacheWrite +
59
+ (t.cacheRead || 0) * price.cacheRead +
60
+ (t.output || 0) * price.output) /
61
+ 1_000_000);
62
+ }
63
+ // Files that are "project rules / design principles / repo map" — re-reading these unchanged is the
64
+ // most quotable kind of waste.
65
+ const PRINCIPLE_FILE_RE = /(^claude\.md$|^agents\.md$|^tokens\.css$|^readme|cursorrules|^foundations|principle)/i;
66
+ const IMAGE_FILE_RE = /\.(png|jpe?g|gif|webp|svg|ico|avif|bmp|mp4|mov)$/i;
67
+ // Shell read commands whose target file we try to extract for Codex reread forensics.
68
+ const SHELL_READ_BINS = new Set(["cat", "head", "tail", "less", "more", "bat", "nl", "sed", "awk", "od", "strings"]);
69
+ // ---------------------------------------------------------------------------
70
+ // Local-time helpers (USER's tz — never hardcode a zone)
71
+ // ---------------------------------------------------------------------------
72
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
73
+ function localDay(ms) {
74
+ const d = new Date(ms);
75
+ const m = String(d.getMonth() + 1).padStart(2, "0");
76
+ const day = String(d.getDate()).padStart(2, "0");
77
+ return `${d.getFullYear()}-${m}-${day}`;
78
+ }
79
+ const localHour = (ms) => new Date(ms).getHours();
80
+ const localWeekday = (ms) => WEEKDAYS[new Date(ms).getDay()];
81
+ function round(value, digits = 1) {
82
+ const f = 10 ** digits;
83
+ return Math.round((Number(value) || 0) * f) / f;
84
+ }
85
+ function tokensFromChars(chars = 0) {
86
+ return Math.round((chars || 0) / CHARS_PER_TOKEN);
87
+ }
88
+ function repoLabel(cwd) {
89
+ if (!cwd)
90
+ return "home";
91
+ const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
92
+ const base = path.basename(m ? m[1] : cwd);
93
+ return base && base !== "." && base !== "/" ? base : "home";
94
+ }
95
+ function fileLabel(filePath) {
96
+ return path.basename(String(filePath)) || "unknown";
97
+ }
98
+ function contentLength(content) {
99
+ if (content == null)
100
+ return 0;
101
+ if (typeof content === "string")
102
+ return content.length;
103
+ try {
104
+ return JSON.stringify(content).length;
105
+ }
106
+ catch {
107
+ return 0;
108
+ }
109
+ }
110
+ function isRealUserText(text) {
111
+ if (typeof text !== "string")
112
+ return false;
113
+ const t = text.trim();
114
+ if (!t)
115
+ return false;
116
+ if (t.startsWith("<"))
117
+ return false; // <system-reminder>, <command-…, <local-command…
118
+ if (t.startsWith("Caveat:"))
119
+ return false;
120
+ if (t.startsWith("[Request interrupted"))
121
+ return false;
122
+ if (t.startsWith("This session is being continued"))
123
+ return false;
124
+ if (t.includes("<command-name>") || t.includes("<local-command"))
125
+ return false;
126
+ return true;
127
+ }
128
+ /** Best-effort: pull the file a Codex read-style shell command targets (cat/sed/head FILE). */
129
+ function extractReadPath(cmd) {
130
+ const tokens = String(cmd).trim().split(/\s+/);
131
+ let bin = (tokens[0] || "").split("/").pop() || "";
132
+ if (bin === "sudo")
133
+ bin = (tokens[1] || "").split("/").pop() || "";
134
+ if (!SHELL_READ_BINS.has(bin))
135
+ return null;
136
+ // last token that looks like a path (has a / or a file extension, not a flag/number)
137
+ for (let i = tokens.length - 1; i >= 1; i--) {
138
+ const t = tokens[i].replace(/['"]/g, "");
139
+ if (!t || t.startsWith("-"))
140
+ continue;
141
+ if (t.includes("/") || /\.[A-Za-z0-9]{1,6}$/.test(t))
142
+ return t;
143
+ }
144
+ return null;
145
+ }
146
+ class Forensics {
147
+ models = new Map();
148
+ repos = new Map();
149
+ files = new Map();
150
+ fileVersions = new Map();
151
+ lastReadVersion = new Map();
152
+ fileReadSessions = new Map();
153
+ pendingReads = new Map();
154
+ hourHistogram = new Array(24).fill(0);
155
+ weekdayCounts = new Map();
156
+ lateNightSessions = new Map();
157
+ allSessionIds = new Set();
158
+ realUserTurns = 0;
159
+ minTs = null;
160
+ maxTs = null;
161
+ nightExample = null;
162
+ modelBucket(model) {
163
+ const key = model || "unknown";
164
+ let m = this.models.get(key);
165
+ if (!m) {
166
+ m = { messages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
167
+ this.models.set(key, m);
168
+ }
169
+ return m;
170
+ }
171
+ repoBucket(cwd) {
172
+ const name = repoLabel(cwd);
173
+ let r = this.repos.get(name);
174
+ if (!r) {
175
+ r = { name, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [] };
176
+ this.repos.set(name, r);
177
+ }
178
+ return r;
179
+ }
180
+ fileRecord(p, repo) {
181
+ let rec = this.files.get(p);
182
+ if (!rec) {
183
+ const label = fileLabel(p);
184
+ rec = {
185
+ label, repo, isPrinciple: PRINCIPLE_FILE_RE.test(label), isImage: IMAGE_FILE_RE.test(label),
186
+ totalReads: 0, rereads: 0, staleRereads: 0, crossSessionRereads: 0, everEdited: false, editCount: 0,
187
+ readTokens: 0, staleReadTokens: 0, perSession: new Map(), days: new Set(), nightReads: 0,
188
+ };
189
+ this.files.set(p, rec);
190
+ }
191
+ return rec;
192
+ }
193
+ noteTs(ms) {
194
+ if (this.minTs == null || ms < this.minTs)
195
+ this.minTs = ms;
196
+ if (this.maxTs == null || ms > this.maxTs)
197
+ this.maxTs = ms;
198
+ }
199
+ noteSession(id) {
200
+ if (id)
201
+ this.allSessionIds.add(id);
202
+ }
203
+ /** assistant-turn token usage (already split into cold/cacheWrite/cacheRead/output). */
204
+ recordUsage(model, cwd, session, u) {
205
+ const m = this.modelBucket(model);
206
+ m.messages += 1;
207
+ m.cold += u.cold;
208
+ m.cacheWrite += u.cacheWrite;
209
+ m.cacheRead += u.cacheRead;
210
+ m.output += u.output;
211
+ if (cwd) {
212
+ const r = this.repoBucket(cwd);
213
+ if (session)
214
+ r.sessions.add(session);
215
+ r.assistantMessages += 1;
216
+ r.cold += u.cold;
217
+ r.cacheWrite += u.cacheWrite;
218
+ r.cacheRead += u.cacheRead;
219
+ r.output += u.output;
220
+ }
221
+ }
222
+ recordEdit(target, cwd) {
223
+ if (!target)
224
+ return;
225
+ this.fileVersions.set(target, (this.fileVersions.get(target) || 0) + 1);
226
+ const rec = this.fileRecord(target, repoLabel(cwd));
227
+ rec.everEdited = true;
228
+ rec.editCount += 1;
229
+ }
230
+ /** A file read; decides stale/cross-session at read time and parks the token settle by toolId. */
231
+ recordRead(target, cwd, session, ms, toolId) {
232
+ if (!target)
233
+ return;
234
+ const repo = repoLabel(cwd);
235
+ const rec = this.fileRecord(target, repo);
236
+ const priorReads = rec.totalReads;
237
+ const currentVersion = this.fileVersions.get(target) || 0;
238
+ const seenSessions = this.fileReadSessions.get(target) || new Set();
239
+ const isReread = priorReads > 0;
240
+ const crossSession = isReread && !!session && !seenSessions.has(session);
241
+ const changedSinceLastRead = isReread && currentVersion > (this.lastReadVersion.get(target) || 0);
242
+ const stale = isReread && !changedSinceLastRead;
243
+ rec.totalReads += 1;
244
+ if (cwd)
245
+ this.repoBucket(cwd).reads += 1;
246
+ if (isReread) {
247
+ rec.rereads += 1;
248
+ if (cwd)
249
+ this.repoBucket(cwd).rereads += 1;
250
+ }
251
+ if (crossSession)
252
+ rec.crossSessionRereads += 1;
253
+ if (stale) {
254
+ rec.staleRereads += 1;
255
+ if (cwd)
256
+ this.repoBucket(cwd).staleRereads += 1;
257
+ }
258
+ const day = ms != null ? localDay(ms) : "unknown";
259
+ const hour = ms != null ? localHour(ms) : 12;
260
+ const isNight = NIGHT_HOURS.has(hour);
261
+ rec.days.add(day);
262
+ if (isNight)
263
+ rec.nightReads += 1;
264
+ const ps = rec.perSession.get(session || "") || { count: 0, day, nightCount: 0 };
265
+ ps.count += 1;
266
+ if (isNight)
267
+ ps.nightCount += 1;
268
+ rec.perSession.set(session || "", ps);
269
+ if (stale && isNight) {
270
+ const candidate = { file: rec.label, path: target, repo, day, hour, sessionShort: (session || "").slice(0, 8), priorReads };
271
+ if (!this.nightExample || priorReads > this.nightExample.priorReads)
272
+ this.nightExample = candidate;
273
+ }
274
+ if (!this.fileReadSessions.has(target))
275
+ this.fileReadSessions.set(target, new Set());
276
+ if (session)
277
+ this.fileReadSessions.get(target).add(session);
278
+ this.lastReadVersion.set(target, currentVersion);
279
+ if (toolId)
280
+ this.pendingReads.set(toolId, { rec, stale });
281
+ }
282
+ recordReadOutput(toolId, chars) {
283
+ const pending = this.pendingReads.get(toolId);
284
+ if (!pending)
285
+ return;
286
+ const tokens = tokensFromChars(chars);
287
+ pending.rec.readTokens += tokens;
288
+ if (pending.stale)
289
+ pending.rec.staleReadTokens += tokens;
290
+ this.pendingReads.delete(toolId);
291
+ }
292
+ recordUserMsg(cwd, session, ms) {
293
+ this.realUserTurns += 1;
294
+ const hour = localHour(ms);
295
+ this.hourHistogram[hour] += 1;
296
+ const wd = localWeekday(ms);
297
+ this.weekdayCounts.set(wd, (this.weekdayCounts.get(wd) || 0) + 1);
298
+ if (cwd)
299
+ this.repoBucket(cwd).userTimestamps.push(ms);
300
+ if (NIGHT_HOURS.has(hour) && session) {
301
+ const ln = this.lateNightSessions.get(session) || { repo: repoLabel(cwd), day: localDay(ms), nightMsgs: 0 };
302
+ ln.nightMsgs += 1;
303
+ this.lateNightSessions.set(session, ln);
304
+ }
305
+ }
306
+ // ----- work blocks (real attention hours) -----
307
+ workBlocks(timestamps) {
308
+ const sorted = [...timestamps].sort((a, b) => a - b);
309
+ const blocks = [];
310
+ let start = null;
311
+ let last = null;
312
+ for (const ms of sorted) {
313
+ if (start == null) {
314
+ start = ms;
315
+ last = ms;
316
+ continue;
317
+ }
318
+ if (ms - last > WORK_BLOCK_GAP_MS) {
319
+ blocks.push({ start, end: last });
320
+ start = ms;
321
+ }
322
+ last = ms;
323
+ }
324
+ if (start != null)
325
+ blocks.push({ start, end: last });
326
+ const attentionHours = blocks.reduce((s, b) => s + (b.end - b.start) / 3_600_000, 0);
327
+ const days = new Set(blocks.map((b) => localDay(b.start)));
328
+ return { blocks, attentionHours, activeDays: days.size };
329
+ }
330
+ build() {
331
+ // ----- tokens + cost -----
332
+ let cold = 0, cacheWrite = 0, cacheRead = 0, output = 0;
333
+ const modelOut = {};
334
+ for (const [name, m] of this.models) {
335
+ cold += m.cold;
336
+ cacheWrite += m.cacheWrite;
337
+ cacheRead += m.cacheRead;
338
+ output += m.output;
339
+ if (name === "<synthetic>" || m.cold + m.cacheWrite + m.cacheRead + m.output === 0)
340
+ continue;
341
+ const price = priceFor(name);
342
+ modelOut[name] = {
343
+ messages: m.messages, cold: m.cold, cacheWrite: m.cacheWrite, cacheRead: m.cacheRead, output: m.output,
344
+ total: m.cold + m.cacheWrite + m.cacheRead + m.output, cost: round(costOf(m, price), 2),
345
+ };
346
+ }
347
+ const totalInput = cold + cacheWrite + cacheRead;
348
+ const totalTokens = totalInput + output;
349
+ const cost = {
350
+ coldInputCost: round((cold / 1e6) * CLAUDE_DEFAULT.input, 2),
351
+ cacheWriteCost: round((cacheWrite / 1e6) * CLAUDE_DEFAULT.cacheWrite, 2),
352
+ cacheReadCost: round((cacheRead / 1e6) * CLAUDE_DEFAULT.cacheRead, 2),
353
+ outputCost: round((output / 1e6) * CLAUDE_DEFAULT.output, 2),
354
+ total: 0,
355
+ };
356
+ // total cost = sum of per-model costs (so mixed providers price correctly)
357
+ cost.total = round(Object.values(modelOut).reduce((s, m) => s + m.cost, 0), 2);
358
+ const rankedModels = Object.entries(modelOut).sort((a, b) => b[1].total - a[1].total);
359
+ const topModel = rankedModels[0]?.[0] || "unknown";
360
+ const topShare = totalTokens ? Math.round(((rankedModels[0]?.[1].total || 0) / totalTokens) * 100) : 0;
361
+ let identityLabel = "workspace power user";
362
+ if (topShare >= 95)
363
+ identityLabel = "all-in frontier-model builder";
364
+ else if (rankedModels.length >= 3)
365
+ identityLabel = "multi-model operator";
366
+ else if (topShare >= 75)
367
+ identityLabel = "primary-model power user";
368
+ // ----- per-repo attention + global -----
369
+ const allUserTimestamps = [];
370
+ const dayAttention = new Map();
371
+ const repoOut = [];
372
+ for (const repo of this.repos.values()) {
373
+ allUserTimestamps.push(...repo.userTimestamps);
374
+ const wb = this.workBlocks(repo.userTimestamps);
375
+ repoOut.push({
376
+ name: repo.name, sessions: repo.sessions.size, assistantMessages: repo.assistantMessages,
377
+ attentionHours: round(wb.attentionHours, 1), activeAttentionDays: wb.activeDays,
378
+ dailyFocusHours: wb.activeDays ? round(wb.attentionHours / wb.activeDays, 2) : 0,
379
+ equivalentFocusDays: round(wb.attentionHours / FOCUS_DAY_HOURS, 1), workBlocks: wb.blocks.length,
380
+ tokens: repo.cold + repo.cacheWrite + repo.cacheRead + repo.output,
381
+ cost: round(costOf({ cold: repo.cold, cacheWrite: repo.cacheWrite, cacheRead: repo.cacheRead, output: repo.output }, CLAUDE_DEFAULT), 2),
382
+ reads: repo.reads, rereads: repo.rereads, staleRereads: repo.staleRereads,
383
+ });
384
+ for (const b of wb.blocks) {
385
+ const d = localDay(b.start);
386
+ dayAttention.set(d, (dayAttention.get(d) || 0) + (b.end - b.start) / 3_600_000);
387
+ }
388
+ }
389
+ repoOut.sort((a, b) => b.attentionHours - a.attentionHours || b.tokens - a.tokens);
390
+ const globalBlocks = this.workBlocks(allUserTimestamps);
391
+ const userAttentionHours = round(globalBlocks.attentionHours, 1);
392
+ const activeAttentionDays = globalBlocks.activeDays;
393
+ const dayHoursList = [...dayAttention.entries()].sort((a, b) => b[1] - a[1]);
394
+ const peakDay = dayHoursList[0] ? { date: dayHoursList[0][0], hours: round(dayHoursList[0][1], 1) } : null;
395
+ // ----- rhythm -----
396
+ const sumHours = (hrs) => hrs.reduce((s, h) => s + this.hourHistogram[h], 0);
397
+ const totalMsgForShare = Math.max(this.realUserTurns, 1);
398
+ const busiestWeekday = [...this.weekdayCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null;
399
+ const lateNightSessionList = [...this.lateNightSessions.entries()]
400
+ .map(([id, v]) => ({ session: id.slice(0, 8), repo: v.repo, day: v.day, nightMsgs: v.nightMsgs }))
401
+ .sort((a, b) => b.nightMsgs - a.nightMsgs)
402
+ .slice(0, 6);
403
+ // ----- cleanliness (token-weighted; non-image code/text reads only) -----
404
+ const codeFiles = [...this.files.values()].filter((f) => !f.isImage && f.totalReads > 0);
405
+ const codeReadTokens = codeFiles.reduce((s, f) => s + f.readTokens, 0);
406
+ const codeStaleReadTokens = codeFiles.reduce((s, f) => s + f.staleReadTokens, 0);
407
+ const codeReads = codeFiles.reduce((s, f) => s + f.totalReads, 0);
408
+ const codeRereads = codeFiles.reduce((s, f) => s + f.rereads, 0);
409
+ const codeStaleRereads = codeFiles.reduce((s, f) => s + f.staleRereads, 0);
410
+ const codeCrossStale = codeFiles.reduce((s, f) => s + f.crossSessionRereads, 0);
411
+ const staleShare = codeReadTokens ? codeStaleReadTokens / codeReadTokens : 0;
412
+ const cleanlinessScore = Math.max(0, Math.min(100, Math.round(100 * (1 - staleShare))));
413
+ // ----- reread forensics -----
414
+ const ranked = [...this.files.values()]
415
+ .filter((f) => f.totalReads > 0 && !f.isImage)
416
+ .sort((a, b) => b.crossSessionRereads - a.crossSessionRereads || b.perSession.size - a.perSession.size || b.rereads - a.rereads || b.totalReads - a.totalReads);
417
+ const topFiles = ranked.slice(0, 12).map((f) => ({
418
+ file: f.label, repo: f.repo, isPrinciple: f.isPrinciple, totalReads: f.totalReads, rereads: f.rereads,
419
+ staleRereads: f.staleRereads, crossSessionRereads: f.crossSessionRereads, everChanged: f.everEdited,
420
+ editCount: f.editCount, sessionsTouched: f.perSession.size, daysTouched: f.days.size, nightReads: f.nightReads,
421
+ readTokens: f.readTokens, staleReadTokens: f.staleReadTokens,
422
+ perSession: [...f.perSession.entries()].map(([id, v]) => ({ session: (id || "").slice(0, 8), count: v.count, day: v.day, nightCount: v.nightCount })).sort((a, b) => b.count - a.count).slice(0, 6),
423
+ }));
424
+ const neverChangedHeavy = ranked.filter((f) => !f.everEdited && f.rereads >= 2).slice(0, 8).map((f) => ({
425
+ file: f.label, repo: f.repo, isPrinciple: f.isPrinciple, totalReads: f.totalReads,
426
+ sessionsTouched: f.perSession.size, daysTouched: f.days.size, nightReads: f.nightReads, staleReadTokens: f.staleReadTokens,
427
+ }));
428
+ const principleReads = ranked.filter((f) => f.isPrinciple && f.rereads > 0).sort((a, b) => b.staleReadTokens - a.staleReadTokens).slice(0, 6).map((f) => ({
429
+ file: f.label, repo: f.repo, totalReads: f.totalReads, staleReadTokens: f.staleReadTokens, sessionsTouched: f.perSession.size,
430
+ }));
431
+ // ----- avoidable wait + counterfactual (ESTIMATES) -----
432
+ const dailyFocus = activeAttentionDays ? userAttentionHours / activeAttentionDays : repoOut[0]?.dailyFocusHours || 0;
433
+ const avoidableWaitHours = round(userAttentionHours * staleShare * CONTEXT_REACQUISITION_FRACTION, 1);
434
+ const avoidableWaitDays = dailyFocus ? round(avoidableWaitHours / dailyFocus, 1) : 0;
435
+ const daysAgo = Math.max(1, Math.round(avoidableWaitDays));
436
+ const scanEnd = this.maxTs ?? Date.now();
437
+ const cfDate = scanEnd - daysAgo * 86_400_000;
438
+ return {
439
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
440
+ llmCallsUsed: 0,
441
+ transcriptsUploaded: false,
442
+ scanStartDate: this.minTs != null ? localDay(this.minTs) : null,
443
+ scanEndDate: this.maxTs != null ? localDay(this.maxTs) : null,
444
+ scale: {
445
+ sessionCount: this.allSessionIds.size,
446
+ repoCount: repoOut.length,
447
+ activeDays: activeAttentionDays,
448
+ totalTokens, totalInputTokens: totalInput, coldInputTokens: cold, cacheWriteTokens: cacheWrite,
449
+ cacheReadTokens: cacheRead, outputTokens: output,
450
+ inputPct: totalTokens ? Math.round((totalInput / totalTokens) * 100) : 0,
451
+ },
452
+ cost: { ...cost, byModel: modelOut },
453
+ modelUsage: { topModel, topModelShare: topShare, identityLabel },
454
+ userWorkingHours: {
455
+ realUserTurns: this.realUserTurns, userAttentionHours, activeAttentionDays,
456
+ averageDailyAttentionHours: activeAttentionDays ? round(userAttentionHours / activeAttentionDays, 2) : 0,
457
+ maxDailyAttentionHours: peakDay ? peakDay.hours : 0, peakDay, workBlocks: globalBlocks.blocks.length,
458
+ },
459
+ repos: repoOut,
460
+ rhythm: {
461
+ hourHistogram: this.hourHistogram,
462
+ morningShare: Math.round((sumHours([5, 6, 7, 8, 9, 10, 11]) / totalMsgForShare) * 100),
463
+ eveningShare: Math.round((sumHours([18, 19, 20, 21]) / totalMsgForShare) * 100),
464
+ lateNightShare: Math.round((sumHours([22, 23, 0, 1, 2, 3]) / totalMsgForShare) * 100),
465
+ lateNightSessionCount: this.lateNightSessions.size, lateNightSessions: lateNightSessionList,
466
+ busiestWeekday, peakDay,
467
+ },
468
+ cleanliness: {
469
+ score: cleanlinessScore, totalReads: codeReads, rereads: codeRereads, staleRereads: codeStaleRereads,
470
+ crossSessionStaleReads: codeCrossStale, totalReadTokens: codeReadTokens, staleReadTokens: codeStaleReadTokens,
471
+ staleReadSharePct: Math.round(staleShare * 100),
472
+ },
473
+ rereadForensics: { topFiles, neverChangedHeavy, principleReads, nightExample: this.nightExample },
474
+ avoidableWait: { readTokens: codeStaleReadTokens, hours: avoidableWaitHours, days: avoidableWaitDays, cost: round(costOf({ cold: codeStaleReadTokens }, CLAUDE_DEFAULT), 2) },
475
+ counterfactual: { daysAgo, counterfactualDate: localDay(cfDate), daysGained: avoidableWaitDays, targetScore: Math.min(100, cleanlinessScore + 30) },
476
+ };
477
+ }
478
+ }
479
+ // ---------------------------------------------------------------------------
480
+ // Source adapters — translate each log schema into engine calls
481
+ // ---------------------------------------------------------------------------
482
+ /** Claude Code (~/.claude/projects). usage is per assistant turn; tools live in message.content. */
483
+ function feedClaude(file, eng) {
484
+ eachLine(file, (o) => {
485
+ const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
486
+ if (Number.isFinite(ts))
487
+ eng.noteTs(ts);
488
+ const session = o.sessionId || null;
489
+ eng.noteSession(session);
490
+ const cwd = o.cwd || null;
491
+ if (o.type === "assistant" && o.message) {
492
+ const u = o.message.usage || {};
493
+ eng.recordUsage(o.message.model || "unknown", cwd, session, {
494
+ cold: u.input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0,
495
+ cacheRead: u.cache_read_input_tokens || 0, output: u.output_tokens || 0,
496
+ });
497
+ const blocks = Array.isArray(o.message.content) ? o.message.content : [];
498
+ for (const b of blocks) {
499
+ if (b?.type !== "tool_use")
500
+ continue;
501
+ const name = String(b.name || "");
502
+ if (name === "Edit" || name === "Write" || name === "MultiEdit") {
503
+ const target = b.input?.file_path || b.input?.path;
504
+ if (target)
505
+ eng.recordEdit(target, cwd);
506
+ }
507
+ else if (name === "Read") {
508
+ const target = b.input?.file_path;
509
+ if (target)
510
+ eng.recordRead(target, cwd, session, Number.isFinite(ts) ? ts : null, b.id || null);
511
+ }
512
+ }
513
+ return;
514
+ }
515
+ if (o.type === "user" && o.message) {
516
+ const content = o.message.content;
517
+ if (typeof content === "string") {
518
+ if (isRealUserText(content) && Number.isFinite(ts) && !o.isSidechain)
519
+ eng.recordUserMsg(cwd, session, ts);
520
+ }
521
+ else if (Array.isArray(content)) {
522
+ for (const b of content) {
523
+ if (b?.type === "tool_result" && b.tool_use_id)
524
+ eng.recordReadOutput(b.tool_use_id, contentLength(b.content));
525
+ }
526
+ }
527
+ }
528
+ });
529
+ }
530
+ /** Parse one Codex rollout into a compact, cacheable FileEvents. token_count is CUMULATIVE (delta'd);
531
+ * edits arrive via patch_apply_end; reads are exec_command shell commands (path extracted best-effort). */
532
+ function extractCodex(file) {
533
+ let cwd = null;
534
+ let session = null;
535
+ // Default to a Codex/OpenAI model so token counts logged before the model field appears are priced
536
+ // as OpenAI (not the Claude opus default) — the real model overrides this as soon as it's seen.
537
+ let model = "gpt-5-codex";
538
+ let prev = { cold: 0, cacheRead: 0, output: 0 }; // last cumulative seen (for deltas)
539
+ let firstTs = null;
540
+ let lastTs = null;
541
+ const usageByModel = new Map();
542
+ const ev = [];
543
+ eachLine(file, (o) => {
544
+ const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
545
+ if (Number.isFinite(ts)) {
546
+ if (firstTs == null)
547
+ firstTs = ts;
548
+ lastTs = ts;
549
+ }
550
+ const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
551
+ if (!p || typeof p !== "object")
552
+ return;
553
+ if (o.type === "session_meta") {
554
+ if (typeof p.cwd === "string")
555
+ cwd = p.cwd;
556
+ if (typeof p.id === "string" && !session)
557
+ session = p.id;
558
+ return;
559
+ }
560
+ if (typeof p.model === "string")
561
+ model = p.model;
562
+ if (p.type === "token_count") {
563
+ const u = p.info?.total_token_usage;
564
+ if (u) {
565
+ const inputTotal = u.input_tokens || 0;
566
+ const cacheRead = u.cached_input_tokens || 0;
567
+ const cold = Math.max(0, inputTotal - cacheRead);
568
+ const out = (u.output_tokens || 0) + (u.reasoning_output_tokens || 0);
569
+ const dCold = Math.max(0, cold - prev.cold);
570
+ const dCr = Math.max(0, cacheRead - prev.cacheRead);
571
+ const dOut = Math.max(0, out - prev.output);
572
+ if (dCold || dCr || dOut) {
573
+ const acc = usageByModel.get(model) || { cold: 0, cacheRead: 0, output: 0 };
574
+ acc.cold += dCold;
575
+ acc.cacheRead += dCr;
576
+ acc.output += dOut;
577
+ usageByModel.set(model, acc);
578
+ }
579
+ prev = { cold, cacheRead, output: out };
580
+ }
581
+ return;
582
+ }
583
+ if (p.type === "user_message") {
584
+ if (Number.isFinite(ts))
585
+ ev.push({ t: "m", ms: ts });
586
+ return;
587
+ }
588
+ if (p.type === "patch_apply_end" && p.changes && typeof p.changes === "object") {
589
+ for (const target of Object.keys(p.changes))
590
+ ev.push({ t: "e", path: target });
591
+ return;
592
+ }
593
+ // older format: exec_command_end with parsed_cmd[].type==='read'
594
+ if (p.type === "exec_command_end" && Array.isArray(p.parsed_cmd)) {
595
+ for (const c of p.parsed_cmd) {
596
+ if (c?.type === "read" && typeof c.path === "string")
597
+ ev.push({ t: "r", path: c.path, ms: Number.isFinite(ts) ? ts : null, id: p.call_id || null });
598
+ }
599
+ return;
600
+ }
601
+ // newer format: reads are exec_command function_calls; extract the path best-effort
602
+ if (p.type === "function_call" && (p.name === "exec_command" || p.name === "shell")) {
603
+ try {
604
+ const args = JSON.parse(typeof p.arguments === "string" ? p.arguments : "{}");
605
+ const target = typeof args.cmd === "string" ? extractReadPath(args.cmd) : null;
606
+ if (target)
607
+ ev.push({ t: "r", path: target, ms: Number.isFinite(ts) ? ts : null, id: p.call_id || null });
608
+ }
609
+ catch {
610
+ /* skip */
611
+ }
612
+ return;
613
+ }
614
+ if (p.type === "function_call_output" && p.call_id) {
615
+ ev.push({ t: "o", id: p.call_id, chars: contentLength(p.output) });
616
+ }
617
+ });
618
+ const usage = [...usageByModel.entries()].map(([m, u]) => ({ model: m, cold: u.cold, cacheRead: u.cacheRead, output: u.output }));
619
+ return { source: "codex", session, cwd, firstTs, lastTs, usage, ev };
620
+ }
621
+ /** Replay a parsed FileEvents into the engine. Usage is order-independent; reads/edits keep file order
622
+ * so cross-session reread detection is identical to a live scan of the same files in the same order. */
623
+ function replayFile(eng, fe) {
624
+ if (fe.firstTs != null)
625
+ eng.noteTs(fe.firstTs);
626
+ if (fe.lastTs != null)
627
+ eng.noteTs(fe.lastTs);
628
+ eng.noteSession(fe.session);
629
+ for (const u of fe.usage)
630
+ eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output });
631
+ for (const e of fe.ev) {
632
+ if (e.t === "m")
633
+ eng.recordUserMsg(fe.cwd, fe.session, e.ms);
634
+ else if (e.t === "e")
635
+ eng.recordEdit(e.path, fe.cwd);
636
+ else if (e.t === "r")
637
+ eng.recordRead(e.path, fe.cwd, fe.session, e.ms, e.id);
638
+ else if (e.t === "o")
639
+ eng.recordReadOutput(e.id, e.chars);
640
+ }
641
+ }
642
+ // ---------------------------------------------------------------------------
643
+ // Per-file parse cache — keyed by path+mtime+size so re-runs only reparse changed/new Codex files.
644
+ // ---------------------------------------------------------------------------
645
+ const CACHE_VERSION = 1;
646
+ function forensicCachePath() {
647
+ return path.join(os.homedir(), ".echomem", "forensic-cache.json");
648
+ }
649
+ function loadForensicCache() {
650
+ try {
651
+ const parsed = JSON.parse(fs.readFileSync(forensicCachePath(), "utf8"));
652
+ if (parsed && parsed.v === CACHE_VERSION && parsed.files && typeof parsed.files === "object")
653
+ return parsed.files;
654
+ }
655
+ catch {
656
+ /* no cache yet, or a stale/corrupt one — rebuild */
657
+ }
658
+ return {};
659
+ }
660
+ function saveForensicCache(files) {
661
+ try {
662
+ const p = forensicCachePath();
663
+ fs.mkdirSync(path.dirname(p), { recursive: true });
664
+ fs.writeFileSync(p, JSON.stringify({ v: CACHE_VERSION, files }));
665
+ }
666
+ catch {
667
+ /* best effort: a cache write failure must never break the scan */
668
+ }
669
+ }
670
+ /** Scan both sources and build the merged forensic report. Local-only, $0, never throws on bad files. */
671
+ export function buildForensicReport(opts) {
672
+ const sources = opts?.sources ?? ["codex", "claude"];
673
+ const eng = new Forensics();
674
+ // Path-sorted within each source so the order-dependent reread/version state is deterministic.
675
+ const codexFiles = sources.includes("codex")
676
+ ? walk(path.join(os.homedir(), ".codex", "sessions"), (p) => /rollout-.*\.jsonl$/.test(p), () => false).sort()
677
+ : [];
678
+ // Include ALL Claude transcripts (subagents/workflows too): their reads/tokens are real agent activity.
679
+ const claudeFiles = sources.includes("claude")
680
+ ? walk(path.join(os.homedir(), ".claude", "projects"), (p) => p.endsWith(".jsonl"), () => false).sort()
681
+ : [];
682
+ const total = codexFiles.length + claudeFiles.length;
683
+ let done = 0;
684
+ const tick = () => {
685
+ done++;
686
+ if (opts?.onProgress && (done % 8 === 0 || done === total))
687
+ opts.onProgress(done, total);
688
+ };
689
+ // Codex is the slow source (GB-scale rollouts). Cache each file's parse by mtime+size so only
690
+ // changed/new sessions reparse; the cross-session reread state is rebuilt from the cached events in
691
+ // the same file order, so the result is identical to a live scan.
692
+ const cache = loadForensicCache();
693
+ const nextCache = {};
694
+ for (const f of codexFiles) {
695
+ let st;
696
+ try {
697
+ st = fs.statSync(f);
698
+ }
699
+ catch {
700
+ tick();
701
+ continue;
702
+ }
703
+ const hit = cache[f];
704
+ const fe = hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size ? hit.fe : extractCodex(f);
705
+ nextCache[f] = { mtimeMs: st.mtimeMs, size: st.size, fe };
706
+ replayFile(eng, fe);
707
+ tick();
708
+ }
709
+ if (codexFiles.length)
710
+ saveForensicCache(nextCache);
711
+ // Claude is fast (~0.6s) — scan live, no cache needed.
712
+ for (const f of claudeFiles) {
713
+ feedClaude(f, eng);
714
+ tick();
715
+ }
716
+ return eng.build();
717
+ }