@echomem/mcp 1.4.8 → 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 +23 -3
- 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/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1104 -105
- package/dist/city/echo-ai-city-only.template.html +1104 -105
- 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/metric.js +13 -4
- package/dist/hud/monitor.js +135 -16
- package/dist/hud/web.js +344 -298
- package/dist/index.js +7 -3
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +37 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +3290 -196
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +432 -34
- package/package.json +5 -4
- package/templates/echomem-recall.md +2 -2
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { discoverCodexSessionFiles } from "../codex-session-files.js";
|
|
8
|
+
import { resolveClaudeProjectsDir } from "../local-data-paths.js";
|
|
9
|
+
import { walk } from "../report.js";
|
|
10
|
+
import { buildClaudeNativeCanonicalReport } from "./claude-native-canonical.js";
|
|
11
|
+
const PROBLEM_IDS = [
|
|
12
|
+
"P01", "P02", "P03", "P04", "P05", "P06", "P07", "P08", "P09", "P10", "P11", "P12", "P13",
|
|
13
|
+
];
|
|
14
|
+
const PROBLEM_META = {
|
|
15
|
+
P01: { label: "Outdated Images & Screenshots", bucket: "dead", category: "Runtime Bug", confidence: "high" },
|
|
16
|
+
P02: { label: "Ignored User Instructions", bucket: "refind", category: "Model Behavior", confidence: "medium" },
|
|
17
|
+
P03: { label: "Old Files", bucket: "duplicate", category: "Structural Accumulation", confidence: "high" },
|
|
18
|
+
P04: { label: "Repeated Setup After Compaction", bucket: "refind", category: "Structural Accumulation", confidence: "high" },
|
|
19
|
+
P05: { label: "Premature Completion Fixes", bucket: "refind", category: "Human Cost", confidence: "medium" },
|
|
20
|
+
P06: { label: "Session Re-heat", bucket: "refind", category: "Human Cost", confidence: "medium" },
|
|
21
|
+
P07: { label: "Failed Turn Leftovers", bucket: "dead", category: "Runtime Bug", confidence: "medium" },
|
|
22
|
+
P08: { label: "Repeated Git Check Logs", bucket: "refind", category: "Model Behavior", confidence: "high" },
|
|
23
|
+
P09: { label: "Repeated Fix Attempts", bucket: "refind", category: "Human Cost", confidence: "medium" },
|
|
24
|
+
P10: { label: "Repeated Search", bucket: "refind", category: "Model Behavior", confidence: "high" },
|
|
25
|
+
P11: { label: "Visual Debug Logs", bucket: "dead", category: "Context Hygiene", confidence: "fallback" },
|
|
26
|
+
P12: { label: "Tool Call Logs", bucket: "dead", category: "Context Hygiene", confidence: "fallback" },
|
|
27
|
+
P13: { label: "Agent's Reasoning Notes", bucket: "dead", category: "Context Hygiene", confidence: "fallback" },
|
|
28
|
+
};
|
|
29
|
+
const SCORER_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/canonical-scorer");
|
|
30
|
+
// Per-session scored-result cache. Each Codex session is scored by spawning 3 vendored scorer
|
|
31
|
+
// processes (~300ms/session) — the dominant cost of the whole forensic scan. A session's scored
|
|
32
|
+
// output is a pure function of its immutable JSONL, so cache it by (mtime, size): re-runs only
|
|
33
|
+
// re-score new/changed sessions. Mirrors the forensic engine's codex parse cache. Bump the version
|
|
34
|
+
// whenever the vendored scorer scripts change so stale results are discarded.
|
|
35
|
+
const CANONICAL_CACHE_VERSION = 3;
|
|
36
|
+
function canonicalCachePath() {
|
|
37
|
+
return path.join(os.homedir(), ".echomem", "canonical-cache.json");
|
|
38
|
+
}
|
|
39
|
+
function loadCanonicalCache() {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(canonicalCachePath(), "utf8"));
|
|
42
|
+
if (parsed && parsed.v === CANONICAL_CACHE_VERSION && parsed.files && typeof parsed.files === "object")
|
|
43
|
+
return parsed.files;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* no cache yet, or a stale/corrupt one — rebuild */
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
function saveCanonicalCache(files) {
|
|
51
|
+
try {
|
|
52
|
+
const p = canonicalCachePath();
|
|
53
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
54
|
+
fs.writeFileSync(p, JSON.stringify({ v: CANONICAL_CACHE_VERSION, files }));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* best effort: a cache write failure must never break the scan */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function buildVendoredCanonicalReport(opts) {
|
|
61
|
+
const sources = opts?.sources ?? defaultSources(opts);
|
|
62
|
+
const claudeRoot = sources.includes("claude-code") ? resolveClaudeProjectsDir() : null;
|
|
63
|
+
const discoveredCodex = sources.includes("codex")
|
|
64
|
+
? (opts?.codexSessionPaths
|
|
65
|
+
?? opts?.sessionPaths
|
|
66
|
+
?? discoverCodexSessionFiles({ includeArchived: true, userInitiatedOnly: true }).files.map((file) => file.path))
|
|
67
|
+
.filter(isUserInitiatedSession)
|
|
68
|
+
: [];
|
|
69
|
+
const discoveredClaude = sources.includes("claude-code")
|
|
70
|
+
? (opts?.claudeSessionPaths ?? (claudeRoot ? walk(claudeRoot, (candidate) => candidate.endsWith(".jsonl") && !candidate.includes(`${path.sep}subagents${path.sep}`) && !candidate.includes(`${path.sep}workflows${path.sep}`), () => false) : [])).sort()
|
|
71
|
+
: [];
|
|
72
|
+
const codexFiles = limitFiles(discoveredCodex, opts?.limitFiles);
|
|
73
|
+
const claudeFiles = limitFiles(discoveredClaude, opts?.limitFiles);
|
|
74
|
+
const scored = [];
|
|
75
|
+
const codexErrors = [];
|
|
76
|
+
let done = 0;
|
|
77
|
+
const cache = loadCanonicalCache();
|
|
78
|
+
const nextCache = {};
|
|
79
|
+
const codexStart = Date.now();
|
|
80
|
+
let cachedCount = 0;
|
|
81
|
+
let rescoredCount = 0;
|
|
82
|
+
const total = codexFiles.length + claudeFiles.length;
|
|
83
|
+
const strictCodexErrors = opts?.strictSessionErrors ?? Boolean(opts?.codexSessionPaths || opts?.sessionPaths);
|
|
84
|
+
// Score sessions across all cores. Each session is independent (isolated temp dir, no cross-session
|
|
85
|
+
// state) so order only affects the final aggregate iteration — results are written back in file
|
|
86
|
+
// order below to keep the report deterministic. Leave one core for the main thread.
|
|
87
|
+
const poolSize = Number(process.env.ECHOMEM_CANON_POOL) || Math.max(2, os.cpus().length - 1);
|
|
88
|
+
const perFile = await mapPool(codexFiles, poolSize, async (file) => {
|
|
89
|
+
let st = null;
|
|
90
|
+
try {
|
|
91
|
+
st = fs.statSync(file);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* file vanished mid-scan — skip it */
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const hit = st ? cache[file] : null;
|
|
98
|
+
const fresh = hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size;
|
|
99
|
+
const slim = fresh ? hit.scored : slimScored(await scoreSession(file, "codex"));
|
|
100
|
+
if (fresh)
|
|
101
|
+
cachedCount++;
|
|
102
|
+
else
|
|
103
|
+
rescoredCount++;
|
|
104
|
+
return { st, slim, error: null };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (strictCodexErrors)
|
|
108
|
+
throw error;
|
|
109
|
+
const session = sessionIdFor(file).slice(0, 32);
|
|
110
|
+
return {
|
|
111
|
+
st,
|
|
112
|
+
slim: null,
|
|
113
|
+
error: { session, message: "Canonical scorer could not process this local session." },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
opts?.onProgress?.(++done, total);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
codexFiles.forEach((file, i) => {
|
|
121
|
+
const { st, slim, error } = perFile[i];
|
|
122
|
+
if (error)
|
|
123
|
+
codexErrors.push(error);
|
|
124
|
+
if (!slim)
|
|
125
|
+
return;
|
|
126
|
+
if (st)
|
|
127
|
+
nextCache[file] = { mtimeMs: st.mtimeMs, size: st.size, scored: slim };
|
|
128
|
+
scored.push({ ...slim, source: "codex" });
|
|
129
|
+
});
|
|
130
|
+
if (codexFiles.length)
|
|
131
|
+
saveCanonicalCache(nextCache);
|
|
132
|
+
if (codexFiles.length) {
|
|
133
|
+
// One-line breakdown so slow scans are diagnosable: how many sessions were freshly scored (the
|
|
134
|
+
// expensive part) vs reused from cache, across how many parallel workers, and how long it took.
|
|
135
|
+
const skipped = codexErrors.length ? `, ${codexErrors.length} skipped` : "";
|
|
136
|
+
console.error(`[echomem] canonical codex: ${codexFiles.length} sessions (${cachedCount} cached, ${rescoredCount} rescored${skipped}, ${poolSize}-way parallel) in ${((Date.now() - codexStart) / 1000).toFixed(1)}s`);
|
|
137
|
+
}
|
|
138
|
+
const codexScored = scored.filter((row) => row.source === "codex");
|
|
139
|
+
const codexReport = sources.includes("codex") ? aggregate(codexScored, reportMetadata("codex")) : null;
|
|
140
|
+
if (codexReport && codexErrors.length) {
|
|
141
|
+
codexReport.diagnostics = { skippedSessions: codexErrors.length, errors: codexErrors };
|
|
142
|
+
}
|
|
143
|
+
const claudeReport = sources.includes("claude-code")
|
|
144
|
+
? buildClaudeNativeCanonicalReport({
|
|
145
|
+
sessionPaths: claudeFiles,
|
|
146
|
+
strictSessionErrors: opts?.strictSessionErrors ?? Boolean(opts?.claudeSessionPaths),
|
|
147
|
+
onProgress: (claudeDone) => opts?.onProgress?.(done + claudeDone, codexFiles.length + claudeFiles.length),
|
|
148
|
+
})
|
|
149
|
+
: null;
|
|
150
|
+
const reports = [codexReport, claudeReport].filter((report) => Boolean(report));
|
|
151
|
+
const report = reports.length === 1 ? reports[0] : combineReports(reports, reportMetadata("combined"));
|
|
152
|
+
report.sourceReports = {};
|
|
153
|
+
if (codexReport)
|
|
154
|
+
report.sourceReports.codex = stripSourceReports(codexReport);
|
|
155
|
+
if (claudeReport)
|
|
156
|
+
report.sourceReports.claudeCode = stripSourceReports(claudeReport);
|
|
157
|
+
return report;
|
|
158
|
+
}
|
|
159
|
+
async function scoreSession(sessionPath, source) {
|
|
160
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "echomem-canonical-"));
|
|
161
|
+
try {
|
|
162
|
+
const scorerInput = sessionPath;
|
|
163
|
+
const sessionId = sessionIdFor(sessionPath);
|
|
164
|
+
const problemsPath = path.join(temp, "problems.json");
|
|
165
|
+
const efficiencyPath = path.join(temp, "efficiency.json");
|
|
166
|
+
const dashboardPath = path.join(temp, "dashboard.json");
|
|
167
|
+
const placeholderHtml = path.join(temp, "unused.html");
|
|
168
|
+
const env = {
|
|
169
|
+
...process.env,
|
|
170
|
+
GOLDEN_ROOT: temp,
|
|
171
|
+
GOLDEN_PAYLOAD_ACCOUNTING: "0",
|
|
172
|
+
GOLDEN_JSON_ONLY: "1",
|
|
173
|
+
};
|
|
174
|
+
// Run the three scripts in sequence for THIS session (the dashboard needs both prior outputs,
|
|
175
|
+
// and the scorers are not safe to overlap inside one shared temp dir). Parallelism happens
|
|
176
|
+
// ACROSS sessions via the pool below, which is where the speedup comes from.
|
|
177
|
+
// The three scripts have a dependency chain (the dashboard needs both prior outputs), so they run
|
|
178
|
+
// in sequence for THIS session. Parallelism happens ACROSS sessions via the pool above, which
|
|
179
|
+
// already saturates every core — running these two side-by-side only adds contention.
|
|
180
|
+
await runVendored("analyze-10-problems.mjs", [
|
|
181
|
+
"--session-id", sessionId,
|
|
182
|
+
"--jsonl", scorerInput,
|
|
183
|
+
"--out", problemsPath,
|
|
184
|
+
"--html", placeholderHtml,
|
|
185
|
+
], env);
|
|
186
|
+
await runVendored("optimizable_detail.mjs", [scorerInput, "--scoring-mode", "episode-outcome"], env);
|
|
187
|
+
const generatedEfficiency = fs.readdirSync(temp)
|
|
188
|
+
.find((name) => name.startsWith("OPTIMIZABLE_DETAIL_EPISODE_OUTCOME_") && name.endsWith(".json"));
|
|
189
|
+
if (!generatedEfficiency)
|
|
190
|
+
throw new Error(`Canonical efficiency output was not created for ${sessionId}`);
|
|
191
|
+
fs.renameSync(path.join(temp, generatedEfficiency), efficiencyPath);
|
|
192
|
+
await runVendored("build-session-waste-dashboard.mjs", [
|
|
193
|
+
"--session-id", sessionId,
|
|
194
|
+
"--problems", problemsPath,
|
|
195
|
+
"--efficiency", efficiencyPath,
|
|
196
|
+
"--out", dashboardPath,
|
|
197
|
+
"--html", placeholderHtml,
|
|
198
|
+
], env);
|
|
199
|
+
const dashboard = readJson(dashboardPath);
|
|
200
|
+
const efficiency = readJson(efficiencyPath);
|
|
201
|
+
await enrichCodexDashboardTurns(dashboard, sessionPath);
|
|
202
|
+
return {
|
|
203
|
+
dashboard,
|
|
204
|
+
efficiency,
|
|
205
|
+
repo: repoLabel(dashboard.session.sourceSession?.cwd || null),
|
|
206
|
+
source,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Run fn over items with at most `limit` in flight at once, returning results in input order.
|
|
214
|
+
async function mapPool(items, limit, fn) {
|
|
215
|
+
const results = new Array(items.length);
|
|
216
|
+
let next = 0;
|
|
217
|
+
async function worker() {
|
|
218
|
+
while (next < items.length) {
|
|
219
|
+
const i = next++;
|
|
220
|
+
results[i] = await fn(items[i], i);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker()));
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
async function enrichCodexDashboardTurns(dashboard, sessionPath) {
|
|
227
|
+
const previews = await readCodexTurnPreviews(sessionPath);
|
|
228
|
+
if (!dashboard.session.sourceSession)
|
|
229
|
+
dashboard.session.sourceSession = {};
|
|
230
|
+
if (!dashboard.session.sourceSession.startedAt && previews.startedAt)
|
|
231
|
+
dashboard.session.sourceSession.startedAt = previews.startedAt;
|
|
232
|
+
if (!dashboard.session.sourceSession.cwd && previews.cwd)
|
|
233
|
+
dashboard.session.sourceSession.cwd = previews.cwd;
|
|
234
|
+
for (const turn of dashboard.timeline || []) {
|
|
235
|
+
const preview = previews.turns.get(turn.turn);
|
|
236
|
+
if (!preview)
|
|
237
|
+
continue;
|
|
238
|
+
turn.userMessage ||= preview.userMessage;
|
|
239
|
+
turn.assistantOutput ||= compactBlockPreview(preview.assistantMessages.join("\n\n"), 1200);
|
|
240
|
+
turn.timestamp ||= preview.timestamp;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function readCodexTurnPreviews(sessionPath) {
|
|
244
|
+
let cwd = null;
|
|
245
|
+
let startedAt = null;
|
|
246
|
+
let current = null;
|
|
247
|
+
const turns = new Map();
|
|
248
|
+
const lines = readline.createInterface({ input: fs.createReadStream(sessionPath), crlfDelay: Infinity });
|
|
249
|
+
for await (const line of lines) {
|
|
250
|
+
if (!line.trim())
|
|
251
|
+
continue;
|
|
252
|
+
let row;
|
|
253
|
+
try {
|
|
254
|
+
row = JSON.parse(line);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!isRecord(row))
|
|
260
|
+
continue;
|
|
261
|
+
const payload = recordValue(row.payload);
|
|
262
|
+
const type = stringValue(payload.type) || stringValue(row.type);
|
|
263
|
+
const timestamp = stringValue(row.timestamp) || stringValue(payload.timestamp) || null;
|
|
264
|
+
if (stringValue(row.type) === "session_meta" || type === "session_meta") {
|
|
265
|
+
cwd ||= stringValue(payload.cwd) || null;
|
|
266
|
+
startedAt ||= stringValue(payload.timestamp) || timestamp;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const userMessage = codexUserMessageText(row, payload, type);
|
|
270
|
+
if (userMessage) {
|
|
271
|
+
if (current && current.userMessage === userMessage) {
|
|
272
|
+
current.timestamp ||= timestamp;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
current = { userMessage, assistantMessages: [], timestamp };
|
|
276
|
+
turns.set(turns.size + 1, current);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!current)
|
|
280
|
+
continue;
|
|
281
|
+
const assistantMessage = codexAssistantMessageText(row, payload, type);
|
|
282
|
+
if (assistantMessage && current.assistantMessages[current.assistantMessages.length - 1] !== assistantMessage) {
|
|
283
|
+
current.assistantMessages.push(assistantMessage);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { cwd, startedAt, turns };
|
|
287
|
+
}
|
|
288
|
+
function codexUserMessageText(row, payload, type) {
|
|
289
|
+
if (stringValue(row.type) === "event_msg" && type === "user_message")
|
|
290
|
+
return stringValue(payload.message).trim();
|
|
291
|
+
if (stringValue(row.type) !== "response_item" || type !== "message" || stringValue(payload.role) !== "user")
|
|
292
|
+
return "";
|
|
293
|
+
return codexContentText(payload.content).trim();
|
|
294
|
+
}
|
|
295
|
+
function codexAssistantMessageText(row, payload, type) {
|
|
296
|
+
if ((stringValue(row.type) === "event_msg" || stringValue(row.type) === "compacted") && type === "agent_message")
|
|
297
|
+
return stringValue(payload.message).trim();
|
|
298
|
+
if (stringValue(row.type) !== "response_item" || type !== "message" || stringValue(payload.role) !== "assistant")
|
|
299
|
+
return "";
|
|
300
|
+
return codexContentText(payload.content).trim();
|
|
301
|
+
}
|
|
302
|
+
function codexContentText(value) {
|
|
303
|
+
if (typeof value === "string")
|
|
304
|
+
return value;
|
|
305
|
+
if (Array.isArray(value))
|
|
306
|
+
return value.map(codexContentText).filter(Boolean).join("\n");
|
|
307
|
+
if (!isRecord(value))
|
|
308
|
+
return "";
|
|
309
|
+
return stringValue(value.text) || codexContentText(value.content);
|
|
310
|
+
}
|
|
311
|
+
function compactBlockPreview(text, limit = 1200) {
|
|
312
|
+
const compacted = text
|
|
313
|
+
.replace(/\r\n/g, "\n")
|
|
314
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
315
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
316
|
+
.trim();
|
|
317
|
+
if (!compacted)
|
|
318
|
+
return undefined;
|
|
319
|
+
return compacted.length > limit ? `${compacted.slice(0, limit - 1)}…` : compacted;
|
|
320
|
+
}
|
|
321
|
+
// Keep only the fields aggregate() reads. The vendored scorer emits large dashboard-HTML payloads
|
|
322
|
+
// (topWasteTurns/topInputTurns/keyDataPoints, ~32MB) and a full per-turn efficiency.series (~129MB)
|
|
323
|
+
// that ride along via readJson's cast but are never consumed here. Slimming shrinks the on-disk cache
|
|
324
|
+
// from ~200MB to a few MB and cuts peak memory, with byte-identical aggregate output.
|
|
325
|
+
function slimScored(row) {
|
|
326
|
+
const d = row.dashboard;
|
|
327
|
+
const t = d.totals;
|
|
328
|
+
return {
|
|
329
|
+
dashboard: {
|
|
330
|
+
session: {
|
|
331
|
+
id: d.session.id,
|
|
332
|
+
sourceSession: d.session.sourceSession ? {
|
|
333
|
+
cwd: d.session.sourceSession.cwd ?? null,
|
|
334
|
+
turns: d.session.sourceSession.turns,
|
|
335
|
+
startedAt: d.session.sourceSession.startedAt ?? null,
|
|
336
|
+
} : undefined,
|
|
337
|
+
},
|
|
338
|
+
totals: {
|
|
339
|
+
officialInputTokens: t.officialInputTokens || 0,
|
|
340
|
+
usefulTokens: t.usefulTokens || 0,
|
|
341
|
+
wasteTokens: t.wasteTokens || 0,
|
|
342
|
+
duplicateWasteTokens: t.duplicateWasteTokens || 0,
|
|
343
|
+
refindWasteTokens: t.refindWasteTokens || 0,
|
|
344
|
+
deadWasteTokens: t.deadWasteTokens || 0,
|
|
345
|
+
excludedUnlabeledTokens: t.excludedUnlabeledTokens || 0,
|
|
346
|
+
unattributedWasteTokens: t.unattributedWasteTokens || 0,
|
|
347
|
+
},
|
|
348
|
+
problemContributions: (d.problemContributions || []).map((p) => ({
|
|
349
|
+
id: p.id, name: p.name, category: p.category,
|
|
350
|
+
eventsFound: p.eventsFound, evidenceTurns: p.evidenceTurns, qualifiedTurns: p.qualifiedTurns,
|
|
351
|
+
qualifiedTokenPressure: p.qualifiedTokenPressure, allocatedWasteTokens: p.allocatedWasteTokens || 0,
|
|
352
|
+
worstTurnByAllocatedWaste: p.worstTurnByAllocatedWaste
|
|
353
|
+
? {
|
|
354
|
+
turn: p.worstTurnByAllocatedWaste.turn,
|
|
355
|
+
allocatedWasteTokens: p.worstTurnByAllocatedWaste.allocatedWasteTokens,
|
|
356
|
+
inputTokens: p.worstTurnByAllocatedWaste.inputTokens,
|
|
357
|
+
usefulTokens: p.worstTurnByAllocatedWaste.usefulTokens,
|
|
358
|
+
wasteTokens: p.worstTurnByAllocatedWaste.wasteTokens,
|
|
359
|
+
itemReason: p.worstTurnByAllocatedWaste.itemReason,
|
|
360
|
+
}
|
|
361
|
+
: null,
|
|
362
|
+
})),
|
|
363
|
+
timeline: (d.timeline || []).map((tt) => ({
|
|
364
|
+
turn: tt.turn, episode: tt.episode,
|
|
365
|
+
inputTokens: tt.inputTokens || 0, usefulTokens: tt.usefulTokens || 0, wasteTokens: tt.wasteTokens || 0,
|
|
366
|
+
rawUsefulTokens: tt.rawUsefulTokens || 0, rawOutcomeWasteTokens: tt.rawOutcomeWasteTokens || 0,
|
|
367
|
+
userMessage: tt.userMessage,
|
|
368
|
+
assistantOutput: tt.assistantOutput,
|
|
369
|
+
timestamp: tt.timestamp ?? null,
|
|
370
|
+
allocatedItems: (tt.allocatedItems || []).map((it) => ({ problemId: it.problemId, tokens: it.tokens })),
|
|
371
|
+
})),
|
|
372
|
+
},
|
|
373
|
+
efficiency: {
|
|
374
|
+
series: (row.efficiency.series || []).map((s) => ({
|
|
375
|
+
keep_oh: s.keep_oh || 0, keep_prod: s.keep_prod || 0, opt_dup: s.opt_dup || 0, opt_refind: s.opt_refind || 0, opt_dead: s.opt_dead || 0,
|
|
376
|
+
})),
|
|
377
|
+
},
|
|
378
|
+
repo: row.repo,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function aggregate(scored, metadata) {
|
|
382
|
+
const totals = scored.reduce((sum, row) => {
|
|
383
|
+
const value = row.dashboard.totals;
|
|
384
|
+
sum.input += value.officialInputTokens || 0;
|
|
385
|
+
sum.useful += value.usefulTokens || 0;
|
|
386
|
+
sum.waste += value.wasteTokens || 0;
|
|
387
|
+
sum.duplicate += value.duplicateWasteTokens || 0;
|
|
388
|
+
sum.refind += value.refindWasteTokens || 0;
|
|
389
|
+
sum.dead += value.deadWasteTokens || 0;
|
|
390
|
+
sum.unattributed += value.unattributedWasteTokens || 0;
|
|
391
|
+
sum.excluded += value.excludedUnlabeledTokens || 0;
|
|
392
|
+
sum.turns += row.dashboard.session.sourceSession?.turns ?? row.dashboard.timeline.length;
|
|
393
|
+
return sum;
|
|
394
|
+
}, { input: 0, useful: 0, waste: 0, duplicate: 0, refind: 0, dead: 0, unattributed: 0, excluded: 0, turns: 0 });
|
|
395
|
+
const rawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
|
|
396
|
+
for (const row of scored) {
|
|
397
|
+
for (const turn of row.efficiency.series) {
|
|
398
|
+
rawBuckets.keep_oh += turn.keep_oh || 0;
|
|
399
|
+
rawBuckets.keep_prod += turn.keep_prod || 0;
|
|
400
|
+
rawBuckets.opt_dup += turn.opt_dup || 0;
|
|
401
|
+
rawBuckets.opt_refind += turn.opt_refind || 0;
|
|
402
|
+
rawBuckets.opt_dead += turn.opt_dead || 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const problemRows = new Map();
|
|
406
|
+
for (const id of PROBLEM_IDS)
|
|
407
|
+
problemRows.set(id, { tokens: 0, count: 0, pressure: 0, sessions: 0, examples: [] });
|
|
408
|
+
for (const row of scored) {
|
|
409
|
+
const turnByNumber = new Map(row.dashboard.timeline.map((turn) => [turn.turn, turn]));
|
|
410
|
+
for (const problem of row.dashboard.problemContributions || []) {
|
|
411
|
+
const aggregateProblem = problemRows.get(problem.id);
|
|
412
|
+
if (!aggregateProblem)
|
|
413
|
+
continue;
|
|
414
|
+
aggregateProblem.tokens += problem.allocatedWasteTokens || 0;
|
|
415
|
+
aggregateProblem.count += problem.eventsFound ?? problem.evidenceTurns ?? problem.qualifiedTurns ?? 0;
|
|
416
|
+
aggregateProblem.pressure += problem.qualifiedTokenPressure || 0;
|
|
417
|
+
if ((problem.allocatedWasteTokens || 0) > 0)
|
|
418
|
+
aggregateProblem.sessions += 1;
|
|
419
|
+
const worst = problem.worstTurnByAllocatedWaste;
|
|
420
|
+
if (worst) {
|
|
421
|
+
const turn = turnByNumber.get(worst.turn || 0);
|
|
422
|
+
aggregateProblem.examples.push({
|
|
423
|
+
session: row.dashboard.session.id,
|
|
424
|
+
repo: row.repo,
|
|
425
|
+
agentSource: "codex",
|
|
426
|
+
turn: worst.turn || 0,
|
|
427
|
+
tokens: Math.round(worst.allocatedWasteTokens || 0),
|
|
428
|
+
evidence: worst.itemReason || "allocated by vendored canonical scorer",
|
|
429
|
+
prompt: turn?.userMessage,
|
|
430
|
+
output: turn?.assistantOutput,
|
|
431
|
+
timestampMs: turn?.timestamp ? Date.parse(turn.timestamp) : null,
|
|
432
|
+
sessionStartedAt: row.dashboard.session.sourceSession?.startedAt ? Date.parse(row.dashboard.session.sourceSession.startedAt) : null,
|
|
433
|
+
turnInputTokens: worst.inputTokens || turn?.inputTokens || 0,
|
|
434
|
+
turnUsefulTokens: Math.round(worst.usefulTokens || turn?.usefulTokens || 0),
|
|
435
|
+
turnWasteTokens: Math.round(worst.wasteTokens || turn?.wasteTokens || 0),
|
|
436
|
+
});
|
|
437
|
+
aggregateProblem.examples.sort(compareProblemExamples);
|
|
438
|
+
aggregateProblem.examples.splice(1);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const problems = PROBLEM_IDS.map((id) => {
|
|
443
|
+
const meta = PROBLEM_META[id];
|
|
444
|
+
const values = problemRows.get(id);
|
|
445
|
+
return {
|
|
446
|
+
id,
|
|
447
|
+
label: meta.label,
|
|
448
|
+
bucket: meta.bucket,
|
|
449
|
+
category: meta.category,
|
|
450
|
+
confidence: meta.confidence,
|
|
451
|
+
count: values.count,
|
|
452
|
+
sessions: values.sessions,
|
|
453
|
+
allocatedWasteTokens: values.tokens,
|
|
454
|
+
qualifiedTokenPressure: values.pressure,
|
|
455
|
+
severity: totals.waste ? (values.tokens / totals.waste) * 100 : 0,
|
|
456
|
+
examples: values.examples,
|
|
457
|
+
};
|
|
458
|
+
}).sort((a, b) => b.allocatedWasteTokens - a.allocatedWasteTokens || a.id.localeCompare(b.id));
|
|
459
|
+
const repoRows = new Map();
|
|
460
|
+
for (const row of scored) {
|
|
461
|
+
const repo = repoRows.get(row.repo) || { sessions: 0, officialInputTokens: 0, wasteTokens: 0 };
|
|
462
|
+
repo.sessions += 1;
|
|
463
|
+
repo.officialInputTokens += row.dashboard.totals.officialInputTokens;
|
|
464
|
+
repo.wasteTokens += row.dashboard.totals.wasteTokens;
|
|
465
|
+
repoRows.set(row.repo, repo);
|
|
466
|
+
}
|
|
467
|
+
const episodes = [];
|
|
468
|
+
for (const row of scored) {
|
|
469
|
+
const byEpisode = new Map();
|
|
470
|
+
for (const turn of row.dashboard.timeline) {
|
|
471
|
+
const list = byEpisode.get(turn.episode) || [];
|
|
472
|
+
list.push(turn);
|
|
473
|
+
byEpisode.set(turn.episode, list);
|
|
474
|
+
}
|
|
475
|
+
for (const [episode, turns] of byEpisode) {
|
|
476
|
+
const byProblem = new Map();
|
|
477
|
+
for (const turn of turns)
|
|
478
|
+
for (const item of turn.allocatedItems || []) {
|
|
479
|
+
if (item.problemId)
|
|
480
|
+
byProblem.set(item.problemId, (byProblem.get(item.problemId) || 0) + (item.tokens || 0));
|
|
481
|
+
}
|
|
482
|
+
const dominant = [...byProblem.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
483
|
+
if (!dominant)
|
|
484
|
+
continue;
|
|
485
|
+
episodes.push({
|
|
486
|
+
id: `E${episode}`,
|
|
487
|
+
session: row.dashboard.session.id,
|
|
488
|
+
repo: row.repo,
|
|
489
|
+
turnStart: turns[0]?.turn || 0,
|
|
490
|
+
turnEnd: turns[turns.length - 1]?.turn || 0,
|
|
491
|
+
wasteTokens: turns.reduce((sum, turn) => sum + turn.wasteTokens, 0),
|
|
492
|
+
dominantProblemId: dominant,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const sessions = scored.map((row) => {
|
|
497
|
+
const top = [...row.dashboard.problemContributions].sort((a, b) => b.allocatedWasteTokens - a.allocatedWasteTokens)[0];
|
|
498
|
+
return {
|
|
499
|
+
session: row.dashboard.session.id,
|
|
500
|
+
repo: row.repo,
|
|
501
|
+
turns: row.dashboard.session.sourceSession?.turns ?? row.dashboard.timeline.length,
|
|
502
|
+
officialInputTokens: row.dashboard.totals.officialInputTokens,
|
|
503
|
+
wasteTokens: row.dashboard.totals.wasteTokens,
|
|
504
|
+
topProblemId: top?.allocatedWasteTokens > 0 ? top.id : null,
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
version: 1,
|
|
509
|
+
generatedFrom: metadata.generatedFrom,
|
|
510
|
+
methodology: {
|
|
511
|
+
name: "Canonical retained-window episode-outcome analysis",
|
|
512
|
+
status: "deterministic-plus-heuristic",
|
|
513
|
+
scoringMode: "episode-outcome",
|
|
514
|
+
note: metadata.note,
|
|
515
|
+
},
|
|
516
|
+
summary: {
|
|
517
|
+
sessionsAnalyzed: scored.length,
|
|
518
|
+
reposAnalyzed: repoRows.size,
|
|
519
|
+
turnsAnalyzed: totals.turns,
|
|
520
|
+
officialInputTokens: totals.input,
|
|
521
|
+
usefulTokens: totals.useful,
|
|
522
|
+
wasteTokens: totals.waste,
|
|
523
|
+
usefulPct: percentage(totals.useful, totals.input),
|
|
524
|
+
wastePct: percentage(totals.waste, totals.input),
|
|
525
|
+
attributedWasteTokens: Math.round(Math.max(0, totals.waste - totals.unattributed)),
|
|
526
|
+
unattributedWasteTokens: totals.unattributed,
|
|
527
|
+
rawUsefulTokens: rawBuckets.keep_oh + rawBuckets.keep_prod,
|
|
528
|
+
rawOutcomeResidueTokens: rawBuckets.opt_dup + rawBuckets.opt_refind + rawBuckets.opt_dead,
|
|
529
|
+
excludedUnlabeledTokens: totals.excluded,
|
|
530
|
+
},
|
|
531
|
+
buckets: { duplicate: totals.duplicate, refind: totals.refind, dead: totals.dead, unattributed: totals.unattributed },
|
|
532
|
+
rawBuckets,
|
|
533
|
+
problems,
|
|
534
|
+
sessions,
|
|
535
|
+
repos: [...repoRows.entries()].map(([repo, values]) => ({ repo, ...values })).sort((a, b) => b.officialInputTokens - a.officialInputTokens),
|
|
536
|
+
episodes,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function limitFiles(files, limit) {
|
|
540
|
+
return typeof limit === "number" ? files.slice(-Math.max(0, limit)) : files;
|
|
541
|
+
}
|
|
542
|
+
function defaultSources(opts) {
|
|
543
|
+
if (opts?.sessionPaths)
|
|
544
|
+
return ["codex"];
|
|
545
|
+
const sources = [];
|
|
546
|
+
if (opts?.codexSessionPaths)
|
|
547
|
+
sources.push("codex");
|
|
548
|
+
if (opts?.claudeSessionPaths)
|
|
549
|
+
sources.push("claude-code");
|
|
550
|
+
return sources.length ? sources : ["codex", "claude-code"];
|
|
551
|
+
}
|
|
552
|
+
function reportMetadata(source) {
|
|
553
|
+
const scorer = "vendored context-golden-standard scorer";
|
|
554
|
+
if (source === "codex") {
|
|
555
|
+
return {
|
|
556
|
+
generatedFrom: ["~/.codex/sessions", scorer],
|
|
557
|
+
note: "Codex JSONL sessions run through the vendored canonical optimizable-detail, P01-P13 detector, and waste-allocation pipeline without reimplementing its rules.",
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (source === "claude-code") {
|
|
561
|
+
return {
|
|
562
|
+
generatedFrom: ["~/.claude/projects", "Claude Code native useful/waste ledger"],
|
|
563
|
+
note: "Claude Code JSONL sessions are scored natively from requestIds and toolUseResult objects. Useful/waste is computed before P01-P13 attribution, then explainable waste is mapped onto the shared problem endpoint.",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
generatedFrom: ["~/.codex/sessions", "~/.claude/projects", scorer, "Claude Code native useful/waste ledger"],
|
|
568
|
+
note: "Combined Codex + Claude Code canonical rollup. Codex uses the vendored retained-window episode-outcome scorer; Claude Code uses a native JSONL/toolUseResult ledger that computes useful vs waste before mapping explainable waste to P01-P13.",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function combineReports(reports, metadata) {
|
|
572
|
+
const totals = reports.reduce((sum, report) => {
|
|
573
|
+
const summary = report.summary;
|
|
574
|
+
sum.sessions += summary.sessionsAnalyzed;
|
|
575
|
+
sum.repos += summary.reposAnalyzed;
|
|
576
|
+
sum.turns += summary.turnsAnalyzed;
|
|
577
|
+
sum.input += summary.officialInputTokens;
|
|
578
|
+
sum.useful += summary.usefulTokens;
|
|
579
|
+
sum.waste += summary.wasteTokens;
|
|
580
|
+
sum.attributed += summary.attributedWasteTokens;
|
|
581
|
+
sum.unattributed += summary.unattributedWasteTokens;
|
|
582
|
+
sum.rawUseful += summary.rawUsefulTokens;
|
|
583
|
+
sum.rawResidue += summary.rawOutcomeResidueTokens;
|
|
584
|
+
sum.excluded += summary.excludedUnlabeledTokens;
|
|
585
|
+
return sum;
|
|
586
|
+
}, { sessions: 0, repos: 0, turns: 0, input: 0, useful: 0, waste: 0, attributed: 0, unattributed: 0, rawUseful: 0, rawResidue: 0, excluded: 0 });
|
|
587
|
+
const buckets = reports.reduce((sum, report) => {
|
|
588
|
+
sum.duplicate += report.buckets.duplicate || 0;
|
|
589
|
+
sum.refind += report.buckets.refind || 0;
|
|
590
|
+
sum.dead += report.buckets.dead || 0;
|
|
591
|
+
sum.unattributed += report.buckets.unattributed || 0;
|
|
592
|
+
return sum;
|
|
593
|
+
}, { duplicate: 0, refind: 0, dead: 0, unattributed: 0 });
|
|
594
|
+
const rawBuckets = reports.reduce((sum, report) => {
|
|
595
|
+
sum.keep_oh += report.rawBuckets.keep_oh || 0;
|
|
596
|
+
sum.keep_prod += report.rawBuckets.keep_prod || 0;
|
|
597
|
+
sum.opt_dup += report.rawBuckets.opt_dup || 0;
|
|
598
|
+
sum.opt_refind += report.rawBuckets.opt_refind || 0;
|
|
599
|
+
sum.opt_dead += report.rawBuckets.opt_dead || 0;
|
|
600
|
+
return sum;
|
|
601
|
+
}, { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 });
|
|
602
|
+
const problemRows = new Map();
|
|
603
|
+
for (const id of PROBLEM_IDS) {
|
|
604
|
+
const meta = PROBLEM_META[id];
|
|
605
|
+
problemRows.set(id, {
|
|
606
|
+
id,
|
|
607
|
+
label: meta.label,
|
|
608
|
+
bucket: meta.bucket,
|
|
609
|
+
category: meta.category,
|
|
610
|
+
confidence: meta.confidence,
|
|
611
|
+
count: 0,
|
|
612
|
+
sessions: 0,
|
|
613
|
+
allocatedWasteTokens: 0,
|
|
614
|
+
qualifiedTokenPressure: 0,
|
|
615
|
+
severity: 0,
|
|
616
|
+
examples: [],
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
for (const report of reports) {
|
|
620
|
+
for (const problem of report.problems) {
|
|
621
|
+
const row = problemRows.get(problem.id);
|
|
622
|
+
if (!row)
|
|
623
|
+
continue;
|
|
624
|
+
row.count += problem.count;
|
|
625
|
+
row.sessions += problem.sessions;
|
|
626
|
+
row.allocatedWasteTokens += problem.allocatedWasteTokens;
|
|
627
|
+
row.qualifiedTokenPressure += problem.qualifiedTokenPressure;
|
|
628
|
+
row.examples.push(...problem.examples);
|
|
629
|
+
row.examples.sort(compareProblemExamples);
|
|
630
|
+
row.examples.splice(1);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const problems = [...problemRows.values()].map((problem) => ({
|
|
634
|
+
...problem,
|
|
635
|
+
severity: totals.waste ? (problem.allocatedWasteTokens / totals.waste) * 100 : 0,
|
|
636
|
+
})).sort((a, b) => b.allocatedWasteTokens - a.allocatedWasteTokens || a.id.localeCompare(b.id));
|
|
637
|
+
const diagnosticErrors = reports.flatMap((report) => report.diagnostics?.errors || []);
|
|
638
|
+
const repoRows = new Map();
|
|
639
|
+
for (const report of reports)
|
|
640
|
+
for (const repo of report.repos) {
|
|
641
|
+
const row = repoRows.get(repo.repo) || { sessions: 0, officialInputTokens: 0, wasteTokens: 0 };
|
|
642
|
+
row.sessions += repo.sessions;
|
|
643
|
+
row.officialInputTokens += repo.officialInputTokens;
|
|
644
|
+
row.wasteTokens += repo.wasteTokens;
|
|
645
|
+
repoRows.set(repo.repo, row);
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
version: 1,
|
|
649
|
+
generatedFrom: metadata.generatedFrom,
|
|
650
|
+
methodology: {
|
|
651
|
+
name: "Canonical retained-window episode-outcome analysis",
|
|
652
|
+
status: "deterministic-plus-heuristic",
|
|
653
|
+
scoringMode: "episode-outcome",
|
|
654
|
+
note: metadata.note,
|
|
655
|
+
},
|
|
656
|
+
summary: {
|
|
657
|
+
sessionsAnalyzed: totals.sessions,
|
|
658
|
+
reposAnalyzed: repoRows.size || totals.repos,
|
|
659
|
+
turnsAnalyzed: totals.turns,
|
|
660
|
+
officialInputTokens: totals.input,
|
|
661
|
+
usefulTokens: totals.useful,
|
|
662
|
+
wasteTokens: totals.waste,
|
|
663
|
+
usefulPct: percentage(totals.useful, totals.input),
|
|
664
|
+
wastePct: percentage(totals.waste, totals.input),
|
|
665
|
+
attributedWasteTokens: totals.attributed,
|
|
666
|
+
unattributedWasteTokens: totals.unattributed,
|
|
667
|
+
rawUsefulTokens: totals.rawUseful,
|
|
668
|
+
rawOutcomeResidueTokens: totals.rawResidue,
|
|
669
|
+
excludedUnlabeledTokens: totals.excluded,
|
|
670
|
+
},
|
|
671
|
+
buckets,
|
|
672
|
+
rawBuckets,
|
|
673
|
+
problems,
|
|
674
|
+
sessions: reports.flatMap((report) => report.sessions).sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
|
|
675
|
+
repos: [...repoRows.entries()].map(([repo, value]) => ({ repo, ...value })).sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
|
|
676
|
+
episodes: reports.flatMap((report) => report.episodes).sort((a, b) => b.wasteTokens - a.wasteTokens).slice(0, 8),
|
|
677
|
+
...(diagnosticErrors.length ? { diagnostics: { skippedSessions: diagnosticErrors.length, errors: diagnosticErrors } } : {}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function stripSourceReports(report) {
|
|
681
|
+
const { sourceReports: _sourceReports, ...sourceReport } = report;
|
|
682
|
+
return sourceReport;
|
|
683
|
+
}
|
|
684
|
+
function runVendored(script, args, env) {
|
|
685
|
+
// Async spawn (not spawnSync) so many sessions' scorers run concurrently across CPU cores. The
|
|
686
|
+
// scorers write their results to files (--out), so stdout is not a data channel; we only keep a
|
|
687
|
+
// tail of stderr for error reporting.
|
|
688
|
+
return new Promise((resolve, reject) => {
|
|
689
|
+
const child = spawn(process.execPath, [path.join(SCORER_DIR, script), ...args], {
|
|
690
|
+
env,
|
|
691
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
692
|
+
});
|
|
693
|
+
let stderr = "";
|
|
694
|
+
child.stderr?.on("data", (chunk) => { if (stderr.length < 8000)
|
|
695
|
+
stderr += String(chunk); });
|
|
696
|
+
child.on("error", reject);
|
|
697
|
+
child.on("close", (code) => {
|
|
698
|
+
if (code === 0)
|
|
699
|
+
return resolve();
|
|
700
|
+
const detail = stderr.trim().split("\n").slice(-3).join(" ") || "unknown scorer failure";
|
|
701
|
+
reject(new Error(`Vendored canonical scorer failed (${script}): ${detail}`));
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
function isUserInitiatedSession(file) {
|
|
706
|
+
try {
|
|
707
|
+
const firstLine = readFirstLine(file);
|
|
708
|
+
const parsed = JSON.parse(firstLine);
|
|
709
|
+
const source = parsed.payload?.source;
|
|
710
|
+
const spawned = Boolean(source && typeof source === "object" && "subagent" in source);
|
|
711
|
+
const hasParent = typeof parsed.payload?.parent_thread_id === "string" && parsed.payload.parent_thread_id.length > 0;
|
|
712
|
+
return !spawned && !hasParent;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function sessionIdFor(file) {
|
|
719
|
+
try {
|
|
720
|
+
const firstLine = readFirstLine(file);
|
|
721
|
+
const parsed = JSON.parse(firstLine);
|
|
722
|
+
if (typeof parsed.payload?.id === "string")
|
|
723
|
+
return parsed.payload.id;
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// Fall back to the rollout filename for older sessions.
|
|
727
|
+
}
|
|
728
|
+
const match = path.basename(file).match(/([0-9a-f]{8}-[0-9a-f-]{27,})\.jsonl$/i);
|
|
729
|
+
return match?.[1] || path.basename(file, ".jsonl").replace(/^rollout-/, "");
|
|
730
|
+
}
|
|
731
|
+
const MAX_FIRST_LINE_BYTES = 8 * 1024 * 1024;
|
|
732
|
+
/** Read only a bounded first JSONL record without materializing the rest of a rollout. */
|
|
733
|
+
function readFirstLine(file) {
|
|
734
|
+
const fd = fs.openSync(file, "r");
|
|
735
|
+
const chunks = [];
|
|
736
|
+
let total = 0;
|
|
737
|
+
try {
|
|
738
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
739
|
+
for (;;) {
|
|
740
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
741
|
+
if (bytes <= 0)
|
|
742
|
+
break;
|
|
743
|
+
const newline = buffer.subarray(0, bytes).indexOf(0x0a);
|
|
744
|
+
const length = newline >= 0 ? newline : bytes;
|
|
745
|
+
if (total + length > MAX_FIRST_LINE_BYTES) {
|
|
746
|
+
throw new Error(`First JSONL record exceeds ${MAX_FIRST_LINE_BYTES} bytes`);
|
|
747
|
+
}
|
|
748
|
+
chunks.push(Buffer.from(buffer.subarray(0, length)));
|
|
749
|
+
total += length;
|
|
750
|
+
if (newline >= 0)
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
fs.closeSync(fd);
|
|
756
|
+
}
|
|
757
|
+
return Buffer.concat(chunks, total).toString("utf8").replace(/\r$/, "");
|
|
758
|
+
}
|
|
759
|
+
function repoLabel(cwd) {
|
|
760
|
+
if (!cwd)
|
|
761
|
+
return "home";
|
|
762
|
+
const label = path.basename(cwd);
|
|
763
|
+
return label && label !== "." && label !== "/" ? label : "home";
|
|
764
|
+
}
|
|
765
|
+
function readJson(file) {
|
|
766
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
767
|
+
}
|
|
768
|
+
function isRecord(value) {
|
|
769
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
770
|
+
}
|
|
771
|
+
function recordValue(value) {
|
|
772
|
+
return isRecord(value) ? value : {};
|
|
773
|
+
}
|
|
774
|
+
function stringValue(value) {
|
|
775
|
+
return typeof value === "string" ? value : "";
|
|
776
|
+
}
|
|
777
|
+
function compareProblemExamples(left, right) {
|
|
778
|
+
return exampleDisplayRank(right) - exampleDisplayRank(left) || (right.tokens || 0) - (left.tokens || 0);
|
|
779
|
+
}
|
|
780
|
+
function exampleDisplayRank(example) {
|
|
781
|
+
const hasPrompt = Boolean(String(example.prompt || "").trim());
|
|
782
|
+
const hasOutput = Boolean(String(example.output || "").trim());
|
|
783
|
+
if (hasPrompt && hasOutput)
|
|
784
|
+
return 3;
|
|
785
|
+
if (hasPrompt)
|
|
786
|
+
return 2;
|
|
787
|
+
if (hasOutput)
|
|
788
|
+
return 1;
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
function percentage(value, total) {
|
|
792
|
+
return total > 0 ? (value / total) * 100 : 0;
|
|
793
|
+
}
|