@echomem/mcp 1.4.9 → 1.4.12

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 (45) hide show
  1. package/assets/hud/github.svg +1 -0
  2. package/dist/city/README.md +9 -0
  3. package/dist/city/echo-ai-city-only.html +54 -93
  4. package/dist/context-analysis/claude-native-canonical.js +35 -12
  5. package/dist/context-metrics/calculate.js +2 -15
  6. package/dist/context-metrics/estimator.js +45 -0
  7. package/dist/context-metrics/ledger.js +507 -0
  8. package/dist/context-metrics/parse-claude.js +227 -0
  9. package/dist/context-metrics/parse-codex.js +276 -0
  10. package/dist/hud/adapters.js +69 -198
  11. package/dist/hud/electron-main.js +3 -2
  12. package/dist/hud/fs.js +14 -0
  13. package/dist/hud/metric.js +4 -98
  14. package/dist/hud/monitor.js +1 -12
  15. package/dist/hud/render.js +4 -3
  16. package/dist/hud/server.js +30 -0
  17. package/dist/hud/web.js +409 -186
  18. package/dist/index.js +1 -0
  19. package/dist/setup-page/client-core.js +475 -0
  20. package/dist/setup-page/client-extraction.js +550 -0
  21. package/dist/setup-page/client-lifecycle.js +116 -0
  22. package/dist/setup-page/client-report-audit.js +818 -0
  23. package/dist/setup-page/client-report-city.js +204 -0
  24. package/dist/setup-page/client-report.js +6 -0
  25. package/dist/setup-page/client.js +15 -0
  26. package/dist/setup-page/document.js +37 -0
  27. package/dist/setup-page/styles-city-report.js +880 -0
  28. package/dist/setup-page/styles-context-audit.js +470 -0
  29. package/dist/setup-page/styles-extraction.js +821 -0
  30. package/dist/setup-page/styles-foundation.js +231 -0
  31. package/dist/setup-page/styles.js +11 -0
  32. package/dist/setup-page.js +6 -5623
  33. package/dist/setup.js +24 -10
  34. package/package.json +4 -4
  35. package/dist/city/10-problems-report.html +0 -649
  36. package/dist/city/_live.html +0 -37
  37. package/dist/city/_serve.mjs +0 -45
  38. package/dist/city/card-data.json +0 -15
  39. package/dist/city/chaos-to-clarity-pencil.html +0 -582
  40. package/dist/city/city-data.json +0 -248
  41. package/dist/city/echo-ai-city-only.template.html +0 -2271
  42. package/dist/city/generate-echo-city-only.mjs +0 -112
  43. package/dist/city/pencil-pie-generator.html +0 -883
  44. package/dist/city/pencil-webgl-landscape.html +0 -1239
  45. package/dist/city/spatial-fan-story.html +0 -479
@@ -0,0 +1,507 @@
1
+ // Context ledger + per-turn useful/waste classifier.
2
+ //
3
+ // Faithful TypeScript port of the Context Golden Standard scorer
4
+ // (context-golden-standard/ErikMachine-Context_Golden_Standard/tools/optimizable_detail.mjs),
5
+ // validated turn-by-turn against it (0.00pp divergence on 6 codex sessions, 2026-07-12).
6
+ //
7
+ // Two scoring modes over one item ledger:
8
+ // "episode-outcome" offline truth: judges every carried item against its episode's
9
+ // final/anchor turn. Needs the whole session; eval/regression only.
10
+ // "live-causal" HUD estimator: same partition using only information available at
11
+ // turn t, plus per-category uncertainty totals that the calibrated
12
+ // rates (calibration.ts) convert into an expected-value estimate.
13
+ //
14
+ // Every turn's real input window C_t is partitioned into:
15
+ // keep_oh + keep_prod + opt_dup + opt_refind + opt_dead (sums to C_t)
16
+ // signal% = 100 − optimizable%, where optimizable = opt_dup + opt_refind + opt_dead.
17
+ // ---- golden token model ----
18
+ export const R_CODE = 3.3;
19
+ export const R_TEXT = 4.0;
20
+ export const IMG_TOK = 4000;
21
+ export const TRUNC_CAP = 12000;
22
+ const SER_FRAC = 0.03;
23
+ const IMG_FRESH_AGE = 2; // kept screenshots older than this survived the edit cycle
24
+ const CONTENT = new Set(["read", "search", "command", "image", "written", "conv_user", "conv_agent"]);
25
+ const BANDS = ["keep_oh", "keep_prod", "opt_dup", "opt_refind", "opt_dead"];
26
+ const USER_REFERENCE_REPLACEMENT = /\b(use this instead|instead use|replace|updated|new mockup|new reference|ignore (?:the )?(?:previous|old)|actually use|latest version|use this version)\b/i;
27
+ // ---- ground-truth anchors (port of golden_anchors.mjs) ----
28
+ export function isStrongPositiveFeedback(message) {
29
+ const text = String(message || "").toLowerCase().replace(/[’]/g, "'").replace(/\s+/g, " ").trim();
30
+ if (!text)
31
+ return false;
32
+ if (/\b(no|not|wrong|incorrect|fix|bad|worse|broken|laggy|shaking|remove|revert|don't|do not|doesn't|does not|didn't|did not|isn't|is not)\b/.test(text)) {
33
+ if (!/\b(save this|looks good|perfect|exactly what i want|that's what i want|that is what i want)\b/.test(text))
34
+ return false;
35
+ }
36
+ return [
37
+ /\blooks? good\b/, /\bthis is good\b/, /\bcaveat font is good\b/, /\bperfect\b/,
38
+ /\bthis is exactly\b/, /\bthat's exactly\b/, /\bthat is exactly\b/, /\bexactly what i want\b/,
39
+ /\bthat's what i want\b/, /\bthat is what i want\b/, /\bwhat i want\b/,
40
+ /\byes[,.\s].*\blet'?s do it\b/, /\bok[,.\s].*\blet'?s do it\b/, /\blet'?s do it\b/,
41
+ /\bsave this\b/, /\bship it\b/, /\bapproved\b/,
42
+ ].some((pattern) => pattern.test(text));
43
+ }
44
+ function chooseAnchors(ledger, userTurns) {
45
+ const commits = [...ledger.commitTurns].sort((a, b) => a - b);
46
+ if (commits.length)
47
+ return { source: "commit", turns: commits };
48
+ const positive = userTurns.filter((turn) => isStrongPositiveFeedback(ledger.userMessages.get(turn) || ""));
49
+ if (positive.length)
50
+ return { source: "positive-user-feedback", turns: positive };
51
+ const lastTurn = userTurns[userTurns.length - 1];
52
+ return lastTurn ? { source: "final-fallback", turns: [lastTurn] } : { source: "none", turns: [] };
53
+ }
54
+ // ---- shared text signals (golden tier-2 detection) ----
55
+ function signals(text) {
56
+ const out = new Set();
57
+ for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g))
58
+ out.add("c:" + m[0].toLowerCase());
59
+ for (const m of text.matchAll(/--[a-zA-Z][\w-]{2,}/g))
60
+ out.add("v:" + m[0].toLowerCase());
61
+ for (const m of text.matchAll(/\b\d{1,4}(?:\.\d+)?(?:px|rem|em|vh|vw|ms)\b/g))
62
+ out.add("u:" + m[0].toLowerCase());
63
+ return out;
64
+ }
65
+ const rangeOverlap = (a, b) => !a || !b || (a[0] <= b[1] && b[0] <= a[1]);
66
+ function firstGreaterIndex(sorted, value) {
67
+ let lo = 0, hi = sorted.length;
68
+ while (lo < hi) {
69
+ const mid = (lo + hi) >> 1;
70
+ if (sorted[mid] <= value)
71
+ lo = mid + 1;
72
+ else
73
+ hi = mid;
74
+ }
75
+ return lo;
76
+ }
77
+ const anyInRange = (sorted, gt, lte) => {
78
+ const i = firstGreaterIndex(sorted, gt);
79
+ return i < sorted.length && sorted[i] <= lte;
80
+ };
81
+ export function classifySession(ledger, scoringMode, onlyLatestTurn = false) {
82
+ const isOutcome = scoringMode === "episode-outcome";
83
+ const { items, usage, overhead, compactionTurns, editEpoch, userMessages } = ledger;
84
+ const allUserTurns = [...new Set(items.filter((i) => i.kind === "conv_user" && !i.compactSummary && i.turn).map((i) => i.turn))]
85
+ .sort((a, b) => a - b)
86
+ .filter((t) => (usage.get(t) || 0) > 0);
87
+ const userTurns = onlyLatestTurn ? allUserTurns.slice(-1) : allUserTurns;
88
+ const lastCompBefore = (t) => { let c = 0; for (const x of compactionTurns)
89
+ if (x < t)
90
+ c = x; return c; };
91
+ const anchors = chooseAnchors(ledger, allUserTurns);
92
+ const commits = anchors.turns;
93
+ const episodeOf = (t) => { let e = 1; for (const c of commits) {
94
+ if (t <= c)
95
+ return e;
96
+ e++;
97
+ } return e; };
98
+ const firstUserTurn = allUserTurns[0] || 1;
99
+ const lastUserTurn = allUserTurns[allUserTurns.length - 1] || 1;
100
+ const episodeStartTurn = (e) => (e <= 1 ? firstUserTurn : (commits[e - 2] || firstUserTurn - 1) + 1);
101
+ const episodeEndTurn = (e) => commits[e - 1] || lastUserTurn;
102
+ // causal episode boundaries: only commits that have already happened by turn t
103
+ const commitAnchorsAsc = [...ledger.commitTurns].sort((a, b) => a - b);
104
+ const episodeOfAt = (turn, t) => { let e = 1; for (const c of commitAnchorsAsc) {
105
+ if (c > t)
106
+ break;
107
+ if (turn <= c)
108
+ return e;
109
+ e++;
110
+ } return e; };
111
+ const causalEpisodeStart = (t) => { let s = 1; for (const c of commitAnchorsAsc) {
112
+ if (c < t)
113
+ s = c + 1;
114
+ else
115
+ break;
116
+ } return s; };
117
+ const causalAnchorTurn = (t) => ledger.commitTurns.has(t) || isStrongPositiveFeedback(userMessages.get(t) || "");
118
+ // ---- indexes (all built once; every per-turn helper is a bounded scan or binary search) ----
119
+ const editTurnsSorted = [...editEpoch.values()].flat().sort((a, b) => a - b);
120
+ const readsByFile = new Map();
121
+ for (const it of items) {
122
+ if (it.kind !== "read" || !it.file)
123
+ continue;
124
+ const list = readsByFile.get(it.file) || [];
125
+ list.push(it);
126
+ readsByFile.set(it.file, list);
127
+ }
128
+ const screenshotTurnsByTarget = new Map();
129
+ const refItems = [];
130
+ for (const it of items) {
131
+ if (it.kind !== "image")
132
+ continue;
133
+ if (it.source === "tool_screenshot") {
134
+ const key = it.target || "tool_image";
135
+ const list = screenshotTurnsByTarget.get(key) || [];
136
+ list.push(it.turn);
137
+ screenshotTurnsByTarget.set(key, list);
138
+ }
139
+ else if (it.source === "user_reference")
140
+ refItems.push(it);
141
+ }
142
+ for (const list of screenshotTurnsByTarget.values())
143
+ list.sort((a, b) => a - b);
144
+ // turns whose user message uses replacement language AND posts a reference image
145
+ const replacementRefTurnsSorted = [...new Set(refItems.map((r) => r.turn))]
146
+ .filter((turn) => USER_REFERENCE_REPLACEMENT.test(userMessages.get(turn) || ""))
147
+ .sort((a, b) => a - b);
148
+ const firstRefTurn = refItems.length ? Math.min(...refItems.map((r) => r.turn)) : Infinity;
149
+ const churnFromTurn = replacementRefTurnsSorted.find((rt) => rt > firstRefTurn) ?? Infinity;
150
+ const editsOfFile = (f) => (f ? editEpoch.get(f) || [] : []);
151
+ const editBetween = (f, gt, lte) => anyInRange(editsOfFile(f), gt, lte);
152
+ const hasEditAfter = (gt, lte) => anyInRange(editTurnsSorted, gt, lte);
153
+ const hasLaterScreenshot = (it, endTurn) => anyInRange(screenshotTurnsByTarget.get(it.target || "tool_image") || [], it.turn, endTurn);
154
+ const userReferenceSuperseded = (it, endTurn) => anyInRange(replacementRefTurnsSorted, it.turn, endTurn);
155
+ // duplicate reads — golden semantics; the "at" variant is the causal mirror where the newest
156
+ // copy stays fresh and superseded older copies become the duplicates.
157
+ const isDupRead = (it) => it.kind === "read" && !!it.file &&
158
+ (readsByFile.get(it.file) || []).some((h) => h.turn < it.turn && rangeOverlap(h.range, it.range) && !editBetween(it.file, h.turn, it.turn));
159
+ const isDupReadAt = (it, t) => it.kind === "read" && !!it.file &&
160
+ (readsByFile.get(it.file) || []).some((h) => h.turn > it.turn && h.turn <= t && rangeOverlap(h.range, it.range) && !editBetween(it.file, it.turn, h.turn));
161
+ const hasLaterDuplicateReadBeforeOutcome = (it, end) => isDupReadAt(it, end);
162
+ // ---- tier model: does an unedited file's content connect to what got written? ----
163
+ const writeSigTimeline = [];
164
+ {
165
+ let cum = new Set();
166
+ for (const ch of ledger.writeChunks) {
167
+ cum = new Set(cum);
168
+ for (const s of signals(ch.text))
169
+ cum.add(s);
170
+ writeSigTimeline.push({ turn: ch.turn, cum });
171
+ }
172
+ }
173
+ const readSigTimeline = new Map();
174
+ for (const ch of ledger.readChunks) {
175
+ const arr = readSigTimeline.get(ch.file) || [];
176
+ const prev = arr.length ? arr[arr.length - 1].cum : new Set();
177
+ const cum = new Set(prev);
178
+ for (const s of signals(ch.text))
179
+ cum.add(s);
180
+ arr.push({ turn: ch.turn, cum });
181
+ readSigTimeline.set(ch.file, arr);
182
+ }
183
+ const latestAt = (arr, t) => {
184
+ let out = null;
185
+ for (const e of arr) {
186
+ if (e.turn <= t)
187
+ out = e.cum;
188
+ else
189
+ break;
190
+ }
191
+ return out;
192
+ };
193
+ const tierMatches = (sig, ws) => {
194
+ if (!sig)
195
+ return false;
196
+ let m = 0;
197
+ for (const x of sig)
198
+ if (ws.has(x))
199
+ m++;
200
+ return m >= 4 || (sig.size >= 8 && m / sig.size >= 0.15);
201
+ };
202
+ const fullWriteSig = writeSigTimeline.length ? writeSigTimeline[writeSigTimeline.length - 1].cum : new Set();
203
+ const editedByTurn = (f, t) => anyInRange(editsOfFile(f), 0, t);
204
+ const tierOf = (f) => {
205
+ if (!f)
206
+ return 3;
207
+ if (editEpoch.has(f))
208
+ return 1;
209
+ const arr = readSigTimeline.get(f);
210
+ return tierMatches(arr ? arr[arr.length - 1].cum : null, fullWriteSig) ? 2 : 3;
211
+ };
212
+ const tierOfCausal = (f, t) => {
213
+ if (!f)
214
+ return 3;
215
+ if (editedByTurn(f, t))
216
+ return 1;
217
+ const ws = latestAt(writeSigTimeline, t) || new Set();
218
+ return tierMatches(latestAt(readSigTimeline.get(f) || [], t), ws) ? 2 : 3;
219
+ };
220
+ const editedThisEpisodeByT = (f, t) => anyInRange(editsOfFile(f), causalEpisodeStart(t) - 1, t);
221
+ const transientKey = (it) => {
222
+ if (it.kind === "search")
223
+ return `search:${it.searchPattern || it.normalizedCommand || ""}`;
224
+ if (it.kind === "command")
225
+ return `command:${it.normalizedCommand || ""}`;
226
+ return "";
227
+ };
228
+ const episodeOutcome = new Map();
229
+ if (isOutcome) {
230
+ const maxEpisode = Math.max(1, ...allUserTurns.map((t) => episodeOf(t)));
231
+ for (let e = 1; e <= maxEpisode; e++) {
232
+ const start = episodeStartTurn(e);
233
+ const end = episodeEndTurn(e);
234
+ const editedFiles = new Set();
235
+ for (const [file, turns] of editEpoch.entries())
236
+ if (turns.some((x) => x >= start && x <= end))
237
+ editedFiles.add(file);
238
+ const writtenItems = items.filter((it) => it.kind === "written" && it.turn >= start && it.turn <= end).sort((a, b) => b.seq - a.seq);
239
+ const usefulWrittenSeqs = new Set();
240
+ const latestWrittenByFile = new Map();
241
+ const unfiled = [];
242
+ for (const it of writtenItems) {
243
+ const files = it.files?.length ? it.files : it.file ? [it.file] : [];
244
+ if (!files.length) {
245
+ unfiled.push(it);
246
+ continue;
247
+ }
248
+ for (const file of files) {
249
+ const n = latestWrittenByFile.get(file) || 0;
250
+ if (n < 2) {
251
+ usefulWrittenSeqs.add(it.seq);
252
+ latestWrittenByFile.set(file, n + 1);
253
+ }
254
+ }
255
+ }
256
+ for (const it of unfiled.slice(0, 2))
257
+ usefulWrittenSeqs.add(it.seq);
258
+ const latestTransientByKey = new Map();
259
+ for (const it of items) {
260
+ if (it.turn < start || it.turn > end)
261
+ continue;
262
+ if (it.kind !== "search" && it.kind !== "command")
263
+ continue;
264
+ const key = transientKey(it);
265
+ if (!key)
266
+ continue;
267
+ const prev = latestTransientByKey.get(key);
268
+ if (prev === undefined || it.seq > prev)
269
+ latestTransientByKey.set(key, it.seq);
270
+ }
271
+ episodeOutcome.set(e, { start, end, editedFiles, usefulWrittenSeqs, latestTransientByKey });
272
+ }
273
+ }
274
+ const imageBucket = (it, currentTurn, epOf) => {
275
+ if (it.source === "user_reference") {
276
+ if (epOf(it.turn) !== epOf(currentTurn))
277
+ return "opt_dead";
278
+ return userReferenceSuperseded(it, currentTurn) ? "opt_dead" : "keep_prod";
279
+ }
280
+ if (it.source === "tool_screenshot") {
281
+ if (hasEditAfter(it.turn, currentTurn) || hasLaterScreenshot(it, currentTurn))
282
+ return "opt_dead";
283
+ return "keep_prod";
284
+ }
285
+ return "opt_dead";
286
+ };
287
+ const outcomeBucket = (it, currentTurn) => {
288
+ const e = episodeOf(currentTurn);
289
+ const outcome = episodeOutcome.get(e) || { start: episodeStartTurn(e), end: episodeEndTurn(e), editedFiles: new Set(), usefulWrittenSeqs: new Set(), latestTransientByKey: new Map() };
290
+ if (it.kind === "conv_user")
291
+ return episodeOf(it.turn) === e ? "keep_prod" : "opt_dead";
292
+ if (it.kind === "conv_agent")
293
+ return it.turn === outcome.end && currentTurn === outcome.end ? "keep_prod" : "opt_refind";
294
+ if (it.kind === "search" || it.kind === "command") {
295
+ const key = transientKey(it);
296
+ const latest = key ? outcome.latestTransientByKey.get(key) : undefined;
297
+ if (latest !== undefined && latest !== it.seq)
298
+ return "opt_refind";
299
+ return it.turn === outcome.end && currentTurn === outcome.end ? "keep_prod" : "opt_refind";
300
+ }
301
+ if (it.kind === "image")
302
+ return imageBucket(it, outcome.end, episodeOf);
303
+ if (it.kind === "written")
304
+ return outcome.usefulWrittenSeqs.has(it.seq) ? "keep_prod" : "opt_dup";
305
+ if (it.kind === "read") {
306
+ if (hasLaterDuplicateReadBeforeOutcome(it, outcome.end))
307
+ return "opt_dup";
308
+ if (outcome.editedFiles.size)
309
+ return it.file && outcome.editedFiles.has(it.file) ? "keep_prod" : "opt_dead";
310
+ return tierOf(it.file) <= 2 ? "keep_prod" : "opt_dead";
311
+ }
312
+ return null;
313
+ };
314
+ // codex cumulative reasoning -> per-turn segments since the last compaction
315
+ const maxReasoningAt = new Map();
316
+ for (const x of ledger.reasoningCalls)
317
+ maxReasoningAt.set(x.turn, Math.max(maxReasoningAt.get(x.turn) || 0, x.tokens));
318
+ const reasoningSegments = (lastCompactionTurn, currentTurn) => {
319
+ const segments = [];
320
+ let previous = 0;
321
+ for (let k = lastCompactionTurn + 1; k <= currentTurn; k++) {
322
+ const cumulative = Math.max(previous, maxReasoningAt.get(k) || previous);
323
+ if (cumulative - previous > 0)
324
+ segments.push({ turn: k, tokens: cumulative - previous });
325
+ previous = cumulative;
326
+ }
327
+ return segments;
328
+ };
329
+ const series = [];
330
+ const agg = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
331
+ for (const t of userTurns) {
332
+ const lc = lastCompBefore(t);
333
+ const C_t = usage.get(t) || 0;
334
+ const b = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
335
+ let deadReadTokens = 0;
336
+ let deadImageTokens = 0;
337
+ let dupCount = 0;
338
+ let reasoning = 0;
339
+ if (ledger.reasoningStyle === "cumulative") {
340
+ for (const segment of reasoningSegments(lc, t)) {
341
+ reasoning += segment.tokens;
342
+ const keep = isOutcome
343
+ ? segment.turn === t && t === episodeEndTurn(episodeOf(t))
344
+ : segment.turn === t;
345
+ b[keep ? "keep_prod" : "opt_refind"] += segment.tokens;
346
+ }
347
+ }
348
+ const structureFloor = Math.round(SER_FRAC * C_t);
349
+ b.keep_oh = overhead + structureFloor;
350
+ const budget = Math.max(0, C_t - overhead - reasoning - structureFloor);
351
+ const carried = [];
352
+ for (const it of items) {
353
+ if (it.turn > t)
354
+ break;
355
+ if (ledger.hardCompactionDrop && it.turn <= lc && !(it.compactSummary && it.turn >= lc))
356
+ continue;
357
+ if (it.kind === "reasoning") {
358
+ if (it.turn === t)
359
+ carried.push(it);
360
+ continue;
361
+ }
362
+ if (it.compactSummary || CONTENT.has(it.kind))
363
+ carried.push(it);
364
+ }
365
+ carried.reverse();
366
+ const un = { stReads: 0, cReads: 0, stTransients: 0, stAgent: 0, stReasoning: 0, deadReads: 0, imgRefs: 0, imgRefsCalm: 0, imgShots: 0 };
367
+ const anchorNow = !isOutcome && causalAnchorTurn(t);
368
+ const commitHappened = commitAnchorsAsc.length > 0 && commitAnchorsAsc[0] <= t;
369
+ const episodeHasEdits = anyInRange(editTurnsSorted, causalEpisodeStart(t) - 1, t);
370
+ const cReadsExempt = commitHappened && !episodeHasEdits;
371
+ const refChurnObserved = churnFromTurn <= t;
372
+ const epOfLive = (turn) => episodeOfAt(turn, t);
373
+ let running = 0;
374
+ const seenWriteByFile = new Map();
375
+ const seenSameTurnTransient = new Set();
376
+ for (const it of carried) {
377
+ if (running >= budget)
378
+ break;
379
+ const tok = Math.min(it.tokens, budget - running);
380
+ running += tok;
381
+ let bucket = null;
382
+ let isDeadRead = false;
383
+ let isDeadImage = false;
384
+ if (it.compactSummary)
385
+ bucket = "keep_prod"; // distilled carried state
386
+ else if (it.kind === "reasoning") {
387
+ // items-style reasoning is only carried same-turn; the outcome judge keeps it only
388
+ // when this turn is its episode's anchor
389
+ bucket = isOutcome ? (t === episodeEndTurn(episodeOf(t)) ? "keep_prod" : "opt_refind") : "keep_prod";
390
+ if (!isOutcome && !anchorNow)
391
+ un.stReasoning += tok;
392
+ }
393
+ else if (isOutcome) {
394
+ bucket = outcomeBucket(it, t);
395
+ if (bucket === "opt_dead") {
396
+ isDeadRead = it.kind === "read";
397
+ isDeadImage = it.kind === "image";
398
+ }
399
+ if (bucket === "opt_dup" && it.kind === "read")
400
+ dupCount += 1;
401
+ }
402
+ else {
403
+ if (it.kind === "conv_user")
404
+ bucket = it.turn < causalEpisodeStart(t) ? "opt_dead" : "keep_prod";
405
+ else if (it.kind === "conv_agent") {
406
+ bucket = it.turn === t ? "keep_prod" : "opt_refind";
407
+ if (bucket === "keep_prod" && !anchorNow)
408
+ un.stAgent += tok;
409
+ }
410
+ else if (it.kind === "search" || it.kind === "command") {
411
+ if (it.turn === t) {
412
+ const key = transientKey(it);
413
+ bucket = key && seenSameTurnTransient.has(key) ? "opt_refind" : "keep_prod";
414
+ if (key)
415
+ seenSameTurnTransient.add(key);
416
+ if (bucket === "keep_prod" && !anchorNow)
417
+ un.stTransients += tok;
418
+ }
419
+ else
420
+ bucket = "opt_refind";
421
+ }
422
+ else if (it.kind === "image") {
423
+ bucket = imageBucket(it, t, epOfLive);
424
+ if (bucket === "keep_prod") {
425
+ if (it.source === "user_reference")
426
+ un[refChurnObserved ? "imgRefs" : "imgRefsCalm"] += tok;
427
+ else if (t - it.turn <= IMG_FRESH_AGE)
428
+ un.imgShots += tok;
429
+ }
430
+ else
431
+ isDeadImage = true;
432
+ }
433
+ else if (it.kind === "written") {
434
+ if (it.turn < causalEpisodeStart(t))
435
+ bucket = "opt_dup"; // superseded copies from closed episodes
436
+ else {
437
+ const wf = it.file || (it.files?.length === 1 ? it.files[0] : "__unfiled__");
438
+ const seen = seenWriteByFile.get(wf) || 0;
439
+ if (seen < 2) {
440
+ bucket = "keep_prod";
441
+ seenWriteByFile.set(wf, seen + 1);
442
+ }
443
+ else
444
+ bucket = "opt_dup";
445
+ }
446
+ }
447
+ else if (it.kind === "read") {
448
+ if (isDupReadAt(it, t)) {
449
+ bucket = "opt_dup";
450
+ dupCount += 1;
451
+ }
452
+ else if (editedThisEpisodeByT(it.file, t))
453
+ bucket = "keep_prod"; // connects to episode edits
454
+ else if (it.turn === t) {
455
+ bucket = "keep_prod";
456
+ un.stReads += tok;
457
+ }
458
+ else if (tierOfCausal(it.file, t) === 3) {
459
+ bucket = "opt_dead";
460
+ isDeadRead = true;
461
+ un.deadReads += tok;
462
+ }
463
+ else {
464
+ bucket = "keep_prod";
465
+ if (!cReadsExempt)
466
+ un.cReads += tok;
467
+ }
468
+ }
469
+ }
470
+ if (bucket) {
471
+ b[bucket] += tok;
472
+ if (isDeadRead)
473
+ deadReadTokens += tok;
474
+ if (isDeadImage)
475
+ deadImageTokens += tok;
476
+ }
477
+ }
478
+ const residual = C_t - BANDS.reduce((a, x) => a + b[x], 0);
479
+ b.opt_refind += residual; // reconcile serialization not represented by a concrete item
480
+ for (const x of BANDS)
481
+ agg[x] += b[x];
482
+ const opt = b.opt_dup + b.opt_refind + b.opt_dead;
483
+ const row = {
484
+ turn: t,
485
+ episode: episodeOf(t),
486
+ C_t,
487
+ ...b,
488
+ deadReadTokens,
489
+ deadImageTokens,
490
+ dupCount,
491
+ optimizablePct: C_t > 0 ? +(100 * opt / C_t).toFixed(1) : 0,
492
+ };
493
+ if (!isOutcome)
494
+ row.uncertain = un;
495
+ series.push(row);
496
+ }
497
+ const billTot = series.reduce((a, r) => a + r.C_t, 0) || 1;
498
+ const optTot = agg.opt_dup + agg.opt_refind + agg.opt_dead;
499
+ return {
500
+ turns: userTurns.length,
501
+ anchorSource: anchors.source,
502
+ anchorTurns: commits,
503
+ series,
504
+ billTot,
505
+ optimizablePct: +(100 * optTot / billTot).toFixed(1),
506
+ };
507
+ }