@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.
- package/README.md +35 -9
- package/assets/canonical-scorer/README.md +18 -0
- package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
- package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
- package/assets/canonical-scorer/golden_anchors.mjs +83 -0
- package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
- package/assets/hud/claude.svg +1 -0
- package/assets/hud/codex.svg +1 -0
- package/assets/hud/session-viewer.html +35 -0
- package/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1126 -109
- package/dist/city/echo-ai-city-only.template.html +1126 -109
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/city/pencil-pie-generator.html +883 -0
- package/dist/city/pencil-webgl-landscape.html +1239 -0
- package/dist/city/spatial-fan-story.html +479 -0
- package/dist/codex-session-files.js +283 -0
- package/dist/codex-sync.js +7 -2
- package/dist/context-analysis/canonical-golden.js +47 -0
- package/dist/context-analysis/claude-native-canonical.js +1193 -0
- package/dist/context-analysis/vendored-canonical.js +793 -0
- package/dist/context-analysis/workspace-report.js +1838 -0
- package/dist/context-metrics/calculate.js +56 -0
- package/dist/context-metrics/model-limits.js +26 -0
- package/dist/context-metrics/types.js +1 -0
- package/dist/forensics-10-problems.js +7 -6
- package/dist/forensics.js +863 -132
- package/dist/hud/adapters.js +8 -4
- package/dist/hud/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +182 -19
- package/dist/hud/metric.js +13 -4
- package/dist/hud/monitor.js +171 -84
- package/dist/hud/preload.cjs +3 -0
- package/dist/hud/server.js +321 -4
- package/dist/hud/web.js +880 -270
- package/dist/index.js +122 -24
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +55 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +4257 -245
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +786 -75
- package/dist/v1-contract.js +20 -2
- package/package.json +6 -4
- 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
|
|
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-
|
|
51
|
-
"claude-opus-4-
|
|
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
|
|
57
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
r.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
|
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(
|
|
269
|
-
const seenSessions = this.fileReadSessions.get(
|
|
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(
|
|
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(
|
|
306
|
-
this.fileReadSessions.set(
|
|
417
|
+
if (!this.fileReadSessions.has(identity))
|
|
418
|
+
this.fileReadSessions.set(identity, new Set());
|
|
307
419
|
if (session)
|
|
308
|
-
this.fileReadSessions.get(
|
|
309
|
-
this.lastReadVersion.set(
|
|
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,
|
|
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(
|
|
382
|
-
cacheWriteCost: round(
|
|
383
|
-
cacheReadCost: round(
|
|
384
|
-
outputCost: round(
|
|
385
|
-
total:
|
|
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(
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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 =
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) —
|
|
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
|
-
|
|
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
|
-
|
|
1487
|
+
progress("finalizing-report", "preparing setup page data", total, total);
|
|
1488
|
+
return report;
|
|
758
1489
|
}
|