@echomem/mcp 1.4.39 → 1.4.41
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 +21 -0
- package/dist/hud/cli.js +25 -4
- package/dist/hud/hooks.js +58 -0
- package/dist/index.js +328 -244
- package/dist/package-metadata.js +2 -1
- package/dist/setup-page/client-extraction.js +9 -3
- package/dist/setup-page/styles-mvp.js +46 -0
- package/dist/setup.js +35 -16
- package/dist/source-session-hook.js +103 -0
- package/dist/source-session.js +261 -0
- package/dist/v1-contract.js +22 -0
- package/package.json +2 -2
- package/templates/echomem-recall.md +3 -0
- package/dist/forensics-10-problems.js +0 -633
|
@@ -1,633 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* forensics-10-problems.ts — Incremental turn-level dirty-context problem scanner.
|
|
3
|
-
*
|
|
4
|
-
* Complements forensics.ts (static aggregate scan) with TURN-LEVEL labeling of 10
|
|
5
|
-
* canonical dirty-context degradation patterns. For each session, walks turns in order,
|
|
6
|
-
* detects problems, attributes wasted tokens per problem, and aggregates workspace-wide.
|
|
7
|
-
*
|
|
8
|
-
* The 10 problems (from the dirty-context canonical spec):
|
|
9
|
-
* P01 Image flood — binary blobs compaction can't clear
|
|
10
|
-
* P02 Correction cascade — ≥2 consecutive rework turns
|
|
11
|
-
* P03 Shadow file versions — stale read coexists with newer edit in context
|
|
12
|
-
* P04 Post-compaction re-discovery — re-reading unchanged files after context drop
|
|
13
|
-
* P05 False completion — agent commits, user immediately reworks
|
|
14
|
-
* P06 Gap state disconnect — long idle gap → re-orientation reads
|
|
15
|
-
* P07 Abort leaves bad patch — edit followed by user revert/undo
|
|
16
|
-
* P08 Git as memory substitute — repeated identical git queries
|
|
17
|
-
* P09 User pulls back trajectory — ≥2 explicit redirects in session
|
|
18
|
-
* P10 Search replay — duplicate grep/find/search commands
|
|
19
|
-
*
|
|
20
|
-
* Detection tiers:
|
|
21
|
-
* Tier 1 (deterministic, high confidence): P01, P04, P08, P10
|
|
22
|
-
* Tier 2 (structural heuristic, medium): P03, P06, P07
|
|
23
|
-
* Tier 3 (keyword classification, lower): P02, P05, P09
|
|
24
|
-
*
|
|
25
|
-
* All data stays local. No LLM calls. Transcripts never leave the machine.
|
|
26
|
-
*/
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
import { eachLine, walk } from "./report.js";
|
|
29
|
-
import { discoverCodexSessionFiles } from "./codex-session-files.js";
|
|
30
|
-
import { resolveClaudeProjectsDir } from "./local-data-paths.js";
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Constants
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|ico|avif|bmp|mp4|mov)$/i;
|
|
35
|
-
const CHARS_PER_TOKEN = 4;
|
|
36
|
-
const GAP_MS = 30 * 60 * 1000; // 30 min gap = state disconnect
|
|
37
|
-
const GIT_MEMORY_SUBS = new Set(["log", "show", "diff", "blame", "grep"]);
|
|
38
|
-
const SEARCH_BINS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "fd"]);
|
|
39
|
-
const SHELL_READ_BINS = new Set(["cat", "head", "tail", "less", "more", "bat", "nl", "sed", "awk"]);
|
|
40
|
-
// Bilingual keyword classifiers — ported from turn-outcome.mjs with false-positive cleanup.
|
|
41
|
-
const REWORK_RE = /(?<!对)不对|并不是|不是这样|不是我[要想说]|还是[不没]|又(?:回到|错|坏)|错了|搞错|理解错|弄错|重做|重新[做写改]|没(?:有)?(?:生效|起作用|变化)|不应该|不是让你|revert|undo|roll\s*back|\bthat'?s not (?:right|what|it)\b|\bnot what i\b|\bstill (?:broken|wrong|failing|not working|the same)\b|\byou (?:should have|were supposed)\b|\bredo\b/i;
|
|
42
|
-
const REDIRECT_RE = /我说的是|我(?:之前|刚才)说|focus on|回到|你跑偏|不是要你|你在(?:做|干)什么|为什么在|let'?s get back|back to (?:the|what)|you'?re going off/i;
|
|
43
|
-
const ABORT_RE = /revert|undo|roll\s*back|恢复|撤[回销]|回退|还原/i;
|
|
44
|
-
const COMMIT_RE = /\bgit\s+(?:commit|push)\b/;
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Helpers
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
function tokensFromChars(chars) {
|
|
49
|
-
return Math.round(chars / CHARS_PER_TOKEN);
|
|
50
|
-
}
|
|
51
|
-
function isRealUserText(text) {
|
|
52
|
-
if (typeof text !== "string")
|
|
53
|
-
return false;
|
|
54
|
-
const t = text.trim();
|
|
55
|
-
if (!t || t.startsWith("<") || t.startsWith("Caveat:") || t.startsWith("[Request interrupted"))
|
|
56
|
-
return false;
|
|
57
|
-
if (t.includes("<command-name>") || t.includes("<local-command"))
|
|
58
|
-
return false;
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
61
|
-
function cleanUserText(text) {
|
|
62
|
-
return text
|
|
63
|
-
.replace(/#\s*Files? mentioned by the user:[\s\S]*$/i, "")
|
|
64
|
-
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
65
|
-
.trim();
|
|
66
|
-
}
|
|
67
|
-
function classifyVerdict(text) {
|
|
68
|
-
const s = cleanUserText(text);
|
|
69
|
-
if (!s)
|
|
70
|
-
return "neutral";
|
|
71
|
-
if (REWORK_RE.test(s))
|
|
72
|
-
return "rework";
|
|
73
|
-
return "neutral";
|
|
74
|
-
}
|
|
75
|
-
function contentLength(content) {
|
|
76
|
-
if (content == null)
|
|
77
|
-
return 0;
|
|
78
|
-
if (typeof content === "string")
|
|
79
|
-
return content.length;
|
|
80
|
-
try {
|
|
81
|
-
return JSON.stringify(content).length;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return 0;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// Command normalization for dedup detection
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
function gitReadKey(cmd) {
|
|
91
|
-
const m = cmd.match(/\bgit\s+(log|show|diff|blame|grep)\b/);
|
|
92
|
-
if (!m)
|
|
93
|
-
return null;
|
|
94
|
-
return cmd.trim().replace(/\s+/g, " ");
|
|
95
|
-
}
|
|
96
|
-
function searchKey(cmd) {
|
|
97
|
-
const first = cmd.trim().split(/[|;&\n]/)[0].trim();
|
|
98
|
-
const bin = (first.split(/\s+/)[0] || "").split("/").pop() || "";
|
|
99
|
-
if (!SEARCH_BINS.has(bin))
|
|
100
|
-
return null;
|
|
101
|
-
return first.replace(/\s+/g, " ");
|
|
102
|
-
}
|
|
103
|
-
function extractReadFile(cmd) {
|
|
104
|
-
const tokens = cmd.trim().split(/\s+/);
|
|
105
|
-
const bin = (tokens[0] || "").split("/").pop() || "";
|
|
106
|
-
if (!SHELL_READ_BINS.has(bin))
|
|
107
|
-
return null;
|
|
108
|
-
for (let i = tokens.length - 1; i >= 1; i--) {
|
|
109
|
-
const t = tokens[i].replace(/['"]/g, "");
|
|
110
|
-
if (!t || t.startsWith("-"))
|
|
111
|
-
continue;
|
|
112
|
-
if (t.includes("/") || /\.\w{1,6}$/.test(t))
|
|
113
|
-
return t;
|
|
114
|
-
}
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
const PROBLEM_META = [
|
|
118
|
-
{ id: "P01", name: "Image flood", tier: 1, confidence: "high" },
|
|
119
|
-
{ id: "P02", name: "Correction cascade", tier: 3, confidence: "low" },
|
|
120
|
-
{ id: "P03", name: "Shadow file versions", tier: 2, confidence: "medium" },
|
|
121
|
-
{ id: "P04", name: "Post-compaction re-discovery", tier: 1, confidence: "high" },
|
|
122
|
-
{ id: "P05", name: "False completion", tier: 3, confidence: "low" },
|
|
123
|
-
{ id: "P06", name: "Gap state disconnect", tier: 2, confidence: "medium" },
|
|
124
|
-
{ id: "P07", name: "Abort leaves bad patch", tier: 2, confidence: "medium" },
|
|
125
|
-
{ id: "P08", name: "Git as memory substitute", tier: 1, confidence: "high" },
|
|
126
|
-
{ id: "P09", name: "User pulls back trajectory", tier: 3, confidence: "low" },
|
|
127
|
-
{ id: "P10", name: "Search replay", tier: 1, confidence: "high" },
|
|
128
|
-
];
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
// Turn parsers — translate each log schema into a common Turn[]
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
function parseCodexTurns(file) {
|
|
133
|
-
const turns = [];
|
|
134
|
-
let cur = null;
|
|
135
|
-
const pending = new Map();
|
|
136
|
-
const pushTurn = (text, ts) => {
|
|
137
|
-
cur = {
|
|
138
|
-
index: turns.length, ts, userText: text, inputTokens: 0, outputTokens: 0,
|
|
139
|
-
tools: [], editedFiles: [], committed: false, compacted: false,
|
|
140
|
-
};
|
|
141
|
-
turns.push(cur);
|
|
142
|
-
};
|
|
143
|
-
eachLine(file, (o) => {
|
|
144
|
-
const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
|
|
145
|
-
const tsMs = Number.isFinite(ts) ? ts : null;
|
|
146
|
-
const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
|
|
147
|
-
if (!p || typeof p !== "object")
|
|
148
|
-
return;
|
|
149
|
-
if (p.type === "context_compacted") {
|
|
150
|
-
if (cur)
|
|
151
|
-
cur.compacted = true;
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
if (p.type === "user_message") {
|
|
155
|
-
pushTurn(String(p.message ?? p.content ?? p.text ?? ""), tsMs);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (!cur)
|
|
159
|
-
return;
|
|
160
|
-
if (p.type === "token_count" && p.info?.last_token_usage) {
|
|
161
|
-
const u = p.info.last_token_usage;
|
|
162
|
-
if (u.input_tokens)
|
|
163
|
-
cur.inputTokens = Math.max(cur.inputTokens, u.input_tokens);
|
|
164
|
-
cur.outputTokens += u.output_tokens || 0;
|
|
165
|
-
}
|
|
166
|
-
if (p.type === "patch_apply_end" && p.changes) {
|
|
167
|
-
for (const target of Object.keys(p.changes))
|
|
168
|
-
cur.editedFiles.push(path.basename(target));
|
|
169
|
-
}
|
|
170
|
-
if (p.type === "function_call") {
|
|
171
|
-
const name = String(p.name || "");
|
|
172
|
-
let args = p.arguments;
|
|
173
|
-
if (typeof args === "string") {
|
|
174
|
-
try {
|
|
175
|
-
args = JSON.parse(args);
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
args = {};
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
const cmd = typeof (args?.cmd ?? args?.command) === "string" ? (args.cmd || args.command) : null;
|
|
182
|
-
const tc = {
|
|
183
|
-
name, file: null, command: cmd, id: p.call_id || null,
|
|
184
|
-
outputChars: 0, isImage: false, gitKey: null, searchKey: null,
|
|
185
|
-
};
|
|
186
|
-
if (cmd) {
|
|
187
|
-
if (COMMIT_RE.test(cmd))
|
|
188
|
-
cur.committed = true;
|
|
189
|
-
tc.gitKey = gitReadKey(cmd);
|
|
190
|
-
tc.searchKey = searchKey(cmd);
|
|
191
|
-
const readFile = extractReadFile(cmd);
|
|
192
|
-
if (readFile) {
|
|
193
|
-
tc.file = path.basename(readFile);
|
|
194
|
-
tc.isImage = IMAGE_RE.test(readFile);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (name === "view_image" || /image|screenshot/i.test(name))
|
|
198
|
-
tc.isImage = true;
|
|
199
|
-
cur.tools.push(tc);
|
|
200
|
-
if (tc.id)
|
|
201
|
-
pending.set(tc.id, tc);
|
|
202
|
-
}
|
|
203
|
-
if (p.type === "function_call_output" && p.call_id && pending.has(p.call_id)) {
|
|
204
|
-
pending.get(p.call_id).outputChars = contentLength(p.output);
|
|
205
|
-
pending.delete(p.call_id);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
return turns;
|
|
209
|
-
}
|
|
210
|
-
function parseClaudeTurns(file) {
|
|
211
|
-
const turns = [];
|
|
212
|
-
let cur = null;
|
|
213
|
-
const pending = new Map();
|
|
214
|
-
const pushTurn = (text, ts) => {
|
|
215
|
-
cur = {
|
|
216
|
-
index: turns.length, ts, userText: text, inputTokens: 0, outputTokens: 0,
|
|
217
|
-
tools: [], editedFiles: [], committed: false, compacted: false,
|
|
218
|
-
};
|
|
219
|
-
turns.push(cur);
|
|
220
|
-
};
|
|
221
|
-
eachLine(file, (o) => {
|
|
222
|
-
const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
|
|
223
|
-
const tsMs = Number.isFinite(ts) ? ts : null;
|
|
224
|
-
if (o.subtype === "compact_boundary" || o.isCompactSummary === true) {
|
|
225
|
-
if (cur)
|
|
226
|
-
cur.compacted = true;
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (o.type === "user" && !o.isMeta && o.message) {
|
|
230
|
-
const c = o.message.content;
|
|
231
|
-
let text = "";
|
|
232
|
-
if (typeof c === "string") {
|
|
233
|
-
text = c;
|
|
234
|
-
}
|
|
235
|
-
else if (Array.isArray(c)) {
|
|
236
|
-
text = c.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ");
|
|
237
|
-
for (const b of c) {
|
|
238
|
-
if (b?.type === "tool_result" && b.tool_use_id && pending.has(b.tool_use_id)) {
|
|
239
|
-
pending.get(b.tool_use_id).outputChars = contentLength(b.content);
|
|
240
|
-
pending.delete(b.tool_use_id);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (isRealUserText(text) && !o.isSidechain)
|
|
245
|
-
pushTurn(text, tsMs);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if (o.type === "assistant" && o.message) {
|
|
249
|
-
if (!cur)
|
|
250
|
-
return;
|
|
251
|
-
const u = o.message.usage;
|
|
252
|
-
if (u) {
|
|
253
|
-
const inp = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
254
|
-
cur.inputTokens = Math.max(cur.inputTokens, inp);
|
|
255
|
-
cur.outputTokens += u.output_tokens || 0;
|
|
256
|
-
}
|
|
257
|
-
const content = o.message.content;
|
|
258
|
-
if (!Array.isArray(content))
|
|
259
|
-
return;
|
|
260
|
-
for (const b of content) {
|
|
261
|
-
if (b?.type !== "tool_use")
|
|
262
|
-
continue;
|
|
263
|
-
const name = String(b.name || "");
|
|
264
|
-
const inp = b.input || {};
|
|
265
|
-
const tc = {
|
|
266
|
-
name, file: null, command: null, id: b.id || null,
|
|
267
|
-
outputChars: 0, isImage: false, gitKey: null, searchKey: null,
|
|
268
|
-
};
|
|
269
|
-
if (name === "Read") {
|
|
270
|
-
tc.file = path.basename(inp.file_path || "");
|
|
271
|
-
tc.isImage = IMAGE_RE.test(inp.file_path || "");
|
|
272
|
-
}
|
|
273
|
-
else if (name === "Edit" || name === "Write" || name === "MultiEdit") {
|
|
274
|
-
cur.editedFiles.push(path.basename(inp.file_path || ""));
|
|
275
|
-
}
|
|
276
|
-
else if (name === "Bash" && typeof inp.command === "string") {
|
|
277
|
-
tc.command = inp.command;
|
|
278
|
-
if (COMMIT_RE.test(inp.command))
|
|
279
|
-
cur.committed = true;
|
|
280
|
-
tc.gitKey = gitReadKey(inp.command);
|
|
281
|
-
tc.searchKey = searchKey(inp.command);
|
|
282
|
-
const readFile = extractReadFile(inp.command);
|
|
283
|
-
if (readFile) {
|
|
284
|
-
tc.file = path.basename(readFile);
|
|
285
|
-
tc.isImage = IMAGE_RE.test(readFile);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
else if (name === "Grep" || name === "Glob" || name === "LS") {
|
|
289
|
-
tc.searchKey = `${name}:${inp.pattern || inp.path || ""}`;
|
|
290
|
-
}
|
|
291
|
-
cur.tools.push(tc);
|
|
292
|
-
if (tc.id)
|
|
293
|
-
pending.set(tc.id, tc);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
return turns;
|
|
298
|
-
}
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
// Problem scanner — walks turns in order, maintains cross-turn state
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
function scanSession(turns, sessionLabel) {
|
|
303
|
-
// ---- Cross-turn state ----
|
|
304
|
-
const fileVersions = new Map();
|
|
305
|
-
const readHistory = new Map();
|
|
306
|
-
const gitCmds = new Map();
|
|
307
|
-
const searchCmds = new Map();
|
|
308
|
-
let lastCompactTurn = -1;
|
|
309
|
-
let prevTs = null;
|
|
310
|
-
const results = [];
|
|
311
|
-
// ==== First pass: Tier 1 + Tier 2 detection ====
|
|
312
|
-
for (let i = 0; i < turns.length; i++) {
|
|
313
|
-
const t = turns[i];
|
|
314
|
-
const problems = [];
|
|
315
|
-
// ---- Detect P06: gap → re-orientation reads ----
|
|
316
|
-
const hasGap = prevTs != null && t.ts != null && (t.ts - prevTs) > GAP_MS;
|
|
317
|
-
const gapMin = hasGap ? Math.round((t.ts - prevTs) / 60_000) : 0;
|
|
318
|
-
// ---- Per-tool detection (using state BEFORE this turn's updates) ----
|
|
319
|
-
let imageTokens = 0;
|
|
320
|
-
let postCompactTokens = 0;
|
|
321
|
-
const postCompactFiles = [];
|
|
322
|
-
let gapReorientTokens = 0;
|
|
323
|
-
const gapReorientFiles = [];
|
|
324
|
-
let dupGitTokens = 0;
|
|
325
|
-
const dupGitKeys = [];
|
|
326
|
-
let dupSearchTokens = 0;
|
|
327
|
-
const dupSearchKeys = [];
|
|
328
|
-
const turnReadEntries = [];
|
|
329
|
-
for (const tc of t.tools) {
|
|
330
|
-
const outTokens = tokensFromChars(tc.outputChars);
|
|
331
|
-
// --- P01: image content ---
|
|
332
|
-
if (tc.isImage && outTokens > 0) {
|
|
333
|
-
imageTokens += outTokens;
|
|
334
|
-
}
|
|
335
|
-
// --- File reads: P04 (post-compaction) + P06 (gap re-orientation) ---
|
|
336
|
-
if (tc.file && !tc.isImage) {
|
|
337
|
-
const base = tc.file;
|
|
338
|
-
const curVer = fileVersions.get(base) || 0;
|
|
339
|
-
turnReadEntries.push({ file: base, version: curVer, tokens: outTokens });
|
|
340
|
-
// P04: file was read before the most recent compaction, at the same version
|
|
341
|
-
if (lastCompactTurn >= 0 && i > lastCompactTurn) {
|
|
342
|
-
const priorRead = readHistory.get(base)?.find((r) => r.turn <= lastCompactTurn && r.version === curVer);
|
|
343
|
-
if (priorRead) {
|
|
344
|
-
postCompactTokens += outTokens;
|
|
345
|
-
postCompactFiles.push(base);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
// P06: file was read before the gap, now re-read after it
|
|
349
|
-
if (hasGap) {
|
|
350
|
-
const priorRead = readHistory.get(base)?.find((r) => r.turn < i);
|
|
351
|
-
if (priorRead) {
|
|
352
|
-
gapReorientTokens += outTokens;
|
|
353
|
-
gapReorientFiles.push(base);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
// --- P08: duplicate git queries ---
|
|
358
|
-
if (tc.gitKey) {
|
|
359
|
-
const prev = gitCmds.get(tc.gitKey);
|
|
360
|
-
if (prev && prev.length > 0) {
|
|
361
|
-
dupGitTokens += outTokens;
|
|
362
|
-
dupGitKeys.push(tc.gitKey);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
// --- P10: duplicate search commands ---
|
|
366
|
-
if (tc.searchKey) {
|
|
367
|
-
const prev = searchCmds.get(tc.searchKey);
|
|
368
|
-
if (prev && prev.length > 0) {
|
|
369
|
-
dupSearchTokens += outTokens;
|
|
370
|
-
dupSearchKeys.push(tc.searchKey);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
// ---- State updates (AFTER detection) ----
|
|
375
|
-
// Update read history
|
|
376
|
-
for (const entry of turnReadEntries) {
|
|
377
|
-
if (!readHistory.has(entry.file))
|
|
378
|
-
readHistory.set(entry.file, []);
|
|
379
|
-
readHistory.get(entry.file).push({ turn: i, version: entry.version, tokens: entry.tokens });
|
|
380
|
-
}
|
|
381
|
-
// Update git/search history
|
|
382
|
-
for (const tc of t.tools) {
|
|
383
|
-
if (tc.gitKey) {
|
|
384
|
-
if (!gitCmds.has(tc.gitKey))
|
|
385
|
-
gitCmds.set(tc.gitKey, []);
|
|
386
|
-
gitCmds.get(tc.gitKey).push(i);
|
|
387
|
-
}
|
|
388
|
-
if (tc.searchKey) {
|
|
389
|
-
if (!searchCmds.has(tc.searchKey))
|
|
390
|
-
searchCmds.set(tc.searchKey, []);
|
|
391
|
-
searchCmds.get(tc.searchKey).push(i);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
// P03: edits create shadow versions of previously-read files still in context
|
|
395
|
-
let shadowTokens = 0;
|
|
396
|
-
const shadowFiles = [];
|
|
397
|
-
for (const editedFile of t.editedFiles) {
|
|
398
|
-
fileVersions.set(editedFile, (fileVersions.get(editedFile) || 0) + 1);
|
|
399
|
-
const history = readHistory.get(editedFile);
|
|
400
|
-
if (history) {
|
|
401
|
-
const curVer = fileVersions.get(editedFile) || 0;
|
|
402
|
-
for (const r of history) {
|
|
403
|
-
if (r.version < curVer && r.turn > lastCompactTurn) {
|
|
404
|
-
shadowTokens += r.tokens;
|
|
405
|
-
if (!shadowFiles.includes(editedFile))
|
|
406
|
-
shadowFiles.push(editedFile);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
// Compaction tracking (after detection so this turn's reads are pre-compaction)
|
|
412
|
-
if (t.compacted)
|
|
413
|
-
lastCompactTurn = i;
|
|
414
|
-
if (t.ts != null)
|
|
415
|
-
prevTs = t.ts;
|
|
416
|
-
// ---- Emit problem hits ----
|
|
417
|
-
if (imageTokens > 0) {
|
|
418
|
-
problems.push({ id: "P01", wastedTokens: imageTokens, confidence: "high",
|
|
419
|
-
evidence: `${imageTokens} tokens of image/binary content loaded` });
|
|
420
|
-
}
|
|
421
|
-
if (shadowTokens > 0) {
|
|
422
|
-
problems.push({ id: "P03", wastedTokens: shadowTokens, confidence: "medium",
|
|
423
|
-
evidence: `Edit to ${shadowFiles.join(", ")} shadowed ${shadowTokens} stale-read tokens still in context` });
|
|
424
|
-
}
|
|
425
|
-
if (postCompactTokens > 0) {
|
|
426
|
-
problems.push({ id: "P04", wastedTokens: postCompactTokens, confidence: "high",
|
|
427
|
-
evidence: `Re-read ${[...new Set(postCompactFiles)].join(", ")} unchanged after compaction; ${postCompactTokens} tokens re-gathered` });
|
|
428
|
-
}
|
|
429
|
-
if (gapReorientTokens > 0) {
|
|
430
|
-
problems.push({ id: "P06", wastedTokens: gapReorientTokens, confidence: "medium",
|
|
431
|
-
evidence: `${gapMin}min gap → re-read ${[...new Set(gapReorientFiles)].join(", ")}; ${gapReorientTokens} tokens` });
|
|
432
|
-
}
|
|
433
|
-
if (dupGitTokens > 0) {
|
|
434
|
-
problems.push({ id: "P08", wastedTokens: dupGitTokens, confidence: "high",
|
|
435
|
-
evidence: `Duplicate git queries (${[...new Set(dupGitKeys)].length} unique); ${dupGitTokens} tokens` });
|
|
436
|
-
}
|
|
437
|
-
if (dupSearchTokens > 0) {
|
|
438
|
-
problems.push({ id: "P10", wastedTokens: dupSearchTokens, confidence: "high",
|
|
439
|
-
evidence: `Duplicate searches (${[...new Set(dupSearchKeys)].length} unique); ${dupSearchTokens} tokens` });
|
|
440
|
-
}
|
|
441
|
-
const wastedSum = problems.reduce((s, p) => s + p.wastedTokens, 0);
|
|
442
|
-
results.push({
|
|
443
|
-
index: i, inputTokens: t.inputTokens, outputTokens: t.outputTokens,
|
|
444
|
-
verdict: "neutral", problems, wastedTokens: wastedSum,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
// ==== Second pass: verdicts + Tier 3 problems (P02, P05, P07, P09) ====
|
|
448
|
-
// Compute verdicts: verdict of turn i = classify the user text of turn i+1.
|
|
449
|
-
for (let i = 0; i < turns.length - 1; i++) {
|
|
450
|
-
results[i].verdict = classifyVerdict(turns[i + 1].userText);
|
|
451
|
-
}
|
|
452
|
-
// P02: correction cascade — ≥2 consecutive rework-verdict turns
|
|
453
|
-
let cascadeStart = -1;
|
|
454
|
-
let cascadeLen = 0;
|
|
455
|
-
for (let i = 0; i <= results.length; i++) {
|
|
456
|
-
const isRework = i < results.length && results[i].verdict === "rework";
|
|
457
|
-
if (isRework) {
|
|
458
|
-
if (cascadeStart < 0)
|
|
459
|
-
cascadeStart = i;
|
|
460
|
-
cascadeLen++;
|
|
461
|
-
}
|
|
462
|
-
else {
|
|
463
|
-
if (cascadeLen >= 2) {
|
|
464
|
-
// Turns after the first in the cascade are the avoidable waste.
|
|
465
|
-
for (let j = cascadeStart + 1; j < cascadeStart + cascadeLen; j++) {
|
|
466
|
-
const waste = results[j].outputTokens;
|
|
467
|
-
if (waste > 0) {
|
|
468
|
-
results[j].problems.push({ id: "P02", wastedTokens: waste, confidence: "low",
|
|
469
|
-
evidence: `Turn ${j} in ${cascadeLen}-turn correction cascade (started turn ${cascadeStart})` });
|
|
470
|
-
results[j].wastedTokens += waste;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
cascadeStart = -1;
|
|
475
|
-
cascadeLen = 0;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
// P05: false completion — agent committed, next turn is rework
|
|
479
|
-
for (let i = 0; i < turns.length - 1; i++) {
|
|
480
|
-
if (turns[i].committed && results[i + 1].verdict === "rework") {
|
|
481
|
-
const waste = results[i].outputTokens;
|
|
482
|
-
if (waste > 0) {
|
|
483
|
-
results[i].problems.push({ id: "P05", wastedTokens: waste, confidence: "low",
|
|
484
|
-
evidence: `Committed at turn ${i} but turn ${i + 1} is a rework` });
|
|
485
|
-
results[i].wastedTokens += waste;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
// P07: abort → revert — user says revert/undo and previous turn had edits
|
|
490
|
-
for (let i = 1; i < turns.length; i++) {
|
|
491
|
-
if (ABORT_RE.test(cleanUserText(turns[i].userText)) && turns[i - 1].editedFiles.length > 0) {
|
|
492
|
-
const waste = results[i - 1].outputTokens;
|
|
493
|
-
if (waste > 0) {
|
|
494
|
-
results[i - 1].problems.push({ id: "P07", wastedTokens: waste, confidence: "medium",
|
|
495
|
-
evidence: `Edits at turn ${i - 1} (${turns[i - 1].editedFiles.slice(0, 3).join(", ")}) reverted at turn ${i}` });
|
|
496
|
-
results[i - 1].wastedTokens += waste;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
// P09: user pulls back trajectory — ≥2 redirect messages in session
|
|
501
|
-
const redirectTurns = [];
|
|
502
|
-
for (let i = 0; i < turns.length; i++) {
|
|
503
|
-
if (REDIRECT_RE.test(cleanUserText(turns[i].userText)))
|
|
504
|
-
redirectTurns.push(i);
|
|
505
|
-
}
|
|
506
|
-
if (redirectTurns.length >= 2) {
|
|
507
|
-
for (const ri of redirectTurns) {
|
|
508
|
-
if (ri > 0) {
|
|
509
|
-
const waste = results[ri - 1].outputTokens;
|
|
510
|
-
if (waste > 0) {
|
|
511
|
-
results[ri - 1].problems.push({ id: "P09", wastedTokens: waste, confidence: "low",
|
|
512
|
-
evidence: `User redirect at turn ${ri}; agent went off-track at turn ${ri - 1}` });
|
|
513
|
-
results[ri - 1].wastedTokens += waste;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
return results;
|
|
519
|
-
}
|
|
520
|
-
// ---------------------------------------------------------------------------
|
|
521
|
-
// Aggregation — merge per-session results into a workspace-level report
|
|
522
|
-
// ---------------------------------------------------------------------------
|
|
523
|
-
function localDay(ms) {
|
|
524
|
-
const d = new Date(ms);
|
|
525
|
-
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
526
|
-
const day = String(d.getDate()).padStart(2, "0");
|
|
527
|
-
return `${d.getFullYear()}-${m}-${day}`;
|
|
528
|
-
}
|
|
529
|
-
function aggregateResults(sessionResults, firstTs, lastTs) {
|
|
530
|
-
let totalInput = 0;
|
|
531
|
-
let totalWaste = 0;
|
|
532
|
-
let turnCount = 0;
|
|
533
|
-
const byProblem = new Map();
|
|
534
|
-
for (const m of PROBLEM_META) {
|
|
535
|
-
byProblem.set(m.id, { turnKeys: new Set(), tokens: 0, episodes: [] });
|
|
536
|
-
}
|
|
537
|
-
for (const { session, results } of sessionResults) {
|
|
538
|
-
turnCount += results.length;
|
|
539
|
-
for (const r of results) {
|
|
540
|
-
totalInput += r.inputTokens;
|
|
541
|
-
totalWaste += r.wastedTokens;
|
|
542
|
-
for (const p of r.problems) {
|
|
543
|
-
const bucket = byProblem.get(p.id);
|
|
544
|
-
bucket.turnKeys.add(`${session}:${r.index}`);
|
|
545
|
-
bucket.tokens += p.wastedTokens;
|
|
546
|
-
bucket.episodes.push({
|
|
547
|
-
session, turns: [r.index], wastedTokens: p.wastedTokens, evidence: p.evidence,
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
const problems = PROBLEM_META.map((m) => {
|
|
553
|
-
const bucket = byProblem.get(m.id);
|
|
554
|
-
return {
|
|
555
|
-
id: m.id, name: m.name, tier: m.tier, confidence: m.confidence,
|
|
556
|
-
turnsAffected: bucket.turnKeys.size,
|
|
557
|
-
turnsAffectedPct: turnCount > 0 ? Math.round((bucket.turnKeys.size / turnCount) * 100) : 0,
|
|
558
|
-
wastedTokens: bucket.tokens,
|
|
559
|
-
wastedPct: totalWaste > 0 ? Math.round((bucket.tokens / totalWaste) * 100) : 0,
|
|
560
|
-
topEpisodes: bucket.episodes.sort((a, b) => b.wastedTokens - a.wastedTokens).slice(0, 5),
|
|
561
|
-
};
|
|
562
|
-
});
|
|
563
|
-
return {
|
|
564
|
-
schemaVersion: 1,
|
|
565
|
-
generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
|
|
566
|
-
llmCallsUsed: 0,
|
|
567
|
-
transcriptsUploaded: false,
|
|
568
|
-
scanWindow: {
|
|
569
|
-
first: firstTs != null ? localDay(firstTs) : null,
|
|
570
|
-
last: lastTs != null ? localDay(lastTs) : null,
|
|
571
|
-
},
|
|
572
|
-
sessionCount: sessionResults.length,
|
|
573
|
-
turnCount,
|
|
574
|
-
overall: {
|
|
575
|
-
totalInputTokens: totalInput,
|
|
576
|
-
totalWastedTokens: totalWaste,
|
|
577
|
-
wasteRate: totalInput > 0 ? Math.round((totalWaste / totalInput) * 10_000) / 10_000 : 0,
|
|
578
|
-
avgWastePerTurn: turnCount > 0 ? Math.round(totalWaste / turnCount) : 0,
|
|
579
|
-
},
|
|
580
|
-
problems,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
// ---------------------------------------------------------------------------
|
|
584
|
-
// Public API
|
|
585
|
-
// ---------------------------------------------------------------------------
|
|
586
|
-
export function buildProblemReport(opts) {
|
|
587
|
-
const sources = opts?.sources ?? ["codex", "claude"];
|
|
588
|
-
const codexDiscovery = sources.includes("codex") ? discoverCodexSessionFiles({ includeArchived: true }) : null;
|
|
589
|
-
const claudeRoot = sources.includes("claude") ? resolveClaudeProjectsDir() : null;
|
|
590
|
-
const codexFiles = codexDiscovery?.files.map((file) => file.path) ?? [];
|
|
591
|
-
const claudeFiles = claudeRoot
|
|
592
|
-
? walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows").sort()
|
|
593
|
-
: [];
|
|
594
|
-
const total = codexFiles.length + claudeFiles.length;
|
|
595
|
-
let done = 0;
|
|
596
|
-
const tick = () => {
|
|
597
|
-
done++;
|
|
598
|
-
if (opts?.onProgress && (done % 8 === 0 || done === total))
|
|
599
|
-
opts.onProgress(done, total);
|
|
600
|
-
};
|
|
601
|
-
const sessionResults = [];
|
|
602
|
-
let globalFirstTs = null;
|
|
603
|
-
let globalLastTs = null;
|
|
604
|
-
const trackTs = (turns) => {
|
|
605
|
-
for (const t of turns) {
|
|
606
|
-
if (t.ts != null) {
|
|
607
|
-
if (globalFirstTs == null || t.ts < globalFirstTs)
|
|
608
|
-
globalFirstTs = t.ts;
|
|
609
|
-
if (globalLastTs == null || t.ts > globalLastTs)
|
|
610
|
-
globalLastTs = t.ts;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
for (const f of codexFiles) {
|
|
615
|
-
const turns = parseCodexTurns(f);
|
|
616
|
-
if (turns.length > 0) {
|
|
617
|
-
trackTs(turns);
|
|
618
|
-
const label = path.basename(f, ".jsonl").slice(0, 16);
|
|
619
|
-
sessionResults.push({ session: label, results: scanSession(turns, label) });
|
|
620
|
-
}
|
|
621
|
-
tick();
|
|
622
|
-
}
|
|
623
|
-
for (const f of claudeFiles) {
|
|
624
|
-
const turns = parseClaudeTurns(f);
|
|
625
|
-
if (turns.length > 0) {
|
|
626
|
-
trackTs(turns);
|
|
627
|
-
const label = path.basename(f, ".jsonl").slice(0, 16);
|
|
628
|
-
sessionResults.push({ session: label, results: scanSession(turns, label) });
|
|
629
|
-
}
|
|
630
|
-
tick();
|
|
631
|
-
}
|
|
632
|
-
return aggregateResults(sessionResults, globalFirstTs, globalLastTs);
|
|
633
|
-
}
|