@echomem/mcp 1.4.7 → 1.4.9

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.
Files changed (46) hide show
  1. package/README.md +35 -9
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
package/dist/forensics.js CHANGED
@@ -18,7 +18,13 @@ import fs from "node:fs";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
20
  import { execFileSync } from "node:child_process";
21
+ import { calculateContextMetrics } from "./context-metrics/calculate.js";
22
+ import { resolveModelContextLimit } from "./context-metrics/model-limits.js";
23
+ import { buildWorkspaceContextReport } from "./context-analysis/workspace-report.js";
24
+ import { buildCanonicalGoldenReport } from "./context-analysis/canonical-golden.js";
21
25
  import { eachLine, walk } from "./report.js";
26
+ import { discoverCodexSessionFiles } from "./codex-session-files.js";
27
+ import { resolveClaudeProjectsDir } from "./local-data-paths.js";
22
28
  /** Fast, safe per-repo commit count since a date. Metadata only (no diffs) so it stays cheap;
23
29
  * `rev-list --count` is git-indexed. Any failure (not a repo, git missing, timeout) → 0. */
24
30
  function gitCommitCount(cwd, sinceIso) {
@@ -45,39 +51,96 @@ const FOCUS_DAY_HOURS = 5; // one "focused work day" == 5h of attention, for day
45
51
  const CONTEXT_REACQUISITION_FRACTION = 0.5; // ESTIMATE: share of stale-reread time felt as avoidable wait
46
52
  const NIGHT_HOURS = new Set([22, 23, 0, 1, 2, 3]); // late-night grind window (local tz)
47
53
  const CHARS_PER_TOKEN = 4; // rough token estimate for read (tool-result) output size
48
- // Per-million-token USD. API-equivalent ESTIMATE at list prices.
54
+ // Per-million-token USD. Standard API-equivalent list prices, reviewed 2026-07-12.
55
+ // These are deliberately model-specific: using an Opus fallback for every repo was the source of
56
+ // the old impossible city math where two repo labels alone exceeded TOTAL SPENT.
49
57
  const CLAUDE_PRICES = {
50
- "claude-opus-4-8": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
51
- "claude-opus-4-7": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
58
+ "claude-fable-5": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
59
+ "claude-opus-4-8": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
60
+ "claude-opus-4-7": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
61
+ "claude-opus-4-6": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
62
+ "claude-opus-4-5": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
52
63
  "claude-sonnet-4-6": { input: 3, cacheWrite: 3.75, cacheRead: 0.3, output: 15 },
64
+ "claude-sonnet-4-5": { input: 3, cacheWrite: 3.75, cacheRead: 0.3, output: 15 },
53
65
  "claude-haiku-4-5": { input: 1, cacheWrite: 1.25, cacheRead: 0.1, output: 5 },
54
- "claude-fable-5": { input: 15, cacheWrite: 18.75, cacheRead: 1.5, output: 75 },
55
66
  };
56
- const CLAUDE_DEFAULT = CLAUDE_PRICES["claude-opus-4-8"];
57
- // OpenAI/Codex list-price ESTIMATE (no cache-write line; cached input billed ~10% of input).
67
+ const CLAUDE_SONNET_5_INTRO = { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 };
68
+ const CLAUDE_SONNET_5_STANDARD = { input: 3, cacheWrite: 3.75, cacheRead: 0.3, output: 15 };
69
+ const CLAUDE_SONNET_5_STANDARD_START_MS = Date.parse("2026-09-01T00:00:00.000Z");
70
+ const CLAUDE_DEFAULT = CLAUDE_PRICES["claude-sonnet-4-6"];
71
+ // OpenAI/Codex list prices. GPT-5.6 adds explicit cache writes; older Codex logs expose only
72
+ // uncached and cached input, so their cacheWrite rate remains zero.
58
73
  const OPENAI_PRICES = {
59
- "gpt-5.5": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
74
+ "gpt-5.6-terra": { input: 2.5, cacheWrite: 3.125, cacheRead: 0.25, output: 15 },
75
+ "gpt-5.6-luna": { input: 1, cacheWrite: 1.25, cacheRead: 0.1, output: 6 },
76
+ "gpt-5.6-sol": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 30 },
77
+ "gpt-5.6": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 30 },
78
+ "gpt-5.4-mini": { input: 0.75, cacheWrite: 0, cacheRead: 0.075, output: 4.5 },
79
+ "gpt-5.4-nano": { input: 0.2, cacheWrite: 0, cacheRead: 0.02, output: 1.25 },
80
+ "gpt-5.5": { input: 5, cacheWrite: 0, cacheRead: 0.5, output: 30 },
81
+ "gpt-5.4": { input: 2.5, cacheWrite: 0, cacheRead: 0.25, output: 15 },
60
82
  "gpt-5": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
61
83
  "gpt-5-codex": { input: 1.25, cacheWrite: 0, cacheRead: 0.125, output: 10 },
62
84
  };
63
85
  const OPENAI_DEFAULT = OPENAI_PRICES["gpt-5"];
64
- function priceFor(model) {
86
+ function longestPrefixPrice(table, model) {
87
+ const key = Object.keys(table)
88
+ .filter((candidate) => model.startsWith(candidate))
89
+ .sort((a, b) => b.length - a.length)[0];
90
+ return key ? table[key] : null;
91
+ }
92
+ function priceFor(model, ms) {
93
+ if (model.startsWith("claude-sonnet-5")) {
94
+ const at = typeof ms === "number" && Number.isFinite(ms) ? ms : Date.now();
95
+ return at < CLAUDE_SONNET_5_STANDARD_START_MS ? CLAUDE_SONNET_5_INTRO : CLAUDE_SONNET_5_STANDARD;
96
+ }
65
97
  const table = model.startsWith("gpt") || model.startsWith("o1") || model.startsWith("o3") ? OPENAI_PRICES : CLAUDE_PRICES;
66
98
  const def = table === OPENAI_PRICES ? OPENAI_DEFAULT : CLAUDE_DEFAULT;
67
- if (table[model])
68
- return table[model];
69
- // Prefix match so dated ids (claude-haiku-4-5-20251001) get the family price.
70
- for (const k of Object.keys(table))
71
- if (model.startsWith(k))
72
- return table[k];
73
- return def;
74
- }
75
- function costOf(t, price) {
76
- return (((t.cold || 0) * price.input +
77
- (t.cacheWrite || 0) * price.cacheWrite +
78
- (t.cacheRead || 0) * price.cacheRead +
79
- (t.output || 0) * price.output) /
80
- 1_000_000);
99
+ return table[model] || longestPrefixPrice(table, model) || def;
100
+ }
101
+ function hasOpenAiLongContextPremium(model, inputTokens) {
102
+ if (inputTokens <= 272_000 || model.startsWith("gpt-5.4-mini") || model.startsWith("gpt-5.4-nano"))
103
+ return false;
104
+ return model.startsWith("gpt-5.4") || model.startsWith("gpt-5.5") || model.startsWith("gpt-5.6");
105
+ }
106
+ export function estimateApiEquivalentCost(model, t, ms) {
107
+ const price = priceFor(model, ms);
108
+ const inputTokens = (t.cold || 0) + (t.cacheWrite || 0) + (t.cacheRead || 0);
109
+ const longContextPremiumApplied = hasOpenAiLongContextPremium(model, inputTokens);
110
+ const inputMultiplier = longContextPremiumApplied ? 2 : 1;
111
+ const outputMultiplier = longContextPremiumApplied ? 1.5 : 1;
112
+ const coldInputCost = ((t.cold || 0) * price.input * inputMultiplier) / 1_000_000;
113
+ const cacheWriteCost = ((t.cacheWrite || 0) * price.cacheWrite * inputMultiplier) / 1_000_000;
114
+ const cacheReadCost = ((t.cacheRead || 0) * price.cacheRead * inputMultiplier) / 1_000_000;
115
+ const outputCost = ((t.output || 0) * price.output * outputMultiplier) / 1_000_000;
116
+ return {
117
+ coldInputCost,
118
+ cacheWriteCost,
119
+ cacheReadCost,
120
+ outputCost,
121
+ total: coldInputCost + cacheWriteCost + cacheReadCost + outputCost,
122
+ longContextPremiumApplied,
123
+ };
124
+ }
125
+ function emptyCostAccumulator() {
126
+ return { coldInputCost: 0, cacheWriteCost: 0, cacheReadCost: 0, outputCost: 0, total: 0, longContextRequestCount: 0 };
127
+ }
128
+ function addEstimatedCost(target, value) {
129
+ target.coldInputCost += value.coldInputCost;
130
+ target.cacheWriteCost += value.cacheWriteCost;
131
+ target.cacheReadCost += value.cacheReadCost;
132
+ target.outputCost += value.outputCost;
133
+ target.total += value.total;
134
+ if (value.longContextPremiumApplied)
135
+ target.longContextRequestCount += 1;
136
+ }
137
+ function addAccumulatedCost(target, value) {
138
+ target.coldInputCost += value.coldInputCost;
139
+ target.cacheWriteCost += value.cacheWriteCost;
140
+ target.cacheReadCost += value.cacheReadCost;
141
+ target.outputCost += value.outputCost;
142
+ target.total += value.total;
143
+ target.longContextRequestCount += value.longContextRequestCount;
81
144
  }
82
145
  // Files that are "project rules / design principles / repo map" — re-reading these unchanged is the
83
146
  // most quotable kind of waste.
@@ -111,6 +174,23 @@ export function repoLabel(cwd) {
111
174
  const base = path.basename(m ? m[1] : cwd);
112
175
  return base && base !== "." && base !== "/" ? base : "home";
113
176
  }
177
+ function fileIdentity(target, cwd) {
178
+ const value = String(target).trim();
179
+ if (!value)
180
+ return "unknown";
181
+ if (value === "~")
182
+ return os.homedir();
183
+ if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
184
+ return path.normalize(path.join(os.homedir(), value.slice(2)));
185
+ }
186
+ if (path.isAbsolute(value))
187
+ return path.normalize(value);
188
+ if (cwd && path.isAbsolute(cwd))
189
+ return path.resolve(cwd, value);
190
+ // An unknown/relative cwd cannot safely be resolved against the MCP process cwd. Namespace it so
191
+ // identical relative paths in unrelated workspaces never become the same forensic file.
192
+ return `${repoLabel(cwd)}::${path.normalize(value)}`;
193
+ }
114
194
  function fileLabel(filePath) {
115
195
  return path.basename(String(filePath)) || "unknown";
116
196
  }
@@ -170,6 +250,8 @@ class Forensics {
170
250
  lastReadVersion = new Map();
171
251
  fileReadSessions = new Map();
172
252
  pendingReads = new Map();
253
+ latestContextObservation = null;
254
+ contextObservationSeq = 0;
173
255
  hourHistogram = new Array(24).fill(0);
174
256
  weekdayCounts = new Map();
175
257
  lateNightSessions = new Map();
@@ -182,7 +264,7 @@ class Forensics {
182
264
  const key = model || "unknown";
183
265
  let m = this.models.get(key);
184
266
  if (!m) {
185
- m = { messages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
267
+ m = { messages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, cost: emptyCostAccumulator() };
186
268
  this.models.set(key, m);
187
269
  }
188
270
  return m;
@@ -191,7 +273,7 @@ class Forensics {
191
273
  const name = repoLabel(cwd);
192
274
  let r = this.repos.get(name);
193
275
  if (!r) {
194
- r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [], byDay: new Map() };
276
+ r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, cost: emptyCostAccumulator(), codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [], byDay: new Map() };
195
277
  this.repos.set(name, r);
196
278
  }
197
279
  if (cwd && !r.cwd)
@@ -224,37 +306,66 @@ class Forensics {
224
306
  /** assistant-turn token usage (already split into cold/cacheWrite/cacheRead/output). */
225
307
  recordUsage(model, cwd, session, u, provider, ms) {
226
308
  const m = this.modelBucket(model);
309
+ const estimatedCost = estimateApiEquivalentCost(model, u, ms);
227
310
  m.messages += 1;
228
311
  m.cold += u.cold;
229
312
  m.cacheWrite += u.cacheWrite;
230
313
  m.cacheRead += u.cacheRead;
231
314
  m.output += u.output;
232
- if (cwd) {
233
- const r = this.repoBucket(cwd);
234
- if (session)
235
- r.sessions.add(session);
236
- r.assistantMessages += 1;
237
- r.cold += u.cold;
238
- r.cacheWrite += u.cacheWrite;
239
- r.cacheRead += u.cacheRead;
240
- r.output += u.output;
241
- // per-repo provider split → drives the city's per-cube colour (Codex green / Claude orange)
242
- const turnTokens = u.cold + u.cacheWrite + u.cacheRead + u.output;
243
- if (provider === "codex")
244
- r.codexTokens += turnTokens;
245
- else
246
- r.claudeTokens += turnTokens;
247
- if (ms != null && turnTokens > 0) {
248
- const d = localDay(ms);
249
- r.byDay.set(d, (r.byDay.get(d) || 0) + turnTokens);
250
- }
315
+ addEstimatedCost(m.cost, estimatedCost);
316
+ // Keep usage with missing legacy cwd metadata in an explicit "home" bucket. Silently omitting it
317
+ // makes repo/day timelines fail to reconcile with the provider ledger even though scale includes it.
318
+ const r = this.repoBucket(cwd);
319
+ if (session)
320
+ r.sessions.add(session);
321
+ r.assistantMessages += 1;
322
+ r.cold += u.cold;
323
+ r.cacheWrite += u.cacheWrite;
324
+ r.cacheRead += u.cacheRead;
325
+ r.output += u.output;
326
+ addEstimatedCost(r.cost, estimatedCost);
327
+ // per-repo provider split → drives the city's per-cube colour (Codex green / Claude orange)
328
+ const turnTokens = u.cold + u.cacheWrite + u.cacheRead + u.output;
329
+ if (provider === "codex")
330
+ r.codexTokens += turnTokens;
331
+ else
332
+ r.claudeTokens += turnTokens;
333
+ if (ms != null && turnTokens > 0) {
334
+ const d = localDay(ms);
335
+ r.byDay.set(d, (r.byDay.get(d) || 0) + turnTokens);
336
+ }
337
+ }
338
+ recordContextObservation(input) {
339
+ const inputTokens = Math.max(0, Math.round(input.inputTokens || 0));
340
+ if (inputTokens <= 0)
341
+ return;
342
+ const resolvedLimit = resolveModelContextLimit({
343
+ loggedLimitTokens: input.modelContextLimitTokens ?? null,
344
+ model: input.model,
345
+ });
346
+ const observation = {
347
+ seq: ++this.contextObservationSeq,
348
+ model: input.model || "unknown",
349
+ inputTokens,
350
+ outputTokens: Math.max(0, Math.round(input.outputTokens || 0)),
351
+ modelContextLimitTokens: resolvedLimit.tokens,
352
+ modelContextLimitSource: resolvedLimit.source,
353
+ ms: input.ms ?? null,
354
+ };
355
+ const current = this.latestContextObservation;
356
+ if (!current ||
357
+ (observation.ms != null && current.ms != null && observation.ms >= current.ms) ||
358
+ (observation.ms != null && current.ms == null) ||
359
+ (observation.ms == null && current.ms == null && observation.seq >= current.seq)) {
360
+ this.latestContextObservation = observation;
251
361
  }
252
362
  }
253
363
  recordEdit(target, cwd) {
254
364
  if (!target)
255
365
  return;
256
- this.fileVersions.set(target, (this.fileVersions.get(target) || 0) + 1);
257
- const rec = this.fileRecord(target, repoLabel(cwd));
366
+ const identity = fileIdentity(target, cwd);
367
+ this.fileVersions.set(identity, (this.fileVersions.get(identity) || 0) + 1);
368
+ const rec = this.fileRecord(identity, repoLabel(cwd));
258
369
  rec.everEdited = true;
259
370
  rec.editCount += 1;
260
371
  }
@@ -263,13 +374,14 @@ class Forensics {
263
374
  if (!target)
264
375
  return;
265
376
  const repo = repoLabel(cwd);
266
- const rec = this.fileRecord(target, repo);
377
+ const identity = fileIdentity(target, cwd);
378
+ const rec = this.fileRecord(identity, repo);
267
379
  const priorReads = rec.totalReads;
268
- const currentVersion = this.fileVersions.get(target) || 0;
269
- const seenSessions = this.fileReadSessions.get(target) || new Set();
380
+ const currentVersion = this.fileVersions.get(identity) || 0;
381
+ const seenSessions = this.fileReadSessions.get(identity) || new Set();
270
382
  const isReread = priorReads > 0;
271
383
  const crossSession = isReread && !!session && !seenSessions.has(session);
272
- const changedSinceLastRead = isReread && currentVersion > (this.lastReadVersion.get(target) || 0);
384
+ const changedSinceLastRead = isReread && currentVersion > (this.lastReadVersion.get(identity) || 0);
273
385
  const stale = isReread && !changedSinceLastRead;
274
386
  rec.totalReads += 1;
275
387
  if (cwd)
@@ -302,11 +414,11 @@ class Forensics {
302
414
  if (!this.nightExample || priorReads > this.nightExample.priorReads)
303
415
  this.nightExample = candidate;
304
416
  }
305
- if (!this.fileReadSessions.has(target))
306
- this.fileReadSessions.set(target, new Set());
417
+ if (!this.fileReadSessions.has(identity))
418
+ this.fileReadSessions.set(identity, new Set());
307
419
  if (session)
308
- this.fileReadSessions.get(target).add(session);
309
- this.lastReadVersion.set(target, currentVersion);
420
+ this.fileReadSessions.get(identity).add(session);
421
+ this.lastReadVersion.set(identity, currentVersion);
310
422
  if (toolId)
311
423
  this.pendingReads.set(toolId, { rec, stale });
312
424
  }
@@ -362,30 +474,38 @@ class Forensics {
362
474
  // ----- tokens + cost -----
363
475
  let cold = 0, cacheWrite = 0, cacheRead = 0, output = 0;
364
476
  const modelOut = {};
477
+ const accumulatedCost = emptyCostAccumulator();
365
478
  for (const [name, m] of this.models) {
366
479
  cold += m.cold;
367
480
  cacheWrite += m.cacheWrite;
368
481
  cacheRead += m.cacheRead;
369
482
  output += m.output;
483
+ addAccumulatedCost(accumulatedCost, m.cost);
370
484
  if (name === "<synthetic>" || m.cold + m.cacheWrite + m.cacheRead + m.output === 0)
371
485
  continue;
372
- const price = priceFor(name);
373
486
  modelOut[name] = {
374
487
  messages: m.messages, cold: m.cold, cacheWrite: m.cacheWrite, cacheRead: m.cacheRead, output: m.output,
375
- total: m.cold + m.cacheWrite + m.cacheRead + m.output, cost: round(costOf(m, price), 2),
488
+ total: m.cold + m.cacheWrite + m.cacheRead + m.output,
489
+ coldInputCost: round(m.cost.coldInputCost, 2),
490
+ cacheWriteCost: round(m.cost.cacheWriteCost, 2),
491
+ cacheReadCost: round(m.cost.cacheReadCost, 2),
492
+ outputCost: round(m.cost.outputCost, 2),
493
+ cost: round(m.cost.total, 2),
494
+ longContextRequestCount: m.cost.longContextRequestCount,
376
495
  };
377
496
  }
378
497
  const totalInput = cold + cacheWrite + cacheRead;
379
498
  const totalTokens = totalInput + output;
380
499
  const cost = {
381
- coldInputCost: round((cold / 1e6) * CLAUDE_DEFAULT.input, 2),
382
- cacheWriteCost: round((cacheWrite / 1e6) * CLAUDE_DEFAULT.cacheWrite, 2),
383
- cacheReadCost: round((cacheRead / 1e6) * CLAUDE_DEFAULT.cacheRead, 2),
384
- outputCost: round((output / 1e6) * CLAUDE_DEFAULT.output, 2),
385
- total: 0,
500
+ coldInputCost: round(accumulatedCost.coldInputCost, 2),
501
+ cacheWriteCost: round(accumulatedCost.cacheWriteCost, 2),
502
+ cacheReadCost: round(accumulatedCost.cacheReadCost, 2),
503
+ outputCost: round(accumulatedCost.outputCost, 2),
504
+ total: round(accumulatedCost.total, 2),
505
+ longContextRequestCount: accumulatedCost.longContextRequestCount,
506
+ pricingBasis: "standard-api-list-price",
507
+ pricingAsOf: "2026-07-12",
386
508
  };
387
- // total cost = sum of per-model costs (so mixed providers price correctly)
388
- cost.total = round(Object.values(modelOut).reduce((s, m) => s + m.cost, 0), 2);
389
509
  const rankedModels = Object.entries(modelOut).sort((a, b) => b[1].total - a[1].total);
390
510
  const topModel = rankedModels[0]?.[0] || "unknown";
391
511
  const topShare = totalTokens ? Math.round(((rankedModels[0]?.[1].total || 0) / totalTokens) * 100) : 0;
@@ -413,7 +533,7 @@ class Forensics {
413
533
  dailyFocusHours: wb.activeDays ? round(wb.attentionHours / wb.activeDays, 2) : 0,
414
534
  equivalentFocusDays: round(wb.attentionHours / FOCUS_DAY_HOURS, 1), workBlocks: wb.blocks.length,
415
535
  tokens: repo.cold + repo.cacheWrite + repo.cacheRead + repo.output,
416
- cost: round(costOf({ cold: repo.cold, cacheWrite: repo.cacheWrite, cacheRead: repo.cacheRead, output: repo.output }, CLAUDE_DEFAULT), 2),
536
+ cost: round(repo.cost.total, 2),
417
537
  reads: repo.reads, rereads: repo.rereads, staleRereads: repo.staleRereads,
418
538
  dominantProvider: repo.codexTokens >= repo.claudeTokens ? "codex" : "claude",
419
539
  codexTokens: repo.codexTokens, claudeTokens: repo.claudeTokens,
@@ -475,7 +595,9 @@ class Forensics {
475
595
  const scanEnd = this.maxTs ?? Date.now();
476
596
  const cfDate = scanEnd - daysAgo * 86_400_000;
477
597
  return {
478
- generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
598
+ schemaVersion: 1,
599
+ dataOrigin: "local-workspace-scan",
600
+ generatedFrom: [],
479
601
  llmCallsUsed: 0,
480
602
  transcriptsUploaded: false,
481
603
  scanStartDate: this.minTs != null ? localDay(this.minTs) : null,
@@ -510,44 +632,159 @@ class Forensics {
510
632
  crossSessionStaleReads: codeCrossStale, totalReadTokens: codeReadTokens, staleReadTokens: codeStaleReadTokens,
511
633
  staleReadSharePct: Math.round(staleShare * 100),
512
634
  },
635
+ contextWindow: this.buildContextWindowSummary(topModel, staleShare),
513
636
  rereadForensics: { topFiles, neverChangedHeavy, principleReads, nightExample: this.nightExample },
514
- avoidableWait: { readTokens: codeStaleReadTokens, hours: avoidableWaitHours, days: avoidableWaitDays, cost: round(costOf({ cold: codeStaleReadTokens }, CLAUDE_DEFAULT), 2) },
637
+ avoidableWait: {
638
+ readTokens: codeStaleReadTokens,
639
+ hours: avoidableWaitHours,
640
+ days: avoidableWaitDays,
641
+ cost: round(estimateApiEquivalentCost(topModel, { cold: codeStaleReadTokens }, this.maxTs).total, 2),
642
+ },
515
643
  counterfactual: { daysAgo, counterfactualDate: localDay(cfDate), daysGained: avoidableWaitDays, targetScore: Math.min(100, cleanlinessScore + 30) },
516
644
  };
517
645
  }
646
+ buildContextWindowSummary(topModel, staleShare) {
647
+ const latest = this.latestContextObservation;
648
+ const latestInputTokens = latest?.inputTokens ?? 0;
649
+ const currentResidentWasteTokens = Math.round(latestInputTokens * Math.max(0, staleShare));
650
+ return {
651
+ source: latest ? "latest-observed-input" : "unavailable",
652
+ model: latest?.model || topModel || "unknown",
653
+ modelContextLimitSource: latest?.modelContextLimitSource || "unavailable",
654
+ sampleCount: this.contextObservationSeq,
655
+ staleReadSharePct: Math.round(staleShare * 100),
656
+ metrics: calculateContextMetrics({
657
+ latestInputTokens,
658
+ modelContextLimitTokens: latest?.modelContextLimitTokens ?? null,
659
+ currentResidentWasteTokens,
660
+ newOutputThisTurnTokens: latest?.outputTokens,
661
+ }),
662
+ };
663
+ }
518
664
  }
519
665
  // ---------------------------------------------------------------------------
520
666
  // Source adapters — translate each log schema into engine calls
521
667
  // ---------------------------------------------------------------------------
522
- /** Claude Code (~/.claude/projects). usage is per assistant turn; tools live in message.content. */
523
- function feedClaude(file, eng) {
668
+ function sessionTitleFromText(text) {
669
+ const normalized = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
670
+ if (!normalized)
671
+ return null;
672
+ const sentence = normalized.split(/(?<=[.!?。!?])\s/)[0] || normalized;
673
+ return sentence.slice(0, 96);
674
+ }
675
+ /** Parse one Claude Code transcript without mutating global state. Replaying every provider on one
676
+ * timestamp-ordered timeline prevents a later-scanned file/source from contaminating reread order. */
677
+ function extractClaude(file) {
678
+ const usageByRequest = new Map();
679
+ const seenToolUses = new Set();
680
+ const context = [];
681
+ const ev = [];
682
+ let cwd = null;
683
+ let session = null;
684
+ let firstTs = null;
685
+ let lastTs = null;
686
+ let fallbackTitle = null;
687
+ let anonymousRequestSeq = 0;
688
+ const requestKey = (row) => {
689
+ const messageId = typeof row.message?.id === "string" ? row.message.id.trim() : "";
690
+ if (messageId)
691
+ return `message:${messageId}`;
692
+ const requestId = typeof row.requestId === "string" ? row.requestId.trim() : "";
693
+ if (requestId)
694
+ return `request:${requestId}`;
695
+ const uuid = typeof row.uuid === "string" ? row.uuid.trim() : "";
696
+ if (uuid)
697
+ return `event:${uuid}`;
698
+ // Without a provider identifier it is unsafe to assume two rows are the same request.
699
+ return `anonymous:${++anonymousRequestSeq}`;
700
+ };
701
+ const usageNumber = (value) => (typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0);
702
+ const toolUseKey = (request, block, index) => {
703
+ const id = typeof block?.id === "string" ? block.id.trim() : "";
704
+ if (id)
705
+ return `id:${id}`;
706
+ let input = "";
707
+ try {
708
+ input = JSON.stringify(block?.input ?? null);
709
+ }
710
+ catch {
711
+ input = "[unserializable]";
712
+ }
713
+ // Claude tool_use blocks normally have an id. The positional fingerprint is a conservative
714
+ // fallback that deduplicates repeated snapshots without collapsing two calls in one request.
715
+ return `fallback:${request}:${index}:${String(block?.name || "")}:${input}`;
716
+ };
524
717
  eachLine(file, (o) => {
525
718
  const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
526
- if (Number.isFinite(ts))
527
- eng.noteTs(ts);
528
- const session = o.sessionId || null;
529
- eng.noteSession(session);
530
- const cwd = o.cwd || null;
719
+ if (Number.isFinite(ts)) {
720
+ if (firstTs == null || ts < firstTs)
721
+ firstTs = ts;
722
+ if (lastTs == null || ts > lastTs)
723
+ lastTs = ts;
724
+ }
725
+ if (!session && typeof o.sessionId === "string")
726
+ session = o.sessionId;
727
+ if (!cwd && typeof o.cwd === "string")
728
+ cwd = o.cwd;
531
729
  if (o.type === "assistant" && o.message) {
730
+ const key = requestKey(o);
532
731
  const u = o.message.usage || {};
533
- eng.recordUsage(o.message.model || "unknown", cwd, session, {
534
- cold: u.input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0,
535
- cacheRead: u.cache_read_input_tokens || 0, output: u.output_tokens || 0,
536
- }, "claude", Number.isFinite(ts) ? ts : undefined);
732
+ const model = typeof o.message.model === "string" && o.message.model ? o.message.model : "unknown";
733
+ const existing = usageByRequest.get(key);
734
+ const merged = existing || {
735
+ model,
736
+ cwd,
737
+ session,
738
+ ms: Number.isFinite(ts) ? ts : null,
739
+ cold: 0,
740
+ cacheWrite: 0,
741
+ cacheRead: 0,
742
+ output: 0,
743
+ };
744
+ const candidateCold = usageNumber(u.input_tokens);
745
+ const candidateCacheWrite = usageNumber(u.cache_creation_input_tokens);
746
+ const candidateCacheRead = usageNumber(u.cache_read_input_tokens);
747
+ const candidateInput = candidateCold + candidateCacheWrite + candidateCacheRead;
748
+ const mergedInput = merged.cold + merged.cacheWrite + merged.cacheRead;
749
+ if (candidateInput > mergedInput) {
750
+ // Keep the three input components from one provider snapshot. Taking their independent
751
+ // maxima can synthesize an official-input total that never appeared in the transcript.
752
+ merged.cold = candidateCold;
753
+ merged.cacheWrite = candidateCacheWrite;
754
+ merged.cacheRead = candidateCacheRead;
755
+ }
756
+ merged.output = Math.max(merged.output, usageNumber(u.output_tokens));
757
+ if (merged.model === "unknown" && model !== "unknown")
758
+ merged.model = model;
759
+ if (!merged.cwd && cwd)
760
+ merged.cwd = cwd;
761
+ if (!merged.session && session)
762
+ merged.session = session;
763
+ if (Number.isFinite(ts) && (merged.ms == null || ts >= merged.ms))
764
+ merged.ms = ts;
765
+ usageByRequest.set(key, merged);
537
766
  const blocks = Array.isArray(o.message.content) ? o.message.content : [];
538
- for (const b of blocks) {
767
+ for (let index = 0; index < blocks.length; index += 1) {
768
+ const b = blocks[index];
539
769
  if (b?.type !== "tool_use")
540
770
  continue;
541
771
  const name = String(b.name || "");
772
+ const toolKey = toolUseKey(key, b, index);
773
+ if (seenToolUses.has(toolKey))
774
+ continue;
542
775
  if (name === "Edit" || name === "Write" || name === "MultiEdit") {
543
776
  const target = b.input?.file_path || b.input?.path;
544
- if (target)
545
- eng.recordEdit(target, cwd);
777
+ if (target) {
778
+ ev.push({ t: "e", path: target, ms: Number.isFinite(ts) ? ts : null });
779
+ seenToolUses.add(toolKey);
780
+ }
546
781
  }
547
782
  else if (name === "Read") {
548
783
  const target = b.input?.file_path;
549
- if (target)
550
- eng.recordRead(target, cwd, session, Number.isFinite(ts) ? ts : null, b.id || null);
784
+ if (target) {
785
+ ev.push({ t: "r", path: target, ms: Number.isFinite(ts) ? ts : null, id: b.id || null });
786
+ seenToolUses.add(toolKey);
787
+ }
551
788
  }
552
789
  }
553
790
  return;
@@ -555,17 +792,41 @@ function feedClaude(file, eng) {
555
792
  if (o.type === "user" && o.message) {
556
793
  const content = o.message.content;
557
794
  if (typeof content === "string") {
558
- if (isRealUserText(content) && Number.isFinite(ts) && !o.isSidechain)
559
- eng.recordUserMsg(cwd, session, ts);
795
+ if (isRealUserText(content) && Number.isFinite(ts) && !o.isSidechain) {
796
+ ev.push({ t: "m", ms: ts });
797
+ if (!fallbackTitle)
798
+ fallbackTitle = sessionTitleFromText(content);
799
+ }
560
800
  }
561
801
  else if (Array.isArray(content)) {
562
802
  for (const b of content) {
563
- if (b?.type === "tool_result" && b.tool_use_id)
564
- eng.recordReadOutput(b.tool_use_id, contentLength(b.content));
803
+ if (b?.type === "tool_result" && b.tool_use_id) {
804
+ ev.push({ t: "o", id: b.tool_use_id, chars: contentLength(b.content), ms: Number.isFinite(ts) ? ts : null });
805
+ }
565
806
  }
566
807
  }
567
808
  }
568
809
  });
810
+ const usage = [];
811
+ // Claude can persist several snapshots of the same provider message. Account official usage once,
812
+ // retaining the one input tuple with the largest official total.
813
+ for (const request of usageByRequest.values()) {
814
+ usage.push({
815
+ model: request.model,
816
+ cold: request.cold,
817
+ cacheWrite: request.cacheWrite,
818
+ cacheRead: request.cacheRead,
819
+ output: request.output,
820
+ ms: request.ms,
821
+ });
822
+ context.push({
823
+ model: request.model,
824
+ inputTokens: request.cold + request.cacheWrite + request.cacheRead,
825
+ outputTokens: request.output,
826
+ ms: request.ms,
827
+ });
828
+ }
829
+ return { source: "claude-code", session, cwd, firstTs, lastTs, fallbackTitle, usage, context, ev };
569
830
  }
570
831
  /** Parse one Codex rollout into a compact, cacheable FileEvents. token_count is CUMULATIVE (delta'd);
571
832
  * edits arrive via patch_apply_end; reads are exec_command shell commands (path extracted best-effort). */
@@ -578,14 +839,17 @@ function extractCodex(file) {
578
839
  let prev = { cold: 0, cacheRead: 0, output: 0 }; // last cumulative seen (for deltas)
579
840
  let firstTs = null;
580
841
  let lastTs = null;
581
- const usageByModel = new Map();
842
+ let fallbackTitle = null;
843
+ const usage = [];
844
+ const context = [];
582
845
  const ev = [];
583
846
  eachLine(file, (o) => {
584
847
  const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
585
848
  if (Number.isFinite(ts)) {
586
- if (firstTs == null)
849
+ if (firstTs == null || ts < firstTs)
587
850
  firstTs = ts;
588
- lastTs = ts;
851
+ if (lastTs == null || ts > lastTs)
852
+ lastTs = ts;
589
853
  }
590
854
  const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
591
855
  if (!p || typeof p !== "object")
@@ -595,6 +859,11 @@ function extractCodex(file) {
595
859
  cwd = p.cwd;
596
860
  if (typeof p.id === "string" && !session)
597
861
  session = p.id;
862
+ // Codex records the actual launch time in session metadata. It can precede the first persisted
863
+ // JSONL event by several seconds, so use it for the semantic start of the timeline when present.
864
+ const startedAt = typeof p.timestamp === "string" ? Date.parse(p.timestamp) : NaN;
865
+ if (Number.isFinite(startedAt) && (firstTs == null || startedAt < firstTs))
866
+ firstTs = startedAt;
598
867
  return;
599
868
  }
600
869
  if (typeof p.model === "string")
@@ -609,12 +878,25 @@ function extractCodex(file) {
609
878
  const dCold = Math.max(0, cold - prev.cold);
610
879
  const dCr = Math.max(0, cacheRead - prev.cacheRead);
611
880
  const dOut = Math.max(0, out - prev.output);
881
+ const last = p.info?.last_token_usage || {};
882
+ const lastInput = last.input_tokens || inputTotal;
883
+ const lastOutput = (last.output_tokens || 0) + (last.reasoning_output_tokens || 0) || dOut;
884
+ context.push({
885
+ model,
886
+ inputTokens: lastInput,
887
+ outputTokens: lastOutput,
888
+ modelContextLimitTokens: p.info?.model_context_window || p.model_context_window || null,
889
+ ms: Number.isFinite(ts) ? ts : null,
890
+ });
612
891
  if (dCold || dCr || dOut) {
613
- const acc = usageByModel.get(model) || { cold: 0, cacheRead: 0, output: 0 };
614
- acc.cold += dCold;
615
- acc.cacheRead += dCr;
616
- acc.output += dOut;
617
- usageByModel.set(model, acc);
892
+ usage.push({
893
+ model,
894
+ cold: dCold,
895
+ cacheWrite: 0,
896
+ cacheRead: dCr,
897
+ output: dOut,
898
+ ms: Number.isFinite(ts) ? ts : null,
899
+ });
618
900
  }
619
901
  prev = { cold, cacheRead, output: out };
620
902
  }
@@ -623,11 +905,16 @@ function extractCodex(file) {
623
905
  if (p.type === "user_message") {
624
906
  if (Number.isFinite(ts))
625
907
  ev.push({ t: "m", ms: ts });
908
+ if (!fallbackTitle) {
909
+ const text = typeof p.message === "string" ? p.message : typeof p.content === "string" ? p.content : "";
910
+ if (text)
911
+ fallbackTitle = sessionTitleFromText(text);
912
+ }
626
913
  return;
627
914
  }
628
915
  if (p.type === "patch_apply_end" && p.changes && typeof p.changes === "object") {
629
916
  for (const target of Object.keys(p.changes))
630
- ev.push({ t: "e", path: target });
917
+ ev.push({ t: "e", path: target, ms: Number.isFinite(ts) ? ts : null });
631
918
  return;
632
919
  }
633
920
  // older format: exec_command_end with parsed_cmd[].type==='read'
@@ -652,38 +939,54 @@ function extractCodex(file) {
652
939
  return;
653
940
  }
654
941
  if (p.type === "function_call_output" && p.call_id) {
655
- ev.push({ t: "o", id: p.call_id, chars: contentLength(p.output) });
942
+ ev.push({ t: "o", id: p.call_id, chars: contentLength(p.output), ms: Number.isFinite(ts) ? ts : null });
656
943
  }
657
944
  });
658
- const usage = [...usageByModel.entries()].map(([m, u]) => ({ model: m, cold: u.cold, cacheRead: u.cacheRead, output: u.output }));
659
- return { source: "codex", session, cwd, firstTs, lastTs, usage, ev };
660
- }
661
- /** Replay a parsed FileEvents into the engine. Usage is order-independent; reads/edits keep file order
662
- * so cross-session reread detection is identical to a live scan of the same files in the same order. */
663
- function replayFile(eng, fe) {
664
- if (fe.firstTs != null)
665
- eng.noteTs(fe.firstTs);
666
- if (fe.lastTs != null)
667
- eng.noteTs(fe.lastTs);
668
- eng.noteSession(fe.session);
669
- const provider = fe.source === "codex" ? "codex" : "claude";
670
- for (const u of fe.usage)
671
- eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output }, provider, fe.firstTs ?? undefined);
672
- for (const e of fe.ev) {
945
+ return { source: "codex", session, cwd, firstTs, lastTs, fallbackTitle, usage, context, ev };
946
+ }
947
+ /** Replay every provider on one semantic timeline. Provider timestamps, not source/path order, drive
948
+ * daily token buckets and order-dependent edit/reread state across overlapping sessions. */
949
+ function replayTimelineFiles(eng, files) {
950
+ const orderedEvents = [];
951
+ const orderedUsage = [];
952
+ files.forEach(({ fe }, fileOrder) => {
953
+ if (fe.firstTs != null)
954
+ eng.noteTs(fe.firstTs);
955
+ if (fe.lastTs != null)
956
+ eng.noteTs(fe.lastTs);
957
+ eng.noteSession(fe.session);
958
+ (fe.usage || []).forEach((u, usageOrder) => orderedUsage.push({ fe, u, fileOrder, usageOrder }));
959
+ for (const c of fe.context || [])
960
+ eng.recordContextObservation(c);
961
+ (fe.ev || []).forEach((e, eventOrder) => orderedEvents.push({ fe, e, fileOrder, eventOrder }));
962
+ });
963
+ orderedUsage.sort((a, b) => (a.u.ms ?? a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.u.ms ?? b.fe.firstTs ?? Number.POSITIVE_INFINITY) ||
964
+ a.fileOrder - b.fileOrder ||
965
+ a.usageOrder - b.usageOrder);
966
+ for (const { fe, u } of orderedUsage) {
967
+ const provider = fe.source === "codex" ? "codex" : "claude";
968
+ eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: u.cacheWrite, cacheRead: u.cacheRead, output: u.output }, provider, u.ms ?? undefined);
969
+ }
970
+ const eventMs = (item) => item.e.ms ?? item.fe.firstTs ?? Number.POSITIVE_INFINITY;
971
+ orderedEvents.sort((a, b) => eventMs(a) - eventMs(b) ||
972
+ a.fileOrder - b.fileOrder ||
973
+ a.eventOrder - b.eventOrder);
974
+ for (const { fe, e } of orderedEvents) {
975
+ const callId = (id) => id ? `${fe.source}:${fe.session || "unknown"}:${id}` : null;
673
976
  if (e.t === "m")
674
977
  eng.recordUserMsg(fe.cwd, fe.session, e.ms);
675
978
  else if (e.t === "e")
676
979
  eng.recordEdit(e.path, fe.cwd);
677
980
  else if (e.t === "r")
678
- eng.recordRead(e.path, fe.cwd, fe.session, e.ms, e.id);
981
+ eng.recordRead(e.path, fe.cwd, fe.session, e.ms, callId(e.id));
679
982
  else if (e.t === "o")
680
- eng.recordReadOutput(e.id, e.chars);
983
+ eng.recordReadOutput(callId(e.id) || e.id, e.chars);
681
984
  }
682
985
  }
683
986
  // ---------------------------------------------------------------------------
684
987
  // Per-file parse cache — keyed by path+mtime+size so re-runs only reparse changed/new Codex files.
685
988
  // ---------------------------------------------------------------------------
686
- const CACHE_VERSION = 1;
989
+ const CACHE_VERSION = 6;
687
990
  function forensicCachePath() {
688
991
  return path.join(os.homedir(), ".echomem", "forensic-cache.json");
689
992
  }
@@ -708,51 +1011,479 @@ function saveForensicCache(files) {
708
1011
  /* best effort: a cache write failure must never break the scan */
709
1012
  }
710
1013
  }
1014
+ function loadCodexSessionTitles() {
1015
+ const titles = new Map();
1016
+ try {
1017
+ const indexPath = path.join(os.homedir(), ".codex", "session_index.jsonl");
1018
+ for (const line of fs.readFileSync(indexPath, "utf8").split("\n")) {
1019
+ if (!line.trim())
1020
+ continue;
1021
+ try {
1022
+ const row = JSON.parse(line);
1023
+ if (typeof row.id === "string" && typeof row.thread_name === "string" && row.thread_name.trim()) {
1024
+ titles.set(row.id, row.thread_name.trim());
1025
+ }
1026
+ }
1027
+ catch {
1028
+ /* Ignore incomplete index rows. */
1029
+ }
1030
+ }
1031
+ }
1032
+ catch {
1033
+ /* Index is optional; transcript-derived titles remain available. */
1034
+ }
1035
+ return titles;
1036
+ }
1037
+ function loadClaudeSessionTitles() {
1038
+ const titles = new Map();
1039
+ const roots = [
1040
+ path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions"),
1041
+ path.join(os.homedir(), "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
1042
+ ];
1043
+ for (const root of roots) {
1044
+ const files = walk(root, (p) => /^local_[0-9a-f-]+\.json$/i.test(path.basename(p)), () => false);
1045
+ for (const file of files) {
1046
+ try {
1047
+ const row = JSON.parse(fs.readFileSync(file, "utf8"));
1048
+ if (typeof row.cliSessionId === "string" && typeof row.title === "string" && row.title.trim()) {
1049
+ titles.set(row.cliSessionId, row.title.trim());
1050
+ }
1051
+ }
1052
+ catch {
1053
+ /* Ignore incomplete live metadata. */
1054
+ }
1055
+ }
1056
+ }
1057
+ return titles;
1058
+ }
1059
+ function enrichCanonicalSessionExamples(canonical, timelineFiles) {
1060
+ if (!canonical || "error" in canonical)
1061
+ return;
1062
+ const codexTitles = loadCodexSessionTitles();
1063
+ const claudeTitles = loadClaudeSessionTitles();
1064
+ const records = timelineFiles
1065
+ .filter(({ fe }) => Boolean(fe.session))
1066
+ .sort((a, b) => (a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.fe.firstTs ?? Number.POSITIVE_INFINITY));
1067
+ const sourceTotals = {
1068
+ codex: records.filter(({ fe }) => fe.source === "codex").length,
1069
+ "claude-code": records.filter(({ fe }) => fe.source === "claude-code").length,
1070
+ };
1071
+ const sourceOrdinals = { codex: 0, "claude-code": 0 };
1072
+ const sessionMeta = new Map();
1073
+ for (const { fe } of records) {
1074
+ if (!fe.session)
1075
+ continue;
1076
+ sourceOrdinals[fe.source] += 1;
1077
+ const indexedTitle = fe.source === "codex" ? codexTitles.get(fe.session) : claudeTitles.get(fe.session);
1078
+ sessionMeta.set(fe.session, {
1079
+ id: fe.session,
1080
+ title: indexedTitle || fe.fallbackTitle || `${fe.cwd ? path.basename(fe.cwd) : "Local"} session`,
1081
+ ordinal: sourceOrdinals[fe.source],
1082
+ total: sourceTotals[fe.source],
1083
+ });
1084
+ }
1085
+ const reports = [canonical];
1086
+ if (canonical.sourceReports?.codex)
1087
+ reports.push(canonical.sourceReports.codex);
1088
+ if (canonical.sourceReports?.claudeCode)
1089
+ reports.push(canonical.sourceReports.claudeCode);
1090
+ for (const contextReport of reports) {
1091
+ for (const problem of contextReport.problems || []) {
1092
+ for (const example of problem.examples || []) {
1093
+ const exact = sessionMeta.get(example.session);
1094
+ const matched = exact || [...sessionMeta.entries()].find(([id]) => id.startsWith(example.session))?.[1];
1095
+ if (!matched)
1096
+ continue;
1097
+ example.session = matched.id;
1098
+ example.sessionTitle = matched.title;
1099
+ example.sessionOrdinal = matched.ordinal;
1100
+ example.sourceSessionCount = matched.total;
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ export function projectCanonicalWasteToBilledInput(billedInputTokens, summary) {
1106
+ if (!Number.isFinite(billedInputTokens) ||
1107
+ !Number.isFinite(summary.officialInputTokens) ||
1108
+ !Number.isFinite(summary.wasteTokens))
1109
+ return null;
1110
+ const billedInput = Math.round(billedInputTokens);
1111
+ const canonicalInput = Math.round(summary.officialInputTokens);
1112
+ const canonicalWaste = Math.round(summary.wasteTokens);
1113
+ // With aligned source scope, all provider requests must contain at least as many input tokens as
1114
+ // one selected request per human turn. Refuse to manufacture a projection from inverted ledgers.
1115
+ if (billedInput <= 0 ||
1116
+ canonicalInput <= 0 ||
1117
+ canonicalWaste < 0 ||
1118
+ canonicalWaste > canonicalInput ||
1119
+ billedInput < canonicalInput)
1120
+ return null;
1121
+ const billingMultiplier = billedInput / canonicalInput;
1122
+ const projectedWaste = Math.max(0, Math.min(billedInput, Math.round(canonicalWaste * billingMultiplier)));
1123
+ const wastePct = (canonicalWaste / canonicalInput) * 100;
1124
+ return {
1125
+ method: "canonical-window-share-over-provider-input",
1126
+ billedInputTokens: billedInput,
1127
+ canonicalInputTokens: canonicalInput,
1128
+ canonicalUsefulTokens: canonicalInput - canonicalWaste,
1129
+ canonicalWasteTokens: canonicalWaste,
1130
+ billingMultiplier,
1131
+ projectedUsefulTokens: billedInput - projectedWaste,
1132
+ projectedWasteTokens: projectedWaste,
1133
+ usefulPct: 100 - wastePct,
1134
+ wastePct,
1135
+ };
1136
+ }
1137
+ function recordValue(value) {
1138
+ return typeof value === "object" && value !== null && !Array.isArray(value)
1139
+ ? value
1140
+ : null;
1141
+ }
1142
+ function nonNegativeInteger(value) {
1143
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0
1144
+ ? value
1145
+ : null;
1146
+ }
1147
+ function nonNegativeNumber(value) {
1148
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1149
+ }
1150
+ function approximatelyEqual(actual, expected, relativeTolerance = 1e-9) {
1151
+ return typeof actual === "number" &&
1152
+ Number.isFinite(actual) &&
1153
+ Math.abs(actual - expected) <= Math.max(1, Math.abs(expected)) * relativeTolerance;
1154
+ }
1155
+ function invalidSetupReport(code, message) {
1156
+ return { ok: false, code, message };
1157
+ }
1158
+ /**
1159
+ * Fail-closed boundary between the local scanner and setup UI.
1160
+ *
1161
+ * The setup page must never infer that a fixture, partial scan, or internally inconsistent token
1162
+ * ledger is the user's report. This validator checks the provenance and the cross-ledger identities
1163
+ * the UI relies on before any absolute token number is rendered.
1164
+ */
1165
+ export function validateForensicReportForSetup(value) {
1166
+ const report = recordValue(value);
1167
+ if (!report)
1168
+ return invalidSetupReport("REPORT_MALFORMED", "The local report payload is not an object.");
1169
+ if (report.schemaVersion !== 1) {
1170
+ return invalidSetupReport("REPORT_SCHEMA_UNSUPPORTED", "The local report schema is missing or unsupported.");
1171
+ }
1172
+ if (report.dataOrigin !== "local-workspace-scan") {
1173
+ return invalidSetupReport("REPORT_NOT_LOCAL", "The report did not come from this local workspace scan.");
1174
+ }
1175
+ const scale = recordValue(report.scale);
1176
+ const canonical = recordValue(report.canonicalGoldenStandard);
1177
+ const requiredObjects = [
1178
+ report.cost,
1179
+ report.modelUsage,
1180
+ report.userWorkingHours,
1181
+ report.rhythm,
1182
+ report.cleanliness,
1183
+ report.contextWindow,
1184
+ report.rereadForensics,
1185
+ report.avoidableWait,
1186
+ report.counterfactual,
1187
+ ];
1188
+ if (!scale ||
1189
+ !canonical ||
1190
+ !Array.isArray(report.generatedFrom) ||
1191
+ !Array.isArray(report.repos) ||
1192
+ requiredObjects.some((entry) => !recordValue(entry)) ||
1193
+ typeof report.llmCallsUsed !== "number" ||
1194
+ !Number.isFinite(report.llmCallsUsed) ||
1195
+ typeof report.transcriptsUploaded !== "boolean" ||
1196
+ !(report.scanStartDate === null || typeof report.scanStartDate === "string") ||
1197
+ !(report.scanEndDate === null || typeof report.scanEndDate === "string")) {
1198
+ return invalidSetupReport("REPORT_MALFORMED", "The local report is missing required setup fields.");
1199
+ }
1200
+ if (Object.prototype.hasOwnProperty.call(canonical, "error")) {
1201
+ return invalidSetupReport("REPORT_CANONICAL_FAILED", "Canonical context analysis did not complete.");
1202
+ }
1203
+ const summary = recordValue(canonical.summary);
1204
+ if (!summary)
1205
+ return invalidSetupReport("REPORT_MALFORMED", "Canonical context summary is missing.");
1206
+ const sessionCount = nonNegativeInteger(scale.sessionCount);
1207
+ const canonicalSessionCount = nonNegativeInteger(summary.sessionsAnalyzed);
1208
+ const billedInput = nonNegativeInteger(scale.totalInputTokens);
1209
+ const totalTokens = nonNegativeInteger(scale.totalTokens);
1210
+ const coldInput = nonNegativeInteger(scale.coldInputTokens);
1211
+ const cacheWrite = nonNegativeInteger(scale.cacheWriteTokens);
1212
+ const cacheRead = nonNegativeInteger(scale.cacheReadTokens);
1213
+ const output = nonNegativeInteger(scale.outputTokens);
1214
+ const canonicalInput = nonNegativeInteger(summary.officialInputTokens);
1215
+ const canonicalUseful = nonNegativeInteger(summary.usefulTokens);
1216
+ const canonicalWaste = nonNegativeInteger(summary.wasteTokens);
1217
+ if (sessionCount === null ||
1218
+ canonicalSessionCount === null ||
1219
+ billedInput === null ||
1220
+ totalTokens === null ||
1221
+ coldInput === null ||
1222
+ cacheWrite === null ||
1223
+ cacheRead === null ||
1224
+ output === null ||
1225
+ canonicalInput === null ||
1226
+ canonicalUseful === null ||
1227
+ canonicalWaste === null) {
1228
+ return invalidSetupReport("REPORT_USAGE_INVALID", "The report contains invalid token or session totals.");
1229
+ }
1230
+ if (coldInput + cacheWrite + cacheRead !== billedInput || billedInput + output !== totalTokens) {
1231
+ return invalidSetupReport("REPORT_USAGE_INVALID", "Provider token totals do not reconcile.");
1232
+ }
1233
+ const reportCost = recordValue(report.cost);
1234
+ const costByModel = reportCost ? recordValue(reportCost.byModel) : null;
1235
+ const coldInputCost = reportCost ? nonNegativeNumber(reportCost.coldInputCost) : null;
1236
+ const cacheWriteCost = reportCost ? nonNegativeNumber(reportCost.cacheWriteCost) : null;
1237
+ const cacheReadCost = reportCost ? nonNegativeNumber(reportCost.cacheReadCost) : null;
1238
+ const outputCost = reportCost ? nonNegativeNumber(reportCost.outputCost) : null;
1239
+ const totalCost = reportCost ? nonNegativeNumber(reportCost.total) : null;
1240
+ if (!reportCost || !costByModel || coldInputCost === null || cacheWriteCost === null ||
1241
+ cacheReadCost === null || outputCost === null || totalCost === null) {
1242
+ return invalidSetupReport("REPORT_COST_INVALID", "The API-equivalent cost ledger is missing or malformed.");
1243
+ }
1244
+ const componentCost = coldInputCost + cacheWriteCost + cacheReadCost + outputCost;
1245
+ if (Math.abs(componentCost - totalCost) > 0.05) {
1246
+ return invalidSetupReport("REPORT_COST_INVALID", "Cost components do not reconcile with total spend.");
1247
+ }
1248
+ const modelCosts = Object.values(costByModel).map((value) => {
1249
+ const model = recordValue(value);
1250
+ return model ? nonNegativeNumber(model.cost) : null;
1251
+ });
1252
+ if (modelCosts.some((value) => value === null)) {
1253
+ return invalidSetupReport("REPORT_COST_INVALID", "A model cost entry is malformed.");
1254
+ }
1255
+ const modelCostSum = modelCosts.reduce((sum, value) => sum + (value ?? 0), 0);
1256
+ if (Math.abs(modelCostSum - totalCost) > Math.max(0.05, modelCosts.length * 0.011)) {
1257
+ return invalidSetupReport("REPORT_COST_INVALID", "Per-model costs do not reconcile with total spend.");
1258
+ }
1259
+ const repoCosts = report.repos.map((value) => {
1260
+ const repo = recordValue(value);
1261
+ return repo ? nonNegativeNumber(repo.cost) : null;
1262
+ });
1263
+ if (repoCosts.some((value) => value === null)) {
1264
+ return invalidSetupReport("REPORT_COST_INVALID", "A workspace cost entry is malformed.");
1265
+ }
1266
+ const repoCostSum = repoCosts.reduce((sum, value) => sum + (value ?? 0), 0);
1267
+ if (Math.abs(repoCostSum - totalCost) > Math.max(0.05, repoCosts.length * 0.011)) {
1268
+ return invalidSetupReport("REPORT_COST_INVALID", "Per-workspace costs do not reconcile with total spend.");
1269
+ }
1270
+ const diagnostics = canonical.diagnostics === undefined ? null : recordValue(canonical.diagnostics);
1271
+ if (canonical.diagnostics !== undefined && !diagnostics) {
1272
+ return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
1273
+ }
1274
+ if (diagnostics) {
1275
+ const skippedSessions = nonNegativeInteger(diagnostics.skippedSessions);
1276
+ const errors = diagnostics.errors;
1277
+ if (skippedSessions === null || !Array.isArray(errors)) {
1278
+ return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
1279
+ }
1280
+ if (skippedSessions > 0 || errors.length > 0) {
1281
+ return invalidSetupReport("REPORT_PARTIAL", "Some local sessions could not be analyzed; no partial report was shown.");
1282
+ }
1283
+ }
1284
+ if (sessionCount !== canonicalSessionCount) {
1285
+ return invalidSetupReport("REPORT_COHORT_MISMATCH", "Provider and canonical ledgers cover different session cohorts.");
1286
+ }
1287
+ if (canonicalUseful + canonicalWaste !== canonicalInput) {
1288
+ return invalidSetupReport("REPORT_CANONICAL_INVALID", "Canonical useful and waste totals do not reconcile.");
1289
+ }
1290
+ const canonicalWastePct = canonicalInput > 0 ? (canonicalWaste / canonicalInput) * 100 : 0;
1291
+ const canonicalUsefulPct = canonicalInput > 0 ? (canonicalUseful / canonicalInput) * 100 : 0;
1292
+ if (!approximatelyEqual(summary.wastePct, canonicalWastePct) ||
1293
+ !approximatelyEqual(summary.usefulPct, canonicalUsefulPct)) {
1294
+ return invalidSetupReport("REPORT_CANONICAL_INVALID", "Canonical token percentages do not reconcile with their totals.");
1295
+ }
1296
+ if (sessionCount === 0) {
1297
+ if (totalTokens !== 0 ||
1298
+ billedInput !== 0 ||
1299
+ coldInput !== 0 ||
1300
+ cacheWrite !== 0 ||
1301
+ cacheRead !== 0 ||
1302
+ output !== 0 ||
1303
+ canonicalInput !== 0 ||
1304
+ canonicalUseful !== 0 ||
1305
+ canonicalWaste !== 0) {
1306
+ return invalidSetupReport("REPORT_EMPTY_USAGE_MISMATCH", "An empty scan contains unexpected token usage.");
1307
+ }
1308
+ if (report.billedWasteProjection !== undefined) {
1309
+ return invalidSetupReport("REPORT_PROJECTION_INVALID", "An empty scan must not contain a billed waste projection.");
1310
+ }
1311
+ return { ok: true, kind: "empty", report: report };
1312
+ }
1313
+ const hasRenderableRepo = report.repos.some((value) => {
1314
+ const repo = recordValue(value);
1315
+ return !!repo && typeof repo.name === "string" && repo.name.trim().length > 0 &&
1316
+ nonNegativeInteger(repo.tokens) !== null && Number(repo.tokens) > 0;
1317
+ });
1318
+ if (!hasRenderableRepo) {
1319
+ return invalidSetupReport("REPORT_REPOS_EMPTY", "A non-empty scan has no tokenized local workspace to render.");
1320
+ }
1321
+ if (billedInput <= 0 || canonicalInput <= 0) {
1322
+ return invalidSetupReport("REPORT_USAGE_INVALID", "A non-empty scan must contain positive provider and canonical input totals.");
1323
+ }
1324
+ if (canonicalInput > billedInput) {
1325
+ return invalidSetupReport("REPORT_LEDGER_INVERTED", "Canonical input exceeds provider-billed input for the same sessions.");
1326
+ }
1327
+ const expectedProjection = projectCanonicalWasteToBilledInput(billedInput, {
1328
+ officialInputTokens: canonicalInput,
1329
+ wasteTokens: canonicalWaste,
1330
+ });
1331
+ const projection = recordValue(report.billedWasteProjection);
1332
+ if (!expectedProjection || !projection) {
1333
+ return invalidSetupReport("REPORT_PROJECTION_MISSING", "The billed waste projection is missing for a non-empty scan.");
1334
+ }
1335
+ if (projection.method !== expectedProjection.method ||
1336
+ nonNegativeInteger(projection.billedInputTokens) !== expectedProjection.billedInputTokens ||
1337
+ nonNegativeInteger(projection.canonicalInputTokens) !== expectedProjection.canonicalInputTokens ||
1338
+ nonNegativeInteger(projection.canonicalUsefulTokens) !== expectedProjection.canonicalUsefulTokens ||
1339
+ nonNegativeInteger(projection.canonicalWasteTokens) !== expectedProjection.canonicalWasteTokens ||
1340
+ nonNegativeInteger(projection.projectedUsefulTokens) !== expectedProjection.projectedUsefulTokens ||
1341
+ nonNegativeInteger(projection.projectedWasteTokens) !== expectedProjection.projectedWasteTokens ||
1342
+ !approximatelyEqual(projection.billingMultiplier, expectedProjection.billingMultiplier) ||
1343
+ !approximatelyEqual(projection.usefulPct, expectedProjection.usefulPct) ||
1344
+ !approximatelyEqual(projection.wastePct, expectedProjection.wastePct)) {
1345
+ return invalidSetupReport("REPORT_PROJECTION_INVALID", "The billed waste projection does not reconcile with its source ledgers.");
1346
+ }
1347
+ if (expectedProjection.projectedUsefulTokens + expectedProjection.projectedWasteTokens !== billedInput ||
1348
+ !approximatelyEqual(expectedProjection.projectedWasteTokens / billedInput, canonicalWaste / canonicalInput, 1 / Math.max(1, billedInput))) {
1349
+ return invalidSetupReport("REPORT_PROJECTION_INVALID", "The billed waste projection violates its token-share identity.");
1350
+ }
1351
+ return { ok: true, kind: "ready", report: report };
1352
+ }
711
1353
  /** Scan both sources and build the merged forensic report. Local-only, $0, never throws on bad files. */
712
- export function buildForensicReport(opts) {
1354
+ export async function buildForensicReport(opts) {
713
1355
  const sources = opts?.sources ?? ["codex", "claude"];
714
1356
  const eng = new Forensics();
715
- // Path-sorted within each source so the order-dependent reread/version state is deterministic.
716
- const codexFiles = sources.includes("codex")
717
- ? walk(path.join(os.homedir(), ".codex", "sessions"), (p) => /rollout-.*\.jsonl$/.test(p), () => false).sort()
718
- : [];
719
- // Include ALL Claude transcripts (subagents/workflows too): their reads/tokens are real agent activity.
720
- const claudeFiles = sources.includes("claude")
721
- ? walk(path.join(os.homedir(), ".claude", "projects"), (p) => p.endsWith(".jsonl"), () => false).sort()
1357
+ const codexDiscovery = sources.includes("codex")
1358
+ ? discoverCodexSessionFiles({ includeArchived: opts?.includeArchivedCodex !== false })
1359
+ : null;
1360
+ const claudeRoot = sources.includes("claude") ? resolveClaudeProjectsDir() : null;
1361
+ // Discovery combines active + archived stores, de-duplicates by stable session identity, and orders
1362
+ // sessions by semantic start time. Canonical human-turn analysis intentionally excludes subagents.
1363
+ const reportCodexSessions = codexDiscovery?.files.filter((file) => file.userInitiated !== false) ?? [];
1364
+ // Keep the canonical denominator on the same top-level-session population as the billed ledger.
1365
+ // Legacy sessions without provenance are already admitted to reportCodexSessions; dropping them
1366
+ // only here creates a small but real numerator/denominator scope mismatch.
1367
+ const canonicalCodexFiles = reportCodexSessions.map((file) => file.path);
1368
+ const codexFiles = reportCodexSessions.map((file) => file.path);
1369
+ // Claude stores subagent/workflow implementation logs separately. They can share a parent
1370
+ // session ID and must not be counted as additional user conversations.
1371
+ const claudeFiles = claudeRoot
1372
+ ? walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows").sort()
722
1373
  : [];
723
1374
  const total = codexFiles.length + claudeFiles.length;
724
1375
  let done = 0;
725
- const tick = () => {
1376
+ const progress = (stage, detail, progressDone = done, progressTotal = total) => {
1377
+ opts?.onProgress?.(progressDone, progressTotal, stage, detail);
1378
+ };
1379
+ progress("reading-transcripts", "finding local Codex and Claude transcript files");
1380
+ const tick = (stage = "reading-transcripts") => {
726
1381
  done++;
727
1382
  if (opts?.onProgress && (done % 8 === 0 || done === total))
728
- opts.onProgress(done, total);
1383
+ progress(stage);
729
1384
  };
730
1385
  // Codex is the slow source (GB-scale rollouts). Cache each file's parse by mtime+size so only
731
- // changed/new sessions reparse; the cross-session reread state is rebuilt from the cached events in
732
- // the same file order, so the result is identical to a live scan.
1386
+ // changed/new sessions reparse; the cross-session state is rebuilt later on one provider timeline.
733
1387
  const cache = loadForensicCache();
734
1388
  const nextCache = {};
1389
+ const parsedCodexFiles = [];
1390
+ const discoveredCodexByPath = new Map(codexDiscovery?.files.map((file) => [file.path, file]) ?? []);
735
1391
  for (const f of codexFiles) {
736
1392
  let st;
737
1393
  try {
738
1394
  st = fs.statSync(f);
739
1395
  }
740
1396
  catch {
741
- tick();
1397
+ tick("reading-transcripts");
742
1398
  continue;
743
1399
  }
744
1400
  const hit = cache[f];
745
1401
  const fe = hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size ? hit.fe : extractCodex(f);
1402
+ const discovered = discoveredCodexByPath.get(f);
1403
+ if (!fe.session && discovered)
1404
+ fe.session = discovered.sessionKey;
746
1405
  nextCache[f] = { mtimeMs: st.mtimeMs, size: st.size, fe };
747
- replayFile(eng, fe);
748
- tick();
1406
+ parsedCodexFiles.push({ path: f, fe });
1407
+ tick("reading-transcripts");
749
1408
  }
750
1409
  if (codexFiles.length)
751
1410
  saveForensicCache(nextCache);
752
- // Claude is fast (~0.6s) — scan live, no cache needed.
1411
+ // Claude is fast (~0.6s) — parse live, then merge its events with Codex before replay.
1412
+ const timelineFiles = [...parsedCodexFiles];
753
1413
  for (const f of claudeFiles) {
754
- feedClaude(f, eng);
755
- tick();
1414
+ timelineFiles.push({ path: f, fe: extractClaude(f) });
1415
+ tick("reading-transcripts");
1416
+ }
1417
+ timelineFiles.sort((a, b) => (a.fe.firstTs ?? Number.POSITIVE_INFINITY) - (b.fe.firstTs ?? Number.POSITIVE_INFINITY) ||
1418
+ a.path.localeCompare(b.path));
1419
+ replayTimelineFiles(eng, timelineFiles);
1420
+ progress("building-summary", "aggregating rereads, model usage, cost, and local context signals");
1421
+ const report = eng.build();
1422
+ const firstTimelineSession = timelineFiles.find(({ fe }) => fe.firstTs != null);
1423
+ report.firstSession = firstTimelineSession ? {
1424
+ date: firstTimelineSession.fe.firstTs != null ? new Date(firstTimelineSession.fe.firstTs).toISOString() : null,
1425
+ source: firstTimelineSession.fe.source,
1426
+ repo: firstTimelineSession.fe.cwd ? path.basename(firstTimelineSession.fe.cwd) : "workspace",
1427
+ } : null;
1428
+ const activeCodexCount = reportCodexSessions.filter((file) => file.rootKind === "active").length;
1429
+ report.activeSessionCoverage = {
1430
+ codex: activeCodexCount,
1431
+ claudeCode: claudeFiles.length,
1432
+ total: activeCodexCount + claudeFiles.length,
1433
+ };
1434
+ report.generatedFrom = [
1435
+ ...(codexDiscovery?.roots.map((root) => root.kind === "active" ? "$CODEX_HOME/sessions" : "$CODEX_HOME/archived_sessions") ?? []),
1436
+ ...(claudeRoot ? ["$CLAUDE_CONFIG_DIR/projects"] : []),
1437
+ ];
1438
+ if (codexDiscovery) {
1439
+ report.sourceCoverage = {
1440
+ codex: {
1441
+ activeSessions: reportCodexSessions.filter((file) => file.rootKind === "active").length,
1442
+ archivedSessions: reportCodexSessions.filter((file) => file.rootKind === "archived").length,
1443
+ unclassifiedSessions: reportCodexSessions.filter((file) => file.userInitiated == null).length,
1444
+ duplicateSessionsDiscarded: codexDiscovery.diagnostics.conflicts.length,
1445
+ identityFallbacks: codexDiscovery.diagnostics.filenameIdentityFallbacks + codexDiscovery.diagnostics.pathIdentityFallbacks,
1446
+ timelineBasis: "provider-event-timestamps",
1447
+ },
1448
+ };
1449
+ }
1450
+ if (opts?.includeLegacyGoldenStandard !== false) {
1451
+ try {
1452
+ report.goldenStandard = buildWorkspaceContextReport();
1453
+ }
1454
+ catch (error) {
1455
+ report.goldenStandard = { error: error instanceof Error ? error.message : String(error) };
1456
+ }
1457
+ }
1458
+ try {
1459
+ const canonicalSources = [];
1460
+ if (sources.includes("codex"))
1461
+ canonicalSources.push("codex");
1462
+ if (sources.includes("claude"))
1463
+ canonicalSources.push("claude-code");
1464
+ progress("classifying-repeated-context", "reconstructing context windows and attributing P01/P03/P08/P10/P13 waste");
1465
+ report.canonicalGoldenStandard = await buildCanonicalGoldenReport({
1466
+ sources: canonicalSources,
1467
+ codexSessionPaths: canonicalCodexFiles,
1468
+ claudeSessionPaths: claudeFiles,
1469
+ // These paths were discovered from the user's local roots. One malformed/unsupported session
1470
+ // must become a diagnostic, not erase the canonical report for every other valid session.
1471
+ strictSessionErrors: false,
1472
+ onProgress: (canonicalDone, canonicalTotal) => progress("classifying-repeated-context", "reconstructing context windows and attributing P01/P03/P08/P10/P13 waste", canonicalDone, canonicalTotal),
1473
+ });
1474
+ enrichCanonicalSessionExamples(report.canonicalGoldenStandard, timelineFiles);
1475
+ }
1476
+ catch (error) {
1477
+ report.canonicalGoldenStandard = { error: error instanceof Error ? error.message : String(error) };
1478
+ }
1479
+ if (report.canonicalGoldenStandard &&
1480
+ !("error" in report.canonicalGoldenStandard) &&
1481
+ (report.canonicalGoldenStandard.diagnostics?.skippedSessions ?? 0) === 0 &&
1482
+ report.scale.sessionCount === report.canonicalGoldenStandard.summary.sessionsAnalyzed) {
1483
+ const projection = projectCanonicalWasteToBilledInput(Number(report.scale.totalInputTokens || 0), report.canonicalGoldenStandard.summary);
1484
+ if (projection)
1485
+ report.billedWasteProjection = projection;
756
1486
  }
757
- return eng.build();
1487
+ progress("finalizing-report", "preparing setup page data", total, total);
1488
+ return report;
758
1489
  }