@echomem/mcp 1.4.9 → 1.4.10

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 (48) 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-canonical-adapter.js +315 -0
  5. package/dist/context-analysis/claude-native-canonical.js +35 -12
  6. package/dist/context-metrics/calculate.js +2 -15
  7. package/dist/context-metrics/estimator.js +45 -0
  8. package/dist/context-metrics/ledger.js +507 -0
  9. package/dist/context-metrics/parse-claude.js +227 -0
  10. package/dist/context-metrics/parse-codex.js +276 -0
  11. package/dist/hud/adapters.js +69 -198
  12. package/dist/hud/cli.js +0 -0
  13. package/dist/hud/efficiency.js +447 -0
  14. package/dist/hud/electron-main.js +3 -2
  15. package/dist/hud/fs.js +14 -0
  16. package/dist/hud/metric.js +4 -98
  17. package/dist/hud/monitor.js +1 -12
  18. package/dist/hud/render.js +4 -3
  19. package/dist/hud/server.js +30 -0
  20. package/dist/hud/web.js +409 -186
  21. package/dist/index.js +0 -0
  22. package/dist/setup-page/client-core.js +475 -0
  23. package/dist/setup-page/client-extraction.js +550 -0
  24. package/dist/setup-page/client-lifecycle.js +116 -0
  25. package/dist/setup-page/client-report-audit.js +818 -0
  26. package/dist/setup-page/client-report-city.js +204 -0
  27. package/dist/setup-page/client-report.js +6 -0
  28. package/dist/setup-page/client.js +15 -0
  29. package/dist/setup-page/document.js +37 -0
  30. package/dist/setup-page/styles-city-report.js +880 -0
  31. package/dist/setup-page/styles-context-audit.js +470 -0
  32. package/dist/setup-page/styles-extraction.js +821 -0
  33. package/dist/setup-page/styles-foundation.js +231 -0
  34. package/dist/setup-page/styles.js +11 -0
  35. package/dist/setup-page.js +6 -5623
  36. package/dist/setup.js +24 -10
  37. package/package.json +4 -4
  38. package/dist/city/10-problems-report.html +0 -649
  39. package/dist/city/_live.html +0 -37
  40. package/dist/city/_serve.mjs +0 -45
  41. package/dist/city/card-data.json +0 -15
  42. package/dist/city/chaos-to-clarity-pencil.html +0 -582
  43. package/dist/city/city-data.json +0 -248
  44. package/dist/city/echo-ai-city-only.template.html +0 -2271
  45. package/dist/city/generate-echo-city-only.mjs +0 -112
  46. package/dist/city/pencil-pie-generator.html +0 -883
  47. package/dist/city/pencil-webgl-landscape.html +0 -1239
  48. package/dist/city/spatial-fan-story.html +0 -479
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#24292f" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
@@ -0,0 +1,9 @@
1
+ # Runtime City Assets
2
+
3
+ Only these files are part of setup runtime:
4
+
5
+ - `echo-ai-city-only.html` - pre-auth Agent Doctor City.
6
+ - `echo-extraction-plate.html` - post-auth extraction progress plate.
7
+ - `vendor/`, `personality_stickers/`, and `echo-face-cutout.png` - assets loaded by those pages.
8
+
9
+ Everything under `archive/` is historical reference. It is excluded from the published package and rejected by the localhost City asset server.
@@ -1,4 +1,9 @@
1
1
  <!DOCTYPE html>
2
+ <!--
3
+ Runtime City source. The setup parent loads this file into an iframe and sends each local scan
4
+ report with postMessage. Do not generate user-specific HTML files: this renderer updates in place.
5
+ The retired static share-card template pipeline lives in artifacts/archive/.
6
+ -->
2
7
  <html lang="en">
3
8
  <head>
4
9
  <meta charset="UTF-8" />
@@ -1085,6 +1090,7 @@ async function loadSetupReport(){
1085
1090
  typeof envelope.scanId!=='string' || !envelope.scanId.trim()){
1086
1091
  return {kind:'invalid', code:'REPORT_ENVELOPE_INVALID'};
1087
1092
  }
1093
+ if(envelope.kind==='scanning') return {kind:'scanning', progress:envelope.progress||{}};
1088
1094
  if(envelope.kind==='empty') return {kind:'empty'};
1089
1095
  if(envelope.kind==='failed'){
1090
1096
  const error=envelope.error && typeof envelope.error==='object' ? envelope.error : {};
@@ -1102,105 +1108,60 @@ async function loadSetupReport(){
1102
1108
  setLoadState('failed', 'Your local report could not be shown', 'REPORT_NONCE_MISSING: Rerun Echo setup to open a verified local scan.');
1103
1109
  return new Promise(()=>{});
1104
1110
  }
1105
- let cleanupParentListener=()=>{};
1106
- let readyTimer=null;
1107
- const parentEnvelope=new Promise((resolve)=>{
1108
- const postReady=()=>{ try{ window.parent?.postMessage({type:'echomem:city-ready', nonce}, location.origin); }catch(_){} };
1111
+ const renderScanProgress=(progress)=>{
1112
+ const scanned=Number(progress?.scanned||0);
1113
+ const total=Number(progress?.total||0);
1114
+ const elapsed=Number(progress?.stageElapsedMs||0)>0 ? ' · '+Math.round(Number(progress.stageElapsedMs)/1000)+'s' : '';
1115
+ if(total>0){
1116
+ const detail=progress?.stage==='classifying-repeated-context'
1117
+ ? (progress.label || 'classifying repeated context')+' '+scanned.toLocaleString()+' / '+total.toLocaleString()+elapsed
1118
+ : (progress?.stage && progress.stage!=='reading-transcripts'
1119
+ ? (progress.label || 'building report')+elapsed
1120
+ : 'read '+scanned.toLocaleString()+' / '+total.toLocaleString()+' transcript files');
1121
+ setLoadState('running', 'Reading your local history', detail);
1122
+ }
1123
+ };
1124
+ // The parent owns /report polling and validation. The city only renders the
1125
+ // parent-provided state, keeping loading, failure, and ready state in one place.
1126
+ return await new Promise((resolve)=>{
1127
+ let closed=false;
1128
+ const close=()=>{
1129
+ if(closed) return;
1130
+ closed=true;
1131
+ window.removeEventListener('message', onMessage);
1132
+ };
1133
+ const stop=(kind, code, message)=>{
1134
+ close();
1135
+ if(kind==='empty'){
1136
+ setLoadState('empty', 'No local workspace data found', 'No demo data was substituted.');
1137
+ }else{
1138
+ setLoadState('failed', 'Your local report could not be shown', String(code||'REPORT_BUILD_FAILED')+': '+(message||'Rerun Echo setup to try again.'));
1139
+ }
1140
+ // Keep the city on its terminal loading/error surface instead of drawing a city without a report.
1141
+ };
1109
1142
  const onMessage=(event)=>{
1110
1143
  if(event.origin!==location.origin) return;
1111
1144
  const data=event.data||{};
1112
- if(data.type==='echomem:setup-report' && data.nonce===nonce && Object.prototype.hasOwnProperty.call(data,'envelope')){
1113
- resolve({source:'parent', envelope:data.envelope});
1114
- }
1115
- };
1116
- cleanupParentListener=()=>{ window.removeEventListener('message', onMessage); if(readyTimer) window.clearInterval(readyTimer); };
1117
- window.addEventListener('message', onMessage);
1118
- postReady();
1119
- readyTimer=window.setInterval(postReady, 500);
1120
- });
1121
- const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1122
- const fetchOnce=async()=>{
1123
- // A design-preview iframe may consume only its explicit parent envelope. It must
1124
- // never race the production /report endpoint and accidentally display local data.
1125
- if(previewMode) return null;
1126
- const controller=typeof AbortController!=='undefined' ? new AbortController() : null;
1127
- const timer=controller ? window.setTimeout(()=>controller.abort(), 3500) : null;
1128
- try{
1129
- const res=await fetch(`/report?nonce=${encodeURIComponent(nonce)}`, {credentials:'omit', cache:'no-store', signal:controller?.signal});
1130
- if(res.status===200){
1131
- try{ return {source:'server', envelope:await res.json()}; }
1132
- catch(_){ return {source:'failure', code:'REPORT_RESPONSE_INVALID', message:'The local scanner returned an unreadable report.'}; }
1145
+ if(data.type!=='echomem:setup-report' || data.nonce!==nonce || !Object.prototype.hasOwnProperty.call(data,'envelope')) return;
1146
+ const classified=classifyEnvelope(data.envelope);
1147
+ if(classified.kind==='scanning'){
1148
+ renderScanProgress(classified.progress);
1149
+ return;
1133
1150
  }
1134
- if(res.status!==202){
1135
- let payload=null;
1136
- try{ payload=await res.json(); }catch(_){}
1137
- if(payload && payload.kind==='failed') return {source:'server', envelope:payload};
1138
- const error=payload?.error && typeof payload.error==='object' ? payload.error : {};
1139
- return {
1140
- source:'failure',
1141
- code:String(error.code||`REPORT_HTTP_${res.status}`),
1142
- message:String(error.message||'The local scanner could not provide this report.'),
1143
- };
1144
- }
1145
- // 202 = still scanning. This is a file-processing counter; final session totals are
1146
- // deduplicated separately once the report has finished parsing.
1147
- try{
1148
- const p=await res.json();
1149
- if(p && p.kind==='failed') return {source:'server', envelope:p};
1150
- const progress=p?.progress || p;
1151
- if(countEl && progress && Number(progress.total)>0){
1152
- const scanned=Number(progress.scanned||0);
1153
- const total=Number(progress.total||0);
1154
- const elapsed=Number(progress.stageElapsedMs||0)>0 ? ` · ${Math.round(Number(progress.stageElapsedMs)/1000)}s` : '';
1155
- if(progress.stage && progress.stage!=='reading-transcripts'){
1156
- countEl.textContent=progress.stage==='classifying-repeated-context'
1157
- ? `${progress.label || 'classifying repeated context'} ${scanned.toLocaleString()} / ${total.toLocaleString()}${elapsed}`
1158
- : `${progress.label || 'building report'}${elapsed}`;
1159
- }else{
1160
- countEl.textContent=`read ${scanned.toLocaleString()} / ${total.toLocaleString()} transcript files`;
1161
- }
1162
- }
1163
- }catch(_){ return {source:'failure', code:'REPORT_PROGRESS_INVALID', message:'The local scanner returned an unreadable progress update.'}; }
1164
- return null;
1165
- }catch(error){
1166
- if(error?.name!=='AbortError') setLoadState('running', 'Reading your local history', 'Local scanner connection interrupted · retrying');
1167
- return null;
1168
- }finally{
1169
- if(timer) window.clearTimeout(timer);
1170
- }
1171
- };
1172
- const stopClosed=(kind, code, message)=>{
1173
- cleanupParentListener();
1174
- if(kind==='empty'){
1175
- setLoadState('empty', 'No local workspace data found', 'No demo data was substituted.');
1176
- }else{
1177
- setLoadState('failed', 'Your local report could not be shown', `${code}: ${message||'Rerun Echo setup to try again.'}`);
1178
- }
1179
- return new Promise(()=>{});
1180
- };
1181
- while(true){
1182
- const result=await Promise.race([parentEnvelope, fetchOnce()]);
1183
- if(result){
1184
- if(result.source==='failure') return stopClosed('failed', result.code, result.message);
1185
- const classified=classifyEnvelope(result.envelope);
1186
1151
  if(classified.kind==='ready'){
1187
- cleanupParentListener();
1188
- return result.envelope;
1152
+ close();
1153
+ resolve(data.envelope);
1154
+ return;
1189
1155
  }
1190
- if(classified.kind==='empty') return stopClosed('empty');
1191
- return stopClosed('failed', classified.code, classified.message || 'The report failed provenance or structure validation.');
1192
- }
1193
- const duringSleep=await Promise.race([parentEnvelope, sleep(500).then(()=>null)]);
1194
- if(duringSleep){
1195
- const classified=classifyEnvelope(duringSleep.envelope);
1196
- if(classified.kind==='ready'){
1197
- cleanupParentListener();
1198
- return duringSleep.envelope;
1156
+ if(classified.kind==='empty'){
1157
+ stop('empty');
1158
+ return;
1199
1159
  }
1200
- if(classified.kind==='empty') return stopClosed('empty');
1201
- return stopClosed('failed', classified.code, classified.message || 'The report failed provenance or structure validation.');
1202
- }
1203
- }
1160
+ stop('failed', classified.code, classified.message || 'The report failed provenance or structure validation.');
1161
+ };
1162
+ window.addEventListener('message', onMessage);
1163
+ try{ window.parent?.postMessage({type:'echomem:city-ready', nonce}, location.origin); }catch(_){}
1164
+ });
1204
1165
  }
1205
1166
  let signpostTokens=setupMode?'—':'7.49B';
1206
1167
  let signpostHours=setupMode?'—':'191.1h';
@@ -1339,7 +1300,7 @@ const POSTER_REPOS=[
1339
1300
  {name:'Daimler Engineering', x:-168,z:160, tool:'codex', tokens:4439902, cost:15, days:0, hours:0.2, sessions:2},
1340
1301
  ];
1341
1302
  let REPOS=setupMode ? [] : POSTER_REPOS;
1342
- // per-repo codex/claude split (frozen poster values from city-data.json) → drives the stacked two-colour buildings
1303
+ // Per-repo Codex/Claude split for the retired standalone poster fallback.
1343
1304
  const REPO_SPLIT={'WebPageReactVersion':[31,69],'EchoMem-Chrome':[19,81],'echo-design-system':[44,56],'sleepy-merkle-3d0470':[0,100],'DG-Web':[0,100],'Kobe Life':[100,0],'public':[0,100],'EchoMemory_log':[63,37],'production-prompt':[0,100],'brands':[0,100],'components':[0,100],'dist':[0,100],'Daimler Engineering':[100,0]};
1344
1305
  for(const o of REPOS){ const s=REPO_SPLIT[o.name]; if(s){ o.codexPct=s[0]; o.claudePct=s[1]; } else { o.codexPct=o.tool==='codex'?100:0; o.claudePct=100-o.codexPct; } }
1345
1306
  if(setupMode){
@@ -0,0 +1,315 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Convert a Claude Code transcript into the event vocabulary consumed by the
5
+ * vendored canonical scorer. The scorer itself remains provider-independent:
6
+ * this adapter is the only place where Claude's JSONL shape is interpreted.
7
+ */
8
+ export function normalizeClaudeSessionForCanonical(file) {
9
+ const rows = readRows(file);
10
+ const sessionId = firstString(rows, "sessionId") || path.basename(file, ".jsonl");
11
+ const cwd = firstString(rows, "cwd");
12
+ const dynamicTools = collectToolNames(rows).map((name) => ({ name }));
13
+ const inferredOverheadTokens = inferClaudeOverheadTokens(rows);
14
+ const toolTokens = Math.round(JSON.stringify(dynamicTools).length / 3.3);
15
+ const instructionTokens = Math.max(0, inferredOverheadTokens - toolTokens);
16
+ const records = [codexRecord("session_meta", {
17
+ type: "session_meta",
18
+ id: sessionId,
19
+ cwd,
20
+ base_instructions: { text: "x".repeat(instructionTokens * 4) },
21
+ dynamic_tools: dynamicTools,
22
+ }, firstTimestamp(rows))];
23
+ const calls = new Map();
24
+ let hasUserTurn = false;
25
+ for (const row of rows) {
26
+ const timestamp = stringValue(row.timestamp) || firstTimestamp(rows);
27
+ const type = stringValue(row.type);
28
+ if (type === "user") {
29
+ const message = recordValue(row.message);
30
+ const content = message.content;
31
+ if (isToolResultContent(content)) {
32
+ if (!hasUserTurn)
33
+ continue;
34
+ for (const block of arrayValue(content)) {
35
+ if (!isRecord(block) || block.type !== "tool_result")
36
+ continue;
37
+ const callId = stringValue(block.tool_use_id);
38
+ const call = calls.get(callId);
39
+ if (!call || call.mode === "write")
40
+ continue;
41
+ records.push(codexRecord("response_item", {
42
+ type: "function_call_output",
43
+ call_id: callId,
44
+ output: normalizeToolResult(block.content),
45
+ }, timestamp));
46
+ }
47
+ continue;
48
+ }
49
+ const messageText = extractText(content);
50
+ const imageCount = countImages(content);
51
+ records.push(codexRecord("event_msg", {
52
+ type: "user_message",
53
+ message: messageText,
54
+ ...(imageCount > 0 ? { images: Array.from({ length: imageCount }, () => ({ type: "input_image" })) } : {}),
55
+ }, timestamp));
56
+ hasUserTurn = true;
57
+ continue;
58
+ }
59
+ if (type === "system" && hasUserTurn && (row.subtype === "compact_boundary" || row.subtype === "context_compacted")) {
60
+ records.push(codexRecord("event_msg", { type: "context_compacted" }, timestamp));
61
+ continue;
62
+ }
63
+ if (type !== "assistant" || !hasUserTurn)
64
+ continue;
65
+ const message = recordValue(row.message);
66
+ const usage = recordValue(message.usage);
67
+ const inputTokens = claudeOfficialInputTokens(usage);
68
+ if (inputTokens > 0) {
69
+ records.push(codexRecord("event_msg", {
70
+ type: "token_count",
71
+ info: {
72
+ last_token_usage: {
73
+ input_tokens: inputTokens,
74
+ cached_input_tokens: numberValue(usage.cache_read_input_tokens),
75
+ output_tokens: numberValue(usage.output_tokens),
76
+ },
77
+ },
78
+ }, timestamp));
79
+ }
80
+ for (const block of arrayValue(message.content)) {
81
+ if (!isRecord(block))
82
+ continue;
83
+ if (block.type === "thinking") {
84
+ records.push(codexRecord("response_item", { type: "reasoning" }, timestamp));
85
+ continue;
86
+ }
87
+ if (block.type === "text") {
88
+ const text = stringValue(block.text);
89
+ if (text)
90
+ records.push(codexRecord("event_msg", { type: "agent_message", message: text }, timestamp));
91
+ continue;
92
+ }
93
+ if (block.type !== "tool_use")
94
+ continue;
95
+ const normalized = normalizeToolUse(block);
96
+ if (!normalized)
97
+ continue;
98
+ calls.set(normalized.call.callId, normalized.call);
99
+ records.push(...normalized.records.map((record) => ({ ...record, timestamp })));
100
+ }
101
+ }
102
+ return {
103
+ jsonl: `${records.map((record) => JSON.stringify(record)).join("\n")}\n`,
104
+ sessionId,
105
+ cwd,
106
+ inferredOverheadTokens,
107
+ };
108
+ }
109
+ function normalizeToolUse(block) {
110
+ const name = stringValue(block.name);
111
+ const callId = stringValue(block.id);
112
+ if (!name || !callId)
113
+ return null;
114
+ const input = recordValue(block.input);
115
+ if (/^(Write|Edit|MultiEdit|NotebookEdit)$/i.test(name)) {
116
+ const file = stringValue(input.file_path) || stringValue(input.notebook_path) || stringValue(input.path) || "unknown";
117
+ const patch = syntheticPatch(name, file, input);
118
+ return {
119
+ call: { mode: "write", callId },
120
+ records: [
121
+ codexRecord("response_item", { type: "custom_tool_call", name: "apply_patch", call_id: callId, input: patch }, ""),
122
+ codexRecord("event_msg", { type: "patch_apply_end", changes: { [file]: { kind: /^Write$/i.test(name) ? "added" : "modified" } } }, ""),
123
+ ],
124
+ };
125
+ }
126
+ const command = commandForClaudeTool(name, input);
127
+ if (command) {
128
+ return {
129
+ call: { mode: "function", callId },
130
+ records: [codexRecord("response_item", {
131
+ type: "function_call",
132
+ name: "exec_command",
133
+ call_id: callId,
134
+ arguments: JSON.stringify({ cmd: command }),
135
+ }, "")],
136
+ };
137
+ }
138
+ return {
139
+ call: { mode: "function", callId },
140
+ records: [codexRecord("response_item", {
141
+ type: "function_call",
142
+ name: visualToolName(name) ? "view_image" : name,
143
+ call_id: callId,
144
+ arguments: JSON.stringify(input),
145
+ }, "")],
146
+ };
147
+ }
148
+ function commandForClaudeTool(name, input) {
149
+ if (/^Bash$/i.test(name))
150
+ return stringValue(input.command) || "bash";
151
+ if (/^(Read|NotebookRead)$/i.test(name)) {
152
+ const file = stringValue(input.file_path) || stringValue(input.notebook_path) || stringValue(input.path) || "unknown";
153
+ const offset = Math.max(1, numberValue(input.offset) || 1);
154
+ const limit = Math.max(1, numberValue(input.limit) || 2000);
155
+ return `sed -n '${offset},${offset + limit - 1}p' ${quoteArg(file)}`;
156
+ }
157
+ if (/^Grep$/i.test(name)) {
158
+ const pattern = stringValue(input.pattern) || stringValue(input.query) || "unknown";
159
+ const target = stringValue(input.path) || ".";
160
+ return `rg -n ${quoteArg(pattern)} ${quoteArg(target)}`;
161
+ }
162
+ if (/^Glob$/i.test(name))
163
+ return `rg --files ${quoteArg(stringValue(input.path) || ".")}`;
164
+ if (/^(LS|ListFiles)$/i.test(name))
165
+ return `ls ${quoteArg(stringValue(input.path) || ".")}`;
166
+ if (/^(WebSearch|WebFetch)$/i.test(name)) {
167
+ const query = stringValue(input.query) || stringValue(input.url) || "web";
168
+ return `rg ${quoteArg(`web:${query}`)} web`;
169
+ }
170
+ return null;
171
+ }
172
+ function syntheticPatch(name, file, input) {
173
+ const chunks = [];
174
+ const content = stringValue(input.content) || stringValue(input.new_string) || stringValue(input.new_source);
175
+ if (content)
176
+ chunks.push(content);
177
+ for (const edit of arrayValue(input.edits)) {
178
+ if (!isRecord(edit))
179
+ continue;
180
+ const value = stringValue(edit.new_string) || stringValue(edit.new_source) || stringValue(edit.content);
181
+ if (value)
182
+ chunks.push(value);
183
+ }
184
+ const body = chunks.join("\n") || `${name} ${file}`;
185
+ return `*** Begin Patch\n*** ${/^Write$/i.test(name) ? "Add" : "Update"} File: ${file}\n@@\n${body}\n*** End Patch\n`;
186
+ }
187
+ function inferClaudeOverheadTokens(rows) {
188
+ let firstUserTokens = 0;
189
+ let firstUserImages = 0;
190
+ let awaitingUsage = false;
191
+ for (const row of rows) {
192
+ const type = stringValue(row.type);
193
+ if (type === "user" && !isToolResultContent(recordValue(row.message).content)) {
194
+ if (!awaitingUsage) {
195
+ const content = recordValue(row.message).content;
196
+ firstUserTokens = Math.round(extractText(content).length / 4);
197
+ firstUserImages = countImages(content);
198
+ awaitingUsage = true;
199
+ }
200
+ continue;
201
+ }
202
+ if (type !== "assistant" || !awaitingUsage)
203
+ continue;
204
+ const total = claudeOfficialInputTokens(recordValue(recordValue(row.message).usage));
205
+ if (total <= 0)
206
+ continue;
207
+ const structureFloor = Math.round(total * 0.03);
208
+ return Math.max(0, total - firstUserTokens - firstUserImages * 4000 - structureFloor);
209
+ }
210
+ return 0;
211
+ }
212
+ function claudeOfficialInputTokens(usage) {
213
+ return numberValue(usage.input_tokens)
214
+ + numberValue(usage.cache_read_input_tokens)
215
+ + numberValue(usage.cache_creation_input_tokens);
216
+ }
217
+ function normalizeToolResult(value) {
218
+ if (typeof value === "string")
219
+ return value;
220
+ const output = [];
221
+ for (const block of arrayValue(value)) {
222
+ if (!isRecord(block))
223
+ continue;
224
+ if (block.type === "text" && typeof block.text === "string")
225
+ output.push(block.text);
226
+ else if (block.type === "image")
227
+ output.push({ type: "input_image" });
228
+ else
229
+ output.push(block);
230
+ }
231
+ return output.length === 1 ? output[0] : output;
232
+ }
233
+ function extractText(value) {
234
+ if (typeof value === "string")
235
+ return value;
236
+ return arrayValue(value).map((block) => {
237
+ if (!isRecord(block))
238
+ return "";
239
+ if (typeof block.text === "string")
240
+ return block.text;
241
+ return "";
242
+ }).filter(Boolean).join("\n");
243
+ }
244
+ function countImages(value) {
245
+ if (!value)
246
+ return 0;
247
+ if (Array.isArray(value))
248
+ return value.reduce((sum, item) => sum + countImages(item), 0);
249
+ if (!isRecord(value))
250
+ return 0;
251
+ const own = value.type === "image" || value.type === "input_image" ? 1 : 0;
252
+ return own + Object.values(value).reduce((sum, item) => sum + countImages(item), 0);
253
+ }
254
+ function collectToolNames(rows) {
255
+ const names = new Set();
256
+ for (const row of rows) {
257
+ for (const block of arrayValue(recordValue(row.message).content)) {
258
+ if (isRecord(block) && block.type === "tool_use" && typeof block.name === "string")
259
+ names.add(block.name);
260
+ }
261
+ }
262
+ return [...names].sort();
263
+ }
264
+ function readRows(file) {
265
+ const rows = [];
266
+ for (const line of fs.readFileSync(file, "utf8").split(/\n/)) {
267
+ if (!line.trim())
268
+ continue;
269
+ try {
270
+ const value = JSON.parse(line);
271
+ if (isRecord(value))
272
+ rows.push(value);
273
+ }
274
+ catch {
275
+ // Claude may leave a partial final line while a session is active.
276
+ }
277
+ }
278
+ return rows;
279
+ }
280
+ function firstString(rows, key) {
281
+ for (const row of rows)
282
+ if (typeof row[key] === "string" && row[key])
283
+ return row[key];
284
+ return null;
285
+ }
286
+ function firstTimestamp(rows) {
287
+ return firstString(rows, "timestamp") || new Date(0).toISOString();
288
+ }
289
+ function codexRecord(type, payload, timestamp) {
290
+ return { timestamp, type, payload };
291
+ }
292
+ function isToolResultContent(value) {
293
+ return arrayValue(value).some((block) => isRecord(block) && block.type === "tool_result");
294
+ }
295
+ function visualToolName(name) {
296
+ return /screenshot|image|browser|playwright/i.test(name);
297
+ }
298
+ function quoteArg(value) {
299
+ return `"${value.replace(/["\\]/g, "_")}"`;
300
+ }
301
+ function isRecord(value) {
302
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
303
+ }
304
+ function recordValue(value) {
305
+ return isRecord(value) ? value : {};
306
+ }
307
+ function arrayValue(value) {
308
+ return Array.isArray(value) ? value : [];
309
+ }
310
+ function stringValue(value) {
311
+ return typeof value === "string" ? value : "";
312
+ }
313
+ function numberValue(value) {
314
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
315
+ }
@@ -61,7 +61,7 @@ function parseClaudeNativeSession(file) {
61
61
  const requests = new Map();
62
62
  const pendingTools = new Map();
63
63
  for (const row of rows) {
64
- if (typeof row.sessionId === "string")
64
+ if (!session && typeof row.sessionId === "string")
65
65
  session = row.sessionId;
66
66
  if (!cwd && typeof row.cwd === "string")
67
67
  cwd = row.cwd;
@@ -832,6 +832,7 @@ function isVerificationCommand(command) {
832
832
  return /\b(?:test|build|tsc|lint|typecheck|check)\b/.test(normalizeCommand(command));
833
833
  }
834
834
  function aggregateClaudeNative(sessions) {
835
+ const uniqueSessionIds = new Set(sessions.map((session) => session.session));
835
836
  const summary = sessions.reduce((sum, session) => {
836
837
  sum.input += session.officialInputTokens;
837
838
  sum.useful += session.usefulTokens;
@@ -845,26 +846,43 @@ function aggregateClaudeNative(sessions) {
845
846
  const buckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
846
847
  const rawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
847
848
  const repos = new Map();
849
+ const sessionRows = new Map();
848
850
  const problemRows = new Map();
849
851
  for (const id of PROBLEM_IDS)
850
- problemRows.set(id, { tokens: 0, count: 0, pressure: 0, sessions: 0, examples: [] });
852
+ problemRows.set(id, { tokens: 0, count: 0, pressure: 0, sessionIds: new Set(), examples: [] });
851
853
  for (const session of sessions) {
852
854
  for (const key of Object.keys(buckets))
853
855
  buckets[key] += session.buckets[key] || 0;
854
856
  for (const key of Object.keys(rawBuckets))
855
857
  rawBuckets[key] += session.rawBuckets[key] || 0;
856
- const repo = repos.get(session.repo) || { sessions: 0, officialInputTokens: 0, wasteTokens: 0 };
857
- repo.sessions += 1;
858
+ const repo = repos.get(session.repo) || { sessionIds: new Set(), officialInputTokens: 0, wasteTokens: 0 };
859
+ repo.sessionIds.add(session.session);
858
860
  repo.officialInputTokens += session.officialInputTokens;
859
861
  repo.wasteTokens += session.wasteTokens;
860
862
  repos.set(session.repo, repo);
863
+ const sessionRow = sessionRows.get(session.session) || {
864
+ session: session.session,
865
+ repo: session.repo,
866
+ turns: 0,
867
+ officialInputTokens: 0,
868
+ wasteTokens: 0,
869
+ problems: new Map(),
870
+ };
871
+ sessionRow.turns += session.turns;
872
+ sessionRow.officialInputTokens += session.officialInputTokens;
873
+ sessionRow.wasteTokens += session.wasteTokens;
874
+ if (sessionRow.repo === "home" && session.repo !== "home")
875
+ sessionRow.repo = session.repo;
876
+ sessionRows.set(session.session, sessionRow);
861
877
  for (const [id, stats] of session.problems) {
862
878
  const row = problemRows.get(id);
863
879
  row.tokens += stats.tokens;
864
880
  row.count += stats.count;
865
881
  row.pressure += stats.pressure;
866
- if (stats.tokens > 0)
867
- row.sessions += 1;
882
+ if (stats.tokens > 0) {
883
+ row.sessionIds.add(session.session);
884
+ sessionRow.problems.set(id, (sessionRow.problems.get(id) || 0) + stats.tokens);
885
+ }
868
886
  row.examples.push(...stats.examples);
869
887
  row.examples.sort((left, right) => (right.tokens || 0) - (left.tokens || 0));
870
888
  row.examples.splice(1);
@@ -880,7 +898,7 @@ function aggregateClaudeNative(sessions) {
880
898
  category: meta.category,
881
899
  confidence: meta.confidence,
882
900
  count: row.count,
883
- sessions: row.sessions,
901
+ sessions: row.sessionIds.size,
884
902
  allocatedWasteTokens: Math.round(row.tokens),
885
903
  qualifiedTokenPressure: Math.round(row.pressure),
886
904
  severity: summary.waste ? (row.tokens / summary.waste) * 100 : 0,
@@ -897,7 +915,7 @@ function aggregateClaudeNative(sessions) {
897
915
  note: "Claude Code sessions are scored natively from JSONL requestIds and toolUseResult objects. For each human turn, the terminal unique provider request by request sequence is selected as the retained context-window proxy; episode-aware evidence determines raw useful/residue for each selected turn request. Raw residue is counted as waste, while P01-P13 explain the attributable portion and remaining unlabeled residue stays included in headline waste.",
898
916
  },
899
917
  summary: {
900
- sessionsAnalyzed: sessions.length,
918
+ sessionsAnalyzed: uniqueSessionIds.size,
901
919
  reposAnalyzed: repos.size,
902
920
  turnsAnalyzed: summary.turns,
903
921
  officialInputTokens: Math.round(summary.input),
@@ -914,18 +932,23 @@ function aggregateClaudeNative(sessions) {
914
932
  buckets: mapBucketValues(buckets),
915
933
  rawBuckets: mapBucketValues(rawBuckets),
916
934
  problems,
917
- sessions: sessions.map((session) => {
918
- const top = [...session.problems.entries()].sort((a, b) => b[1].tokens - a[1].tokens)[0];
935
+ sessions: [...sessionRows.values()].map((session) => {
936
+ const top = [...session.problems.entries()].sort((a, b) => b[1] - a[1])[0];
919
937
  return {
920
938
  session: session.session,
921
939
  repo: session.repo,
922
940
  turns: session.turns,
923
941
  officialInputTokens: Math.round(session.officialInputTokens),
924
942
  wasteTokens: Math.round(session.wasteTokens),
925
- topProblemId: top && top[1].tokens > 0 ? top[0] : null,
943
+ topProblemId: top && top[1] > 0 ? top[0] : null,
926
944
  };
927
945
  }).sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
928
- repos: [...repos.entries()].map(([repo, value]) => ({ repo, ...mapBucketValues(value) }))
946
+ repos: [...repos.entries()].map(([repo, value]) => ({
947
+ repo,
948
+ sessions: value.sessionIds.size,
949
+ officialInputTokens: Math.round(value.officialInputTokens),
950
+ wasteTokens: Math.round(value.wasteTokens),
951
+ }))
929
952
  .sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
930
953
  episodes: sessions.flatMap((session) => session.episodes).sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
931
954
  };
@@ -11,11 +11,7 @@ export function calculateContextMetrics(input) {
11
11
  const projectedNextFullnessPct = modelContextLimitTokens && newOutputThisTurnTokens !== undefined
12
12
  ? (latestInputTokens + newOutputThisTurnTokens) / modelContextLimitTokens
13
13
  : undefined;
14
- const healthScorePct = calculateHealthScorePct({
15
- noisePct,
16
- contextFullnessPct,
17
- projectedNextFullnessPct,
18
- });
14
+ const healthScorePct = calculateHealthScorePct({ noisePct });
19
15
  return {
20
16
  latestInputTokens,
21
17
  modelContextLimitTokens,
@@ -32,13 +28,7 @@ export function calculateContextMetrics(input) {
32
28
  };
33
29
  }
34
30
  function calculateHealthScorePct(input) {
35
- const pressurePct = Math.max(input.contextFullnessPct ?? 0, input.projectedNextFullnessPct ?? 0);
36
- // Noise is a direct quality loss. Fullness becomes a separate pressure penalty after 70% because
37
- // clean-but-nearly-full sessions still become slow, brittle, or compaction-prone.
38
- const fullnessPenaltyPct = pressurePct > 0.7
39
- ? clamp01((pressurePct - 0.7) / 0.3) * 35
40
- : 0;
41
- return Math.max(0, Math.min(100, Math.round(100 - input.noisePct * 100 - fullnessPenaltyPct)));
31
+ return Math.max(0, Math.min(100, Math.round(100 - input.noisePct * 100)));
42
32
  }
43
33
  function nonNegative(value) {
44
34
  return Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
@@ -51,6 +41,3 @@ function optionalPositive(value) {
51
41
  return null;
52
42
  return Math.round(value);
53
43
  }
54
- function clamp01(value) {
55
- return Math.max(0, Math.min(1, value));
56
- }