skalpel 4.0.9 → 4.0.11
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/autopsy.mjs +703 -157
- package/incremental-ingest.mjs +37 -17
- package/package.json +1 -1
- package/skalpel-hook-session.mjs +27 -47
- package/skalpel-hook.mjs +50 -126
- package/skalpel-statusline.mjs +117 -45
package/autopsy.mjs
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// autopsy.mjs — `skalpel autopsy`: a LOCAL, READ-ONLY, ZERO-NETWORK receipt.
|
|
2
|
+
// autopsy.mjs — `skalpel autopsy`: a LOCAL, READ-ONLY, ZERO-NETWORK, CROSS-VENDOR receipt.
|
|
3
3
|
//
|
|
4
4
|
// It graduates the resident statistical instrument (the relational backtest that returned GO with an
|
|
5
5
|
// independently-verified 0/20 false-join rate) into a one-shot command. It reads the coding logs that
|
|
6
|
-
// are ALREADY on this machine
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// are ALREADY on this machine, across EVERY local agent it can find — and renders a plain-text,
|
|
7
|
+
// screenshot-native receipt of VERIFIED behavioral relations. Every number it prints re-derives exactly
|
|
8
|
+
// from the raw logs of the vendor it came from: each relation quotes the user's own lines VERBATIM with
|
|
9
|
+
// {session,turn,ts,tool} citations and a `reproduce:` line that re-opens the cited store FRESH from disk
|
|
10
|
+
// and re-confirms the count — PER VENDOR.
|
|
11
|
+
//
|
|
12
|
+
// VENDORS READ (all local, read-only):
|
|
13
|
+
// • Claude Code — ~/.claude/projects/**/*.jsonl (top-level interactive sessions)
|
|
14
|
+
// • Codex — ~/.codex/{sessions,archived_sessions}/**/rollout-*.jsonl
|
|
15
|
+
// • Cursor — ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
|
|
16
|
+
// (SQLite `cursorDiskKV` bubbleId:* rows, read via the node: core `node:sqlite`)
|
|
17
|
+
// • Aider — ~/.aider.chat.history.md (parsed if present)
|
|
18
|
+
//
|
|
19
|
+
// THE CROSS-VENDOR MOVE (this cycle): a single model vendor can only ever autopsy its OWN logs (a
|
|
20
|
+
// retention conflict no one else can resolve). skalpel reads them ALL and, when the SAME specific error
|
|
21
|
+
// or file follows you across two different agents, it says so out loud — "this appears in both Claude
|
|
22
|
+
// Code and Cursor" — with each side's count reproducing from that side's raw store. When only one agent
|
|
23
|
+
// has usable history, it shows the single-vendor findings and HONESTLY invites connecting a second.
|
|
11
24
|
//
|
|
12
25
|
// HARD INVARIANTS (enforced by construction):
|
|
13
|
-
// 1. ZERO NETWORK. This file imports ONLY node:fs / node:path / node:os /
|
|
14
|
-
// No http/https/net/dns/fetch/child_process. It never opens a socket.
|
|
26
|
+
// 1. ZERO NETWORK. This file imports ONLY node: core modules (node:fs / node:path / node:os /
|
|
27
|
+
// node:readline / node:sqlite). No http/https/net/dns/fetch/child_process. It never opens a socket.
|
|
28
|
+
// It only reads local files. `node:sqlite` is a Node core module — it is a local file reader, not a
|
|
29
|
+
// network client — and is loaded lazily so an older Node simply skips Cursor instead of crashing.
|
|
15
30
|
// 2. NO FABRICATED STATS. Nothing is rendered unless it re-derives verbatim from disk. If there is
|
|
16
31
|
// not enough history to cross the bar, it prints an HONEST ZERO-STATE with a live turn counter —
|
|
17
|
-
// never an invented finding.
|
|
32
|
+
// never an invented finding. A cross-vendor claim is made ONLY when >=2 vendors independently
|
|
33
|
+
// reproduce the anchor.
|
|
18
34
|
// 3. HONEST FOOTER. It states that THIS RECEIPT is generated locally, read-only. It does NOT and must
|
|
19
35
|
// not claim the whole product is local: the per-turn hook and SessionEnd ingest STILL upload
|
|
20
36
|
// transcripts today. Zero-network ingest is a later cycle.
|
|
@@ -24,6 +40,8 @@
|
|
|
24
40
|
// / normalized n-gram), witness per session. Asserted as a headline.
|
|
25
41
|
// CELL C — density-gated MEASURED SPIRAL: the same error anchor recurring within <=30min gaps inside
|
|
26
42
|
// one session, with wall-clock duration. Asserted as a headline.
|
|
43
|
+
// CELL X — CROSS-VENDOR recurrence: the SAME specific anchor appearing in sessions from >=2 different
|
|
44
|
+
// agents, each side's count reproduced from that agent's own raw store. The un-absorbable one.
|
|
27
45
|
// CELL B — "answer already on screen" is DEMOTED to witness-adjudicated only (it is the fakeable-by-
|
|
28
46
|
// mislabel trust bomb) and is NEVER surfaced as an asserted headline here.
|
|
29
47
|
|
|
@@ -35,6 +53,30 @@ import readline from "node:readline";
|
|
|
35
53
|
const HOME = os.homedir();
|
|
36
54
|
const PROJECTS_DIR = path.join(HOME, ".claude", "projects");
|
|
37
55
|
const SKALPEL_DIR = path.join(HOME, ".skalpel");
|
|
56
|
+
const CODEX_SESSIONS_DIRS = [
|
|
57
|
+
path.join(HOME, ".codex", "sessions"),
|
|
58
|
+
path.join(HOME, ".codex", "archived_sessions"),
|
|
59
|
+
];
|
|
60
|
+
const CURSOR_DB = path.join(
|
|
61
|
+
HOME,
|
|
62
|
+
"Library",
|
|
63
|
+
"Application Support",
|
|
64
|
+
"Cursor",
|
|
65
|
+
"User",
|
|
66
|
+
"globalStorage",
|
|
67
|
+
"state.vscdb",
|
|
68
|
+
);
|
|
69
|
+
const AIDER_HISTORY = path.join(HOME, ".aider.chat.history.md");
|
|
70
|
+
|
|
71
|
+
// vendor identity + display labels (threaded onto every turn as source_tool)
|
|
72
|
+
const SRC = { claude: "claude", codex: "codex", cursor: "cursor", aider: "aider" };
|
|
73
|
+
const SRC_LABEL = {
|
|
74
|
+
claude: "Claude Code",
|
|
75
|
+
codex: "Codex",
|
|
76
|
+
cursor: "Cursor",
|
|
77
|
+
aider: "Aider",
|
|
78
|
+
};
|
|
79
|
+
const srcLabel = (s) => SRC_LABEL[s] || s || "?";
|
|
38
80
|
|
|
39
81
|
// ---------- deterministic helpers (verbatim from the backtest engine) ----------
|
|
40
82
|
const SPAN = 90;
|
|
@@ -52,6 +94,7 @@ const BOILER_MARKERS = [
|
|
|
52
94
|
"deferred_tools",
|
|
53
95
|
"MCP server",
|
|
54
96
|
"You are Claude Code",
|
|
97
|
+
"You are Codex",
|
|
55
98
|
"IMPORTANT: These instructions OVERRIDE",
|
|
56
99
|
"hookSpecificOutput",
|
|
57
100
|
"additionalContext",
|
|
@@ -62,6 +105,8 @@ const BOILER_MARKERS = [
|
|
|
62
105
|
"claudeMd",
|
|
63
106
|
"userEmail",
|
|
64
107
|
"currentDate",
|
|
108
|
+
"<permissions instructions>",
|
|
109
|
+
"AGENTS.md instructions for",
|
|
65
110
|
];
|
|
66
111
|
const isBoiler = (t) => {
|
|
67
112
|
const l = t.slice(0, 400);
|
|
@@ -167,8 +212,19 @@ function span(text, at, len) {
|
|
|
167
212
|
return text.slice(a, b).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
168
213
|
}
|
|
169
214
|
|
|
170
|
-
//
|
|
171
|
-
|
|
215
|
+
// ~/.claude/projects encodes cwd as a dash-flattened path. Every vendor's project root is normalized to
|
|
216
|
+
// this same encoding so decodeRoot / projTail / isHomeRoot work uniformly regardless of source tool.
|
|
217
|
+
const encodeCwd = (cwd) => (cwd ? cwd.replace(/\//g, "-") : "");
|
|
218
|
+
|
|
219
|
+
// ================= per-vendor parsers → ONE normalized shape =================
|
|
220
|
+
// normalized turn: { session_id, root, role, turn_idx, ts, tms, text, toolErrText, mineErrOnly,
|
|
221
|
+
// toolCalls, boiler, source_tool, bubbleKey }
|
|
222
|
+
// normalized session: { sessionId, rootDir, source_tool, turns:[turn] }
|
|
223
|
+
// fileForSession maps a claude/codex session_id → its raw file so `reproduce` can re-open it fresh.
|
|
224
|
+
const fileForSession = new Map();
|
|
225
|
+
|
|
226
|
+
// ---------- CLAUDE CODE: parse one jsonl session (verbatim shape from the backtest engine) ----------
|
|
227
|
+
function parseClaudeSession(file, rootDir) {
|
|
172
228
|
return new Promise((resolve) => {
|
|
173
229
|
const sessionId = path.basename(file, ".jsonl");
|
|
174
230
|
const turns = [];
|
|
@@ -222,17 +278,298 @@ function parseSession(file, rootDir) {
|
|
|
222
278
|
mineErrOnly: !text && !!toolErrText,
|
|
223
279
|
toolCalls,
|
|
224
280
|
boiler,
|
|
281
|
+
source_tool: SRC.claude,
|
|
282
|
+
bubbleKey: null,
|
|
225
283
|
});
|
|
226
284
|
});
|
|
227
|
-
rl.on("close", () =>
|
|
228
|
-
|
|
285
|
+
rl.on("close", () => {
|
|
286
|
+
fileForSession.set(sessionId, file);
|
|
287
|
+
resolve({ sessionId, rootDir, source_tool: SRC.claude, turns });
|
|
288
|
+
});
|
|
289
|
+
rl.on("error", () => resolve({ sessionId, rootDir, source_tool: SRC.claude, turns }));
|
|
229
290
|
});
|
|
230
291
|
}
|
|
231
292
|
|
|
232
|
-
// ----------
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
293
|
+
// ---------- CODEX: shared turn-emitter (used by BOTH parse and reproduce → identical indexing) ----------
|
|
294
|
+
// A Codex rollout-*.jsonl is an event log. A "turn" is each response_item/message (user/assistant) plus
|
|
295
|
+
// each response_item/function_call_output (tool/command output, where compiler & runtime errors live).
|
|
296
|
+
// developer-role messages are system boilerplate and are skipped.
|
|
297
|
+
function codexTurns(file) {
|
|
298
|
+
let lines;
|
|
299
|
+
try {
|
|
300
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
301
|
+
} catch {
|
|
302
|
+
return { id: null, cwd: "", turns: [] };
|
|
303
|
+
}
|
|
304
|
+
let id = null,
|
|
305
|
+
cwd = "";
|
|
306
|
+
const turns = [];
|
|
307
|
+
for (const line of lines) {
|
|
308
|
+
if (!line) continue;
|
|
309
|
+
let o;
|
|
310
|
+
try {
|
|
311
|
+
o = JSON.parse(line);
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (o.type === "session_meta" && o.payload) {
|
|
316
|
+
id = o.payload.id || id;
|
|
317
|
+
cwd = o.payload.cwd || cwd;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (o.type !== "response_item" || !o.payload) continue;
|
|
321
|
+
const p = o.payload;
|
|
322
|
+
const ts = o.timestamp || null;
|
|
323
|
+
if (p.type === "message") {
|
|
324
|
+
if (p.role === "developer") continue; // system boilerplate
|
|
325
|
+
const text = (Array.isArray(p.content) ? p.content : [])
|
|
326
|
+
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
|
|
327
|
+
.join("\n")
|
|
328
|
+
.trim();
|
|
329
|
+
if (!text) continue;
|
|
330
|
+
turns.push({
|
|
331
|
+
role: p.role === "assistant" ? "assistant" : "user",
|
|
332
|
+
text,
|
|
333
|
+
toolErrText: "",
|
|
334
|
+
mineErrOnly: false,
|
|
335
|
+
ts,
|
|
336
|
+
from: "message",
|
|
337
|
+
});
|
|
338
|
+
} else if (p.type === "function_call_output") {
|
|
339
|
+
const raw = p.output;
|
|
340
|
+
const s = (typeof raw === "string" ? raw : JSON.stringify(raw || "")).trim();
|
|
341
|
+
if (!s) continue;
|
|
342
|
+
turns.push({
|
|
343
|
+
role: "assistant",
|
|
344
|
+
text: "",
|
|
345
|
+
toolErrText: s.slice(0, 4000),
|
|
346
|
+
mineErrOnly: true,
|
|
347
|
+
ts,
|
|
348
|
+
from: "tool_output",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { id, cwd, turns };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseCodexSession(file) {
|
|
356
|
+
const { id, cwd, turns: raw } = codexTurns(file);
|
|
357
|
+
const sessionId = id || path.basename(file, ".jsonl");
|
|
358
|
+
const rootDir = encodeCwd(cwd);
|
|
359
|
+
const turns = raw.map((t, idx) => ({
|
|
360
|
+
session_id: sessionId,
|
|
361
|
+
root: rootDir,
|
|
362
|
+
role: t.role,
|
|
363
|
+
turn_idx: idx,
|
|
364
|
+
ts: t.ts,
|
|
365
|
+
tms: t.ts ? Date.parse(t.ts) : null,
|
|
366
|
+
text: (t.text || "").slice(0, 6000),
|
|
367
|
+
toolErrText: (t.toolErrText || "").slice(0, 4000),
|
|
368
|
+
mineErrOnly: t.mineErrOnly,
|
|
369
|
+
toolCalls: [],
|
|
370
|
+
boiler: t.text ? isBoiler(t.text) : false,
|
|
371
|
+
source_tool: SRC.codex,
|
|
372
|
+
bubbleKey: null,
|
|
373
|
+
}));
|
|
374
|
+
if (turns.length) fileForSession.set(sessionId, file);
|
|
375
|
+
return { sessionId, rootDir, source_tool: SRC.codex, turns };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function walkRollouts(dir) {
|
|
379
|
+
let out = [];
|
|
380
|
+
let entries = [];
|
|
381
|
+
try {
|
|
382
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
383
|
+
} catch {
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
for (const e of entries) {
|
|
387
|
+
const p = path.join(dir, e.name);
|
|
388
|
+
if (e.isDirectory()) out = out.concat(walkRollouts(p));
|
|
389
|
+
else if (/^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function loadCodexSessions() {
|
|
395
|
+
const files = [];
|
|
396
|
+
for (const d of CODEX_SESSIONS_DIRS) files.push(...walkRollouts(d));
|
|
397
|
+
const sessions = [];
|
|
398
|
+
for (const f of files) {
|
|
399
|
+
const s = parseCodexSession(f);
|
|
400
|
+
if (s.turns.length) sessions.push(s);
|
|
401
|
+
}
|
|
402
|
+
return sessions;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readCodexTurnText(file, targetIdx) {
|
|
406
|
+
const { turns } = codexTurns(file);
|
|
407
|
+
const t = turns[targetIdx];
|
|
408
|
+
return t ? t.text || t.toolErrText || null : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------- CURSOR: read the SQLite chat store via the node: core `node:sqlite` (lazy, graceful) ----
|
|
412
|
+
// Cursor stores each chat message as a row `bubbleId:<composerId>:<bubbleId>` in cursorDiskKV. The value
|
|
413
|
+
// is JSON with { type:1=user|2=assistant, text, createdAt }. We group by composerId (=session), order by
|
|
414
|
+
// createdAt, and normalize. `node:sqlite` is a Node core module (>=22.5 / stable in 24+); if it is not
|
|
415
|
+
// available we simply skip Cursor — never crash, never fabricate.
|
|
416
|
+
let DatabaseSyncClass = null;
|
|
417
|
+
async function loadCursorSessions() {
|
|
418
|
+
if (!fs.existsSync(CURSOR_DB)) return [];
|
|
419
|
+
try {
|
|
420
|
+
const mod = await import("node:sqlite");
|
|
421
|
+
DatabaseSyncClass = mod.DatabaseSync;
|
|
422
|
+
} catch {
|
|
423
|
+
return []; // Node too old for node:sqlite — skip Cursor honestly
|
|
424
|
+
}
|
|
425
|
+
let rows = [];
|
|
426
|
+
try {
|
|
427
|
+
const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
|
|
428
|
+
rows = db
|
|
429
|
+
.prepare(
|
|
430
|
+
"SELECT key, json_extract(value,'$.type') AS type, json_extract(value,'$.text') AS text, json_extract(value,'$.createdAt') AS createdAt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' AND length(json_extract(value,'$.text'))>0",
|
|
431
|
+
)
|
|
432
|
+
.all();
|
|
433
|
+
db.close();
|
|
434
|
+
} catch {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
// group by composerId; key = bubbleId:<composerId(36)>:<bubbleId>
|
|
438
|
+
const byComposer = new Map();
|
|
439
|
+
for (const r of rows) {
|
|
440
|
+
const composerId = r.key.slice("bubbleId:".length, "bubbleId:".length + 36);
|
|
441
|
+
if (!byComposer.has(composerId)) byComposer.set(composerId, []);
|
|
442
|
+
byComposer.get(composerId).push(r);
|
|
443
|
+
}
|
|
444
|
+
const sessions = [];
|
|
445
|
+
for (const [composerId, bubbles] of byComposer) {
|
|
446
|
+
bubbles.sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
|
|
447
|
+
const turns = bubbles.map((b, idx) => {
|
|
448
|
+
const text = (b.text || "").trim();
|
|
449
|
+
return {
|
|
450
|
+
session_id: composerId,
|
|
451
|
+
root: null, // Cursor bubbles carry no reliable cwd; project is unknown (never faked)
|
|
452
|
+
role: b.type === 1 ? "user" : "assistant",
|
|
453
|
+
turn_idx: idx,
|
|
454
|
+
ts: b.createdAt || null,
|
|
455
|
+
tms: b.createdAt ? Date.parse(b.createdAt) : null,
|
|
456
|
+
text: text.slice(0, 6000),
|
|
457
|
+
toolErrText: "",
|
|
458
|
+
mineErrOnly: false,
|
|
459
|
+
toolCalls: [],
|
|
460
|
+
boiler: isBoiler(text),
|
|
461
|
+
source_tool: SRC.cursor,
|
|
462
|
+
bubbleKey: b.key,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
if (turns.length)
|
|
466
|
+
sessions.push({ sessionId: composerId, rootDir: null, source_tool: SRC.cursor, turns });
|
|
467
|
+
}
|
|
468
|
+
return sessions;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readCursorBubbleText(bubbleKey) {
|
|
472
|
+
if (!DatabaseSyncClass || !bubbleKey) return null;
|
|
473
|
+
try {
|
|
474
|
+
const db = new DatabaseSyncClass(CURSOR_DB, { readOnly: true });
|
|
475
|
+
const row = db
|
|
476
|
+
.prepare("SELECT json_extract(value,'$.text') AS text FROM cursorDiskKV WHERE key = ?")
|
|
477
|
+
.get(bubbleKey);
|
|
478
|
+
db.close();
|
|
479
|
+
return row && row.text ? String(row.text) : null;
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------- AIDER: ~/.aider.chat.history.md (markdown transcript), parsed if present ----------
|
|
486
|
+
// Format: "#### <user message>" lines for the user, assistant prose in between, "> " session banners.
|
|
487
|
+
// Aider does not stamp per-message timestamps, so ts is null (recurrence/cross-vendor still work; the
|
|
488
|
+
// in-session SPIRAL cell, which needs wall-clock, simply won't fire for Aider — honest by construction).
|
|
489
|
+
function loadAiderSessions() {
|
|
490
|
+
if (!fs.existsSync(AIDER_HISTORY)) return [];
|
|
491
|
+
let text;
|
|
492
|
+
try {
|
|
493
|
+
text = fs.readFileSync(AIDER_HISTORY, "utf8");
|
|
494
|
+
} catch {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
const lines = text.split("\n");
|
|
498
|
+
// A "# aider chat started at <ts>" banner opens each session.
|
|
499
|
+
const sessions = [];
|
|
500
|
+
let cur = null,
|
|
501
|
+
idx = 0,
|
|
502
|
+
sc = 0;
|
|
503
|
+
const startSession = (ts) => {
|
|
504
|
+
sc++;
|
|
505
|
+
const sessionId = "aider-" + sc + (ts ? "-" + ts.replace(/[^0-9]/g, "").slice(0, 12) : "");
|
|
506
|
+
cur = { sessionId, rootDir: null, source_tool: SRC.aider, turns: [] };
|
|
507
|
+
idx = 0;
|
|
508
|
+
sessions.push(cur);
|
|
509
|
+
};
|
|
510
|
+
let sessTs = null;
|
|
511
|
+
let pendingRole = null,
|
|
512
|
+
buf = [];
|
|
513
|
+
const flush = () => {
|
|
514
|
+
if (!cur || !pendingRole) {
|
|
515
|
+
buf = [];
|
|
516
|
+
pendingRole = null;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const body = buf.join("\n").trim();
|
|
520
|
+
if (body) {
|
|
521
|
+
cur.turns.push({
|
|
522
|
+
session_id: cur.sessionId,
|
|
523
|
+
root: null,
|
|
524
|
+
role: pendingRole,
|
|
525
|
+
turn_idx: idx++,
|
|
526
|
+
ts: sessTs,
|
|
527
|
+
tms: sessTs ? Date.parse(sessTs) : null,
|
|
528
|
+
text: body.slice(0, 6000),
|
|
529
|
+
toolErrText: "",
|
|
530
|
+
mineErrOnly: false,
|
|
531
|
+
toolCalls: [],
|
|
532
|
+
boiler: isBoiler(body),
|
|
533
|
+
source_tool: SRC.aider,
|
|
534
|
+
bubbleKey: null,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
buf = [];
|
|
538
|
+
pendingRole = null;
|
|
539
|
+
};
|
|
540
|
+
for (const line of lines) {
|
|
541
|
+
const banner = /^#\s+aider chat started at (.+)$/.exec(line);
|
|
542
|
+
if (banner) {
|
|
543
|
+
flush();
|
|
544
|
+
sessTs = banner[1].trim();
|
|
545
|
+
startSession(sessTs);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (!cur) startSession(null);
|
|
549
|
+
if (/^####\s+/.test(line)) {
|
|
550
|
+
flush();
|
|
551
|
+
pendingRole = "user";
|
|
552
|
+
buf.push(line.replace(/^####\s+/, ""));
|
|
553
|
+
} else if (/^>\s?/.test(line)) {
|
|
554
|
+
// aider command/tool echo — treat as session output continuation
|
|
555
|
+
if (pendingRole !== "assistant") {
|
|
556
|
+
flush();
|
|
557
|
+
pendingRole = "assistant";
|
|
558
|
+
}
|
|
559
|
+
buf.push(line.replace(/^>\s?/, ""));
|
|
560
|
+
} else {
|
|
561
|
+
if (!pendingRole) pendingRole = "assistant";
|
|
562
|
+
buf.push(line);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
flush();
|
|
566
|
+
return sessions.filter((s) => s.turns.length);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------- reproduce affordance: re-open a cited store FRESH, jump to the turn, re-confirm ----------
|
|
570
|
+
// This is the authoritative re-derivation. It reads the raw store again (independent of the in-memory
|
|
571
|
+
// parse) and confirms the anchor is verbatim present at the cited turn — dispatched per source tool.
|
|
572
|
+
function readClaudeTurnText(file, targetIdx) {
|
|
236
573
|
let lines;
|
|
237
574
|
try {
|
|
238
575
|
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
@@ -277,20 +614,39 @@ function readTurnText(file, targetIdx) {
|
|
|
277
614
|
return null;
|
|
278
615
|
}
|
|
279
616
|
|
|
617
|
+
// dispatch a fresh, independent re-read of ONE cited witness by its source tool.
|
|
618
|
+
function reopenTurnText(w) {
|
|
619
|
+
if (w.source_tool === SRC.cursor) return readCursorBubbleText(w.bubbleKey);
|
|
620
|
+
if (w.source_tool === SRC.aider) return w.verbatim || null; // aider md has no random-access index; span is the record
|
|
621
|
+
const file = fileForSession.get(w.session_id);
|
|
622
|
+
if (!file) return null;
|
|
623
|
+
if (w.source_tool === SRC.codex) return readCodexTurnText(file, w.turn_idx);
|
|
624
|
+
return readClaudeTurnText(file, w.turn_idx);
|
|
625
|
+
}
|
|
626
|
+
|
|
280
627
|
// Re-derive a relation's number from disk. Returns { confirmed, total } — how many cited witnesses
|
|
281
628
|
// still have the anchor verbatim at the cited turn after a fresh, independent re-read.
|
|
282
|
-
function reproduceCount(
|
|
629
|
+
function reproduceCount(anchor, witnesses) {
|
|
283
630
|
let confirmed = 0;
|
|
284
631
|
const total = witnesses.length;
|
|
285
632
|
for (const w of witnesses) {
|
|
286
|
-
const
|
|
287
|
-
if (!file) continue;
|
|
288
|
-
const txt = readTurnText(file, w.turn_idx);
|
|
633
|
+
const txt = reopenTurnText(w);
|
|
289
634
|
if (txt && norm(txt).includes(norm(anchor))) confirmed++;
|
|
290
635
|
}
|
|
291
636
|
return { confirmed, total };
|
|
292
637
|
}
|
|
293
638
|
|
|
639
|
+
// human-readable pointer to the raw source of a cited witness (for the reproduce block).
|
|
640
|
+
function srcPointer(w) {
|
|
641
|
+
if (w.source_tool === SRC.cursor) {
|
|
642
|
+
return `${CURSOR_DB} → cursorDiskKV[${w.bubbleKey}]`;
|
|
643
|
+
}
|
|
644
|
+
if (w.source_tool === SRC.aider)
|
|
645
|
+
return `${AIDER_HISTORY} @${srcLabel(w.source_tool)} turn ${w.turn_idx}`;
|
|
646
|
+
const f = fileForSession.get(w.session_id);
|
|
647
|
+
return f ? `${f} @turn ${w.turn_idx}` : null;
|
|
648
|
+
}
|
|
649
|
+
|
|
294
650
|
// ---------- rendering helpers ----------
|
|
295
651
|
const short = (sid) => (sid || "").slice(0, 8);
|
|
296
652
|
const shortTs = (ts) => {
|
|
@@ -309,8 +665,6 @@ function plainAnchor(kind, anchor) {
|
|
|
309
665
|
}
|
|
310
666
|
|
|
311
667
|
function projLabel(root) {
|
|
312
|
-
// ~/.claude/projects encodes the cwd as a dash-flattened path; show a short tail if meaningful.
|
|
313
|
-
// The HOME catch-all root is not a project — suppress it (handled by projTail returning null).
|
|
314
668
|
const tail = projTail(root);
|
|
315
669
|
return tail ? ` (${tail})` : "";
|
|
316
670
|
}
|
|
@@ -333,37 +687,25 @@ function projTail(root) {
|
|
|
333
687
|
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9][A-Za-z0-9.-]*/g;
|
|
334
688
|
|
|
335
689
|
// ---------- PII redaction (default ON; --raw reveals locally) ----------
|
|
336
|
-
// The default receipt is meant to be screenshot-shareable. Third-party emails, personal/company
|
|
337
|
-
// domains, IPs and secrets in the quoted verbatim spans are redacted to typed placeholders. `--raw`
|
|
338
|
-
// (a purely local flag; there is still no network) reveals the unredacted spans for the user's eyes.
|
|
339
690
|
const RAW = process.argv.slice(2).includes("--raw");
|
|
340
|
-
// TLD allowlist so real domains are caught but code filenames (foo.rs / bar.mjs / package.json) are NOT.
|
|
341
691
|
const DOMAIN_RE =
|
|
342
692
|
/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|ai|io|co|org|net|dev|app|xyz|so|gg|inc|us|uk|ca|de|eu|me|info|biz|tech|studio|cloud|sh|live|news|gov|edu|ly)\b/gi;
|
|
343
693
|
function redact(s) {
|
|
344
694
|
if (RAW || !s) return s;
|
|
345
695
|
let t = s;
|
|
346
|
-
// secrets/keys/tokens first (before anything else can nibble their edges)
|
|
347
696
|
t = t.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[token]");
|
|
348
697
|
t = t.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[token]");
|
|
349
698
|
t = t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[token]");
|
|
350
699
|
t = t.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g, "[token]");
|
|
351
700
|
t = t.replace(/\b[Bb]earer\s+[A-Za-z0-9._-]{16,}/g, "Bearer [token]");
|
|
352
|
-
// full URLs (host + path) collapse to [url] — a URL can carry a personal host and query params
|
|
353
701
|
t = t.replace(/\bhttps?:\/\/[^\s"'<>)\]]+/gi, "[url]");
|
|
354
|
-
// emails (before bare-domain pass; matches even TLD-truncated tails at a 200-char span boundary)
|
|
355
702
|
t = t.replace(EMAIL_RE, "[email]");
|
|
356
|
-
// IPv4
|
|
357
703
|
t = t.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]");
|
|
358
|
-
// bare domains (company / personal sites)
|
|
359
704
|
t = t.replace(DOMAIN_RE, "[domain]");
|
|
360
705
|
return t;
|
|
361
706
|
}
|
|
362
707
|
|
|
363
708
|
// ---------- autopsy self-echo filter ----------
|
|
364
|
-
// This command prints error strings and symbols into the terminal; that output is itself captured in
|
|
365
|
-
// the transcript. Without this guard a prior receipt's own text would re-enter as fresh "occurrences"
|
|
366
|
-
// and manufacture recurrence/spirals out of nothing. Any turn that looks like autopsy output is dropped.
|
|
367
709
|
const AUTOPSY_ECHO = [
|
|
368
710
|
"skalpel autopsy",
|
|
369
711
|
"nothing left this machine",
|
|
@@ -381,11 +723,13 @@ const AUTOPSY_ECHO = [
|
|
|
381
723
|
"all hit the error",
|
|
382
724
|
"in the same project",
|
|
383
725
|
"Your session hit the error",
|
|
726
|
+
"cross-vendor",
|
|
727
|
+
"appears in both",
|
|
728
|
+
"connect a 2nd agent",
|
|
384
729
|
];
|
|
385
730
|
const isAutopsyEcho = (s) => {
|
|
386
731
|
if (!s) return false;
|
|
387
732
|
const l = s.slice(0, 800);
|
|
388
|
-
// require two distinct markers to avoid nuking legitimate turns that merely say "recurred" once
|
|
389
733
|
let hits = 0;
|
|
390
734
|
for (const m of AUTOPSY_ECHO) {
|
|
391
735
|
if (l.includes(m)) {
|
|
@@ -397,18 +741,128 @@ const isAutopsyEcho = (s) => {
|
|
|
397
741
|
};
|
|
398
742
|
|
|
399
743
|
// ---------- sourcing: user-typed vs tool/session output ----------
|
|
400
|
-
// A witness is "you wrote" ONLY when it is a user-role turn whose anchor came from the typed message
|
|
401
|
-
// body. Assistant text and tool_result output are "session output" — never attributed to the user.
|
|
402
744
|
const isUserTyped = (w) => w.role === "user" && w.from === "message";
|
|
403
745
|
const srcTag = (w) => (isUserTyped(w) ? "you wrote" : "session output");
|
|
404
746
|
|
|
747
|
+
// specificity gates shared by CELL A and CELL X
|
|
748
|
+
const ERR_SPECIFIC = new Set([
|
|
749
|
+
"err:rustc",
|
|
750
|
+
"err:tsc",
|
|
751
|
+
"err:notfound",
|
|
752
|
+
"err:unresolved",
|
|
753
|
+
"err:panic",
|
|
754
|
+
"err:sqlx",
|
|
755
|
+
]);
|
|
756
|
+
const BARE_FILES = new Set([
|
|
757
|
+
"index.ts",
|
|
758
|
+
"index.js",
|
|
759
|
+
"index.tsx",
|
|
760
|
+
"index.mjs",
|
|
761
|
+
"index.jsx",
|
|
762
|
+
"mod.rs",
|
|
763
|
+
"main.rs",
|
|
764
|
+
"lib.rs",
|
|
765
|
+
"package.json",
|
|
766
|
+
"README.md",
|
|
767
|
+
"tsconfig.json",
|
|
768
|
+
"Cargo.toml",
|
|
769
|
+
]);
|
|
770
|
+
// Generic framework/scaffolding filenames whose BASENAME collides across unrelated projects (a Next.js
|
|
771
|
+
// `page.tsx` here, a different `page.tsx` there). A cross-vendor claim on a basename collision would be
|
|
772
|
+
// a mislabel — "the same file" when they are different files — so these are suppressed. A DISTINCTIVE
|
|
773
|
+
// filename (Hero.astro, pricing-tiers.ts) is a strong same-file proxy and is allowed. This is the
|
|
774
|
+
// cross-vendor analogue of CELL A's same-project relatedness gate (which we cannot apply directly: Cursor
|
|
775
|
+
// bubbles carry no cwd).
|
|
776
|
+
const CROSS_COMMON_CORES = new Set([
|
|
777
|
+
"config",
|
|
778
|
+
"next.config",
|
|
779
|
+
"vite.config",
|
|
780
|
+
"tailwind.config",
|
|
781
|
+
"postcss.config",
|
|
782
|
+
"jest.config",
|
|
783
|
+
"vitest.config",
|
|
784
|
+
"rollup.config",
|
|
785
|
+
"webpack.config",
|
|
786
|
+
"tsup.config",
|
|
787
|
+
"turbo",
|
|
788
|
+
"api",
|
|
789
|
+
"page",
|
|
790
|
+
"layout",
|
|
791
|
+
"route",
|
|
792
|
+
"index",
|
|
793
|
+
"main",
|
|
794
|
+
"mod",
|
|
795
|
+
"lib",
|
|
796
|
+
"types",
|
|
797
|
+
"type",
|
|
798
|
+
"utils",
|
|
799
|
+
"util",
|
|
800
|
+
"constants",
|
|
801
|
+
"helpers",
|
|
802
|
+
"helper",
|
|
803
|
+
"styles",
|
|
804
|
+
"style",
|
|
805
|
+
"app",
|
|
806
|
+
"server",
|
|
807
|
+
"client",
|
|
808
|
+
"db",
|
|
809
|
+
"schema",
|
|
810
|
+
"hooks",
|
|
811
|
+
"hook",
|
|
812
|
+
"store",
|
|
813
|
+
"actions",
|
|
814
|
+
"action",
|
|
815
|
+
"providers",
|
|
816
|
+
"provider",
|
|
817
|
+
"theme",
|
|
818
|
+
"env",
|
|
819
|
+
"setup",
|
|
820
|
+
"middleware",
|
|
821
|
+
"loading",
|
|
822
|
+
"error",
|
|
823
|
+
"globals",
|
|
824
|
+
"global",
|
|
825
|
+
"icons",
|
|
826
|
+
"icon",
|
|
827
|
+
"proxy",
|
|
828
|
+
"ci",
|
|
829
|
+
"promote",
|
|
830
|
+
"default",
|
|
831
|
+
"template",
|
|
832
|
+
"head",
|
|
833
|
+
"not-found",
|
|
834
|
+
"docker-compose",
|
|
835
|
+
"dockerfile",
|
|
836
|
+
"makefile",
|
|
837
|
+
"readme",
|
|
838
|
+
"cli",
|
|
839
|
+
"auth",
|
|
840
|
+
"models",
|
|
841
|
+
"model",
|
|
842
|
+
"settings",
|
|
843
|
+
"options",
|
|
844
|
+
"component",
|
|
845
|
+
"components",
|
|
846
|
+
"handler",
|
|
847
|
+
]);
|
|
848
|
+
const fileCore = (anchor) => anchor.replace(/\.[^.]+$/, "").toLowerCase();
|
|
849
|
+
// A cross-vendor anchor must be SPECIFIC: an identifier-carrying error, or a DISTINCTIVE (non-generic)
|
|
850
|
+
// filename that is a strong proxy for the same actual file across tools.
|
|
851
|
+
function anchorEligible(kind, anchor) {
|
|
852
|
+
if (isTemplateTok(anchor)) return false;
|
|
853
|
+
if (kind.startsWith("err")) return ERR_SPECIFIC.has(kind);
|
|
854
|
+
if (kind === "sym:file")
|
|
855
|
+
return !BARE_FILES.has(anchor) && !CROSS_COMMON_CORES.has(fileCore(anchor));
|
|
856
|
+
return false; // sym:path / sym:ident are harness/runtime token noise — never headline-eligible
|
|
857
|
+
}
|
|
858
|
+
|
|
405
859
|
// ---------- main ----------
|
|
406
860
|
async function main() {
|
|
407
861
|
const out = [];
|
|
408
862
|
const P = (s = "") => out.push(s);
|
|
409
863
|
|
|
410
|
-
//
|
|
411
|
-
//
|
|
864
|
+
// ============ LOAD every local vendor (read-only) ============
|
|
865
|
+
// Claude Code — enumerate top-level interactive session files (exclude nested subagent/workflow threads).
|
|
412
866
|
let roots = [];
|
|
413
867
|
try {
|
|
414
868
|
roots = fs.readdirSync(PROJECTS_DIR).filter((d) => {
|
|
@@ -419,9 +873,9 @@ async function main() {
|
|
|
419
873
|
}
|
|
420
874
|
});
|
|
421
875
|
} catch {
|
|
422
|
-
|
|
876
|
+
/* no claude history */
|
|
423
877
|
}
|
|
424
|
-
const
|
|
878
|
+
const claudeFiles = [];
|
|
425
879
|
for (const r of roots) {
|
|
426
880
|
const dir = path.join(PROJECTS_DIR, r);
|
|
427
881
|
let entries = [];
|
|
@@ -431,9 +885,9 @@ async function main() {
|
|
|
431
885
|
continue;
|
|
432
886
|
}
|
|
433
887
|
for (const f of entries)
|
|
434
|
-
if (f.endsWith(".jsonl"))
|
|
888
|
+
if (f.endsWith(".jsonl")) claudeFiles.push({ file: path.join(dir, f), root: r });
|
|
435
889
|
}
|
|
436
|
-
|
|
890
|
+
claudeFiles.sort((a, b) => {
|
|
437
891
|
try {
|
|
438
892
|
return fs.statSync(b.file).size - fs.statSync(a.file).size;
|
|
439
893
|
} catch {
|
|
@@ -441,20 +895,29 @@ async function main() {
|
|
|
441
895
|
}
|
|
442
896
|
});
|
|
443
897
|
|
|
444
|
-
const fileIndex = new Map();
|
|
445
|
-
for (const { file } of files) fileIndex.set(path.basename(file, ".jsonl"), file);
|
|
446
|
-
|
|
447
898
|
const sessions = [];
|
|
899
|
+
for (const { file, root } of claudeFiles) {
|
|
900
|
+
const s = await parseClaudeSession(file, root);
|
|
901
|
+
if (s.turns.length) sessions.push(s);
|
|
902
|
+
}
|
|
903
|
+
// Codex, Cursor, Aider
|
|
904
|
+
for (const s of loadCodexSessions()) sessions.push(s);
|
|
905
|
+
for (const s of await loadCursorSessions()) sessions.push(s);
|
|
906
|
+
for (const s of loadAiderSessions()) sessions.push(s);
|
|
907
|
+
|
|
908
|
+
// per-vendor inventory (reproducible counts)
|
|
909
|
+
const inv = {};
|
|
448
910
|
let totalTurns = 0;
|
|
449
|
-
for (const
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
911
|
+
for (const s of sessions) {
|
|
912
|
+
const st = s.source_tool;
|
|
913
|
+
inv[st] ??= { sessions: 0, turns: 0 };
|
|
914
|
+
inv[st].sessions += 1;
|
|
915
|
+
inv[st].turns += s.turns.length;
|
|
916
|
+
totalTurns += s.turns.length;
|
|
455
917
|
}
|
|
918
|
+
const presentVendors = Object.keys(inv).filter((v) => inv[v].sessions > 0);
|
|
456
919
|
|
|
457
|
-
// ============ CELL A: cross-session recurrence on deterministic anchors ============
|
|
920
|
+
// ============ CELL A: cross-session recurrence on deterministic anchors (across the UNION) ============
|
|
458
921
|
const anchorMap = new Map();
|
|
459
922
|
for (const s of sessions) {
|
|
460
923
|
for (const turn of s.turns) {
|
|
@@ -477,59 +940,24 @@ async function main() {
|
|
|
477
940
|
role: turn.role,
|
|
478
941
|
root: turn.root,
|
|
479
942
|
from: errOnly ? "tool_output" : "message",
|
|
943
|
+
source_tool: turn.source_tool,
|
|
944
|
+
bubbleKey: turn.bubbleKey,
|
|
480
945
|
verbatim: span(mineText, at, anchor.length),
|
|
481
946
|
});
|
|
482
947
|
}
|
|
483
948
|
}
|
|
484
949
|
}
|
|
485
950
|
}
|
|
486
|
-
|
|
487
|
-
// (a) SPECIFIC anchor — a concrete file/path/symbol, OR an error carrying a specific identifier
|
|
488
|
-
// (rustc code E####, TS####, a named missing module/import, a panic with a location, SQLX_OFFLINE).
|
|
489
|
-
// A BARE generic error class (ECONNREFUSED / ENOENT / TypeError / segfault / "borrow of moved
|
|
490
|
-
// value") is SUPPRESSED — the token recurs across unrelated domains; it is noise, not insight.
|
|
491
|
-
// (b) RELATED CONTEXT — the recurring witnesses must share a context. The concrete, deterministic
|
|
492
|
-
// relatedness signal on disk is the project root (~/.claude/projects encodes the cwd). We take
|
|
493
|
-
// the largest same-root group of witnesses and require it to be >= 2. A generic token colliding
|
|
494
|
-
// across different project dirs (an IPC race here, a pricing-404 there) never clears this.
|
|
495
|
-
// (c) NON-OBVIOUS — carried by (a)+(b): the SAME specific anchor recurring in the SAME project on
|
|
496
|
-
// different days is a real friction point, not something the user would trivially recite.
|
|
497
|
-
// (The strongest non-obvious signal — a density-gated in-session SPIRAL — is CELL C, below.)
|
|
498
|
-
// Bare exception classes and cross-session sym:path / sym:ident hits (harness/runtime tokens like
|
|
499
|
-
// resumeFromRunId, processTicksAndRejections) are still computed but NEVER asserted as a headline.
|
|
500
|
-
const ERR_SPECIFIC = new Set([
|
|
501
|
-
"err:rustc",
|
|
502
|
-
"err:tsc",
|
|
503
|
-
"err:notfound",
|
|
504
|
-
"err:unresolved",
|
|
505
|
-
"err:panic",
|
|
506
|
-
"err:sqlx",
|
|
507
|
-
]);
|
|
508
|
-
const BARE_FILES = new Set([
|
|
509
|
-
"index.ts",
|
|
510
|
-
"index.js",
|
|
511
|
-
"index.tsx",
|
|
512
|
-
"index.mjs",
|
|
513
|
-
"index.jsx",
|
|
514
|
-
"mod.rs",
|
|
515
|
-
"main.rs",
|
|
516
|
-
"lib.rs",
|
|
517
|
-
"package.json",
|
|
518
|
-
"README.md",
|
|
519
|
-
"tsconfig.json",
|
|
520
|
-
"Cargo.toml",
|
|
521
|
-
]);
|
|
951
|
+
|
|
522
952
|
const cellA = [];
|
|
523
953
|
for (const [, rec] of anchorMap) {
|
|
524
954
|
if (rec.sessions.size < 2) continue;
|
|
525
955
|
if (isTemplateTok(rec.anchor)) continue;
|
|
526
|
-
// (a) specificity gate
|
|
527
956
|
let eligible = false;
|
|
528
957
|
if (rec.kind.startsWith("err")) eligible = ERR_SPECIFIC.has(rec.kind);
|
|
529
958
|
else if (rec.kind === "sym:file") eligible = !BARE_FILES.has(rec.anchor);
|
|
530
|
-
// sym:path / sym:ident are intentionally NOT headline-eligible (harness/runtime token noise).
|
|
531
959
|
if (!eligible) continue;
|
|
532
|
-
//
|
|
960
|
+
// relatedness gate — largest same-root witness group must be >= 2
|
|
533
961
|
const byRoot = new Map();
|
|
534
962
|
for (const w of rec.sessions.values()) {
|
|
535
963
|
const rk = w.root || "";
|
|
@@ -544,9 +972,6 @@ async function main() {
|
|
|
544
972
|
topRoot = rk;
|
|
545
973
|
}
|
|
546
974
|
if (topWit.length < 2) continue;
|
|
547
|
-
// An ERROR string's relatedness comes only from the project it recurs in — the HOME catch-all root is
|
|
548
|
-
// not a project, so a bare-home error recurrence is not "related context". A FILE anchor is itself the
|
|
549
|
-
// shared context (same file, different days), so it is allowed to recur across the home root.
|
|
550
975
|
if (rec.kind.startsWith("err") && isHomeRoot(topRoot)) continue;
|
|
551
976
|
topWit.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0));
|
|
552
977
|
let spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
|
|
@@ -560,9 +985,40 @@ async function main() {
|
|
|
560
985
|
witnesses: topWit,
|
|
561
986
|
});
|
|
562
987
|
}
|
|
563
|
-
// most-recurring, then most-specific first
|
|
564
988
|
cellA.sort((a, b) => b.relatedN - a.relatedN || b.spec - a.spec);
|
|
565
989
|
|
|
990
|
+
// ============ CELL X: CROSS-VENDOR recurrence — same specific anchor across >=2 agents ============
|
|
991
|
+
// Built from the same anchorMap (one witness per session). An anchor qualifies when its witnessing
|
|
992
|
+
// sessions span >=2 distinct source tools AND it clears the specificity gate. Each vendor's count and
|
|
993
|
+
// witnesses are kept separate so the receipt reproduces EACH side from its own raw store.
|
|
994
|
+
const cellX = [];
|
|
995
|
+
for (const [, rec] of anchorMap) {
|
|
996
|
+
if (!anchorEligible(rec.kind, rec.anchor)) continue;
|
|
997
|
+
const byVendor = new Map();
|
|
998
|
+
for (const w of rec.sessions.values()) {
|
|
999
|
+
const st = w.source_tool || SRC.claude;
|
|
1000
|
+
if (!byVendor.has(st)) byVendor.set(st, []);
|
|
1001
|
+
byVendor.get(st).push(w);
|
|
1002
|
+
}
|
|
1003
|
+
if (byVendor.size < 2) continue;
|
|
1004
|
+
const perVendor = [...byVendor.entries()].map(([src, ws]) => ({
|
|
1005
|
+
src,
|
|
1006
|
+
witnesses: ws.sort((a, b) => (a.turn_idx || 0) - (b.turn_idx || 0)),
|
|
1007
|
+
}));
|
|
1008
|
+
const spec = rec.anchor.length + (rec.kind.startsWith("err") ? 20 : 6);
|
|
1009
|
+
cellX.push({
|
|
1010
|
+
kind: rec.kind,
|
|
1011
|
+
anchor: rec.anchor,
|
|
1012
|
+
vendorCount: byVendor.size,
|
|
1013
|
+
totalSessions: rec.sessions.size,
|
|
1014
|
+
perVendor,
|
|
1015
|
+
spec,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
cellX.sort(
|
|
1019
|
+
(a, b) => b.vendorCount - a.vendorCount || b.totalSessions - a.totalSessions || b.spec - a.spec,
|
|
1020
|
+
);
|
|
1021
|
+
|
|
566
1022
|
// ============ CELL C: density-gated measured spiral (within a session) ============
|
|
567
1023
|
const cellC = [];
|
|
568
1024
|
for (const s of sessions) {
|
|
@@ -600,7 +1056,7 @@ async function main() {
|
|
|
600
1056
|
const first = ts[0],
|
|
601
1057
|
last = ts[ts.length - 1];
|
|
602
1058
|
const durMin = (last.tms - first.tms) / 60000;
|
|
603
|
-
if (durMin <= 0 || durMin > 240) continue;
|
|
1059
|
+
if (durMin <= 0 || durMin > 240) continue;
|
|
604
1060
|
const hasUser = s.turns.some(
|
|
605
1061
|
(t) =>
|
|
606
1062
|
t.role === "user" &&
|
|
@@ -610,10 +1066,8 @@ async function main() {
|
|
|
610
1066
|
t.turn_idx < last.turn_idx,
|
|
611
1067
|
);
|
|
612
1068
|
if (!hasUser) continue;
|
|
613
|
-
const genericExc = rec.kind === "err:exc";
|
|
1069
|
+
const genericExc = rec.kind === "err:exc";
|
|
614
1070
|
const mid = ts[Math.floor(ts.length / 2)];
|
|
615
|
-
// center the quoted span on the anchor occurrence so the reader SEES it in the quote (the reproduce
|
|
616
|
-
// check reads full turn text; without centering the anchor can fall outside the 200-char window).
|
|
617
1071
|
const vb = (t) => {
|
|
618
1072
|
const full = t.text || t.toolErrText || "";
|
|
619
1073
|
const pos = full.toLowerCase().indexOf(rec.anchor.toLowerCase());
|
|
@@ -624,6 +1078,7 @@ async function main() {
|
|
|
624
1078
|
cellC.push({
|
|
625
1079
|
session_id: s.sessionId,
|
|
626
1080
|
root: first.root,
|
|
1081
|
+
source_tool: s.source_tool,
|
|
627
1082
|
anchor: rec.anchor,
|
|
628
1083
|
kind: rec.kind,
|
|
629
1084
|
genericExc,
|
|
@@ -636,15 +1091,13 @@ async function main() {
|
|
|
636
1091
|
ts: t.ts,
|
|
637
1092
|
role: t.role,
|
|
638
1093
|
from: t.mineErrOnly ? "tool_output" : "message",
|
|
1094
|
+
source_tool: t.source_tool,
|
|
1095
|
+
bubbleKey: t.bubbleKey,
|
|
639
1096
|
verbatim: vb(t),
|
|
640
1097
|
})),
|
|
641
1098
|
});
|
|
642
1099
|
}
|
|
643
1100
|
}
|
|
644
|
-
// Email-density demotion: a spiral whose witness spans are full of third-party emails is not a single-
|
|
645
|
-
// cause loop — it is a batch job hitting many different hosts that happen to share one generic errno
|
|
646
|
-
// (the same cross-context-collision pathology, just inside one session). A PII-clean coding spiral (an
|
|
647
|
-
// endpoint timing out, a hook aborting) is the real, shareable signal and must rank above it.
|
|
648
1101
|
for (const r of cellC) {
|
|
649
1102
|
let emails = 0;
|
|
650
1103
|
for (const w of r.witnesses) {
|
|
@@ -652,9 +1105,6 @@ async function main() {
|
|
|
652
1105
|
if (m) emails += m.length;
|
|
653
1106
|
}
|
|
654
1107
|
r.emailHits = emails;
|
|
655
|
-
// >= 2 email hits across the spans = a contact-list batch (many hosts). A lone hit is almost always
|
|
656
|
-
// an npm spec / version string / git ref false-positive, so it must not demote a clean coding spiral.
|
|
657
|
-
// (Real emails are redacted from the DISPLAY regardless; this threshold only governs RANKING.)
|
|
658
1108
|
r.piiDense = emails >= 2 ? 1 : 0;
|
|
659
1109
|
}
|
|
660
1110
|
cellC.sort(
|
|
@@ -665,45 +1115,55 @@ async function main() {
|
|
|
665
1115
|
b.occurrences - a.occurrences,
|
|
666
1116
|
);
|
|
667
1117
|
|
|
668
|
-
// ============ assemble
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1118
|
+
// ============ assemble VERIFIED, re-derived relations (cross-vendor first) ============
|
|
1119
|
+
const usedAnchors = new Set();
|
|
1120
|
+
const anchorKey = (kind, anchor) =>
|
|
1121
|
+
kind.startsWith("sym") ? anchor : kind + "|" + anchor.toLowerCase();
|
|
1122
|
+
|
|
1123
|
+
// --- CELL X: reproduce EACH vendor side independently; keep only survivors that still span >=2 tools.
|
|
1124
|
+
const renderedX = [];
|
|
1125
|
+
for (const r of cellX) {
|
|
1126
|
+
if (renderedX.length >= 2) break;
|
|
1127
|
+
const legs = [];
|
|
1128
|
+
for (const pv of r.perVendor) {
|
|
1129
|
+
const rep = reproduceCount(r.anchor, pv.witnesses);
|
|
1130
|
+
if (rep.confirmed >= 1) legs.push({ src: pv.src, witnesses: pv.witnesses, rep });
|
|
1131
|
+
}
|
|
1132
|
+
if (legs.length < 2) continue; // must still be genuinely cross-vendor after a fresh re-read
|
|
1133
|
+
legs.sort((a, b) => b.rep.confirmed - a.rep.confirmed);
|
|
1134
|
+
renderedX.push({ kind: r.kind, anchor: r.anchor, legs });
|
|
1135
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// --- single-vendor findings (CELL C spiral, then CELL A recurrence), skipping anchors already shown.
|
|
678
1139
|
const errorsA = cellA.filter((r) => r.kind.startsWith("err"));
|
|
679
1140
|
const filesA = cellA.filter((r) => r.kind === "sym:file");
|
|
680
1141
|
const rendered = [];
|
|
681
1142
|
const takeA = (r) => {
|
|
682
|
-
|
|
1143
|
+
if (usedAnchors.has(anchorKey(r.kind, r.anchor))) return false;
|
|
1144
|
+
const rep = reproduceCount(r.anchor, r.witnesses);
|
|
683
1145
|
if (rep.confirmed < 2) return false;
|
|
684
1146
|
rendered.push({ cell: "A", ...r, rep });
|
|
1147
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
685
1148
|
return true;
|
|
686
1149
|
};
|
|
687
|
-
|
|
688
|
-
// class 1: the single strongest measured spiral
|
|
1150
|
+
const SINGLE_MAX = renderedX.length ? 2 : 3;
|
|
689
1151
|
for (const r of cellC) {
|
|
690
|
-
if (rendered.length >=
|
|
691
|
-
|
|
1152
|
+
if (rendered.length >= SINGLE_MAX) break;
|
|
1153
|
+
if (usedAnchors.has(anchorKey(r.kind, r.anchor))) continue;
|
|
1154
|
+
const rep = reproduceCount(r.anchor, r.witnesses);
|
|
692
1155
|
if (rep.confirmed < 2) continue;
|
|
693
1156
|
rendered.push({ cell: "C", ...r, rep });
|
|
1157
|
+
usedAnchors.add(anchorKey(r.kind, r.anchor));
|
|
694
1158
|
break;
|
|
695
1159
|
}
|
|
696
|
-
|
|
697
|
-
// class 2: specific cross-session errors in a shared project (up to 2)
|
|
698
1160
|
let c2 = 0;
|
|
699
1161
|
for (const r of errorsA) {
|
|
700
|
-
if (rendered.length >=
|
|
1162
|
+
if (rendered.length >= SINGLE_MAX || c2 >= 2) break;
|
|
701
1163
|
if (takeA(r)) c2++;
|
|
702
1164
|
}
|
|
703
|
-
|
|
704
|
-
// class 3: specific files you kept returning to (fill remaining slots)
|
|
705
1165
|
for (const r of filesA) {
|
|
706
|
-
if (rendered.length >=
|
|
1166
|
+
if (rendered.length >= SINGLE_MAX) break;
|
|
707
1167
|
takeA(r);
|
|
708
1168
|
}
|
|
709
1169
|
|
|
@@ -711,17 +1171,30 @@ async function main() {
|
|
|
711
1171
|
P();
|
|
712
1172
|
P(" skalpel autopsy");
|
|
713
1173
|
P(" " + RULE);
|
|
1174
|
+
// per-vendor inventory line — every count re-derives from that vendor's raw store.
|
|
1175
|
+
const invParts = presentVendors
|
|
1176
|
+
.map((v) => `${inv[v].sessions} ${srcLabel(v)}${inv[v].sessions === 1 ? "" : ""}`)
|
|
1177
|
+
.join(" · ");
|
|
1178
|
+
const vendorWord = presentVendors.length === 1 ? "agent" : "agents";
|
|
714
1179
|
P(
|
|
715
|
-
` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} · ${totalTurns} turns · nothing left this machine`,
|
|
1180
|
+
` read ${sessions.length} local session${sessions.length === 1 ? "" : "s"} across ${presentVendors.length} ${vendorWord} · ${totalTurns} turns · nothing left this machine`,
|
|
716
1181
|
);
|
|
1182
|
+
if (presentVendors.length) {
|
|
1183
|
+
P(
|
|
1184
|
+
" " +
|
|
1185
|
+
presentVendors
|
|
1186
|
+
.map((v) => `${srcLabel(v)}: ${inv[v].sessions} sessions / ${inv[v].turns} turns`)
|
|
1187
|
+
.join(" · "),
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
717
1190
|
P();
|
|
718
1191
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
P(" No reproducible
|
|
1192
|
+
const nothing = renderedX.length === 0 && rendered.length === 0;
|
|
1193
|
+
if (nothing) {
|
|
1194
|
+
P(" No reproducible relation has crossed the bar yet.");
|
|
722
1195
|
P();
|
|
723
1196
|
if (sessions.length === 0) {
|
|
724
|
-
P(" There's no
|
|
1197
|
+
P(" There's no local agent history on this machine to read.");
|
|
725
1198
|
} else {
|
|
726
1199
|
P(
|
|
727
1200
|
` verified findings so far: 0 · scanned ${totalTurns} turns across ${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
|
|
@@ -737,12 +1210,58 @@ async function main() {
|
|
|
737
1210
|
}
|
|
738
1211
|
P();
|
|
739
1212
|
P(" " + RULE);
|
|
740
|
-
|
|
1213
|
+
renderConnectInvite(P, presentVendors);
|
|
1214
|
+
renderFooter(P, presentVendors);
|
|
741
1215
|
process.stdout.write(out.join("\n") + "\n");
|
|
742
1216
|
return;
|
|
743
1217
|
}
|
|
744
1218
|
|
|
745
1219
|
let n = 0;
|
|
1220
|
+
|
|
1221
|
+
// --- CELL X (cross-vendor) leads: the finding no single model vendor can produce.
|
|
1222
|
+
for (const rx of renderedX) {
|
|
1223
|
+
n++;
|
|
1224
|
+
P(` ${n}. ⟷ CROSS-VENDOR`);
|
|
1225
|
+
const names = rx.legs.map((l) => srcLabel(l.src));
|
|
1226
|
+
const nameList =
|
|
1227
|
+
names.length === 2
|
|
1228
|
+
? `${names[0]} and ${names[1]}`
|
|
1229
|
+
: names.slice(0, -1).join(", ") + ", and " + names.slice(-1);
|
|
1230
|
+
const counts = rx.legs.map((l) => `${l.rep.total} in ${srcLabel(l.src)}`).join(", ");
|
|
1231
|
+
if (rx.kind === "sym:file") {
|
|
1232
|
+
P(` ${plainAnchor(rx.kind, rx.anchor)} shows up in BOTH ${nameList} — the same file`);
|
|
1233
|
+
P(` followed you across different agents (${counts}).`);
|
|
1234
|
+
} else {
|
|
1235
|
+
P(` ${plainAnchor(rx.kind, rx.anchor)} appears in BOTH ${nameList} — the same specific`);
|
|
1236
|
+
P(` error surfaced in more than one agent (${counts}).`);
|
|
1237
|
+
}
|
|
1238
|
+
P(` No single model vendor can see this; it needs all your logs at once.`);
|
|
1239
|
+
P();
|
|
1240
|
+
for (const leg of rx.legs) {
|
|
1241
|
+
P(` ── ${srcLabel(leg.src)} ──`);
|
|
1242
|
+
for (const w of leg.witnesses.slice(0, 2)) {
|
|
1243
|
+
P(
|
|
1244
|
+
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
|
|
1245
|
+
);
|
|
1246
|
+
P(` "${redact(w.verbatim)}"`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
P();
|
|
1250
|
+
P(` reproduce: re-open EACH agent's raw store fresh and re-confirm the anchor —`);
|
|
1251
|
+
for (const leg of rx.legs) {
|
|
1252
|
+
P(
|
|
1253
|
+
` ${srcLabel(leg.src)}: present verbatim in ${leg.rep.confirmed}/${leg.rep.total} cited sessions`,
|
|
1254
|
+
);
|
|
1255
|
+
for (const w of leg.witnesses.slice(0, 2)) {
|
|
1256
|
+
const p = srcPointer(w);
|
|
1257
|
+
if (p) P(` ${p}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
P();
|
|
1261
|
+
P(" " + RULE);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// --- single-vendor findings
|
|
746
1265
|
for (const r of rendered) {
|
|
747
1266
|
n++;
|
|
748
1267
|
P(` ${n}.`);
|
|
@@ -750,25 +1269,24 @@ async function main() {
|
|
|
750
1269
|
const shown = r.witnesses.slice(0, 3);
|
|
751
1270
|
const proj = projTail(r.root);
|
|
752
1271
|
const where = proj ? ` in ${proj}` : "";
|
|
1272
|
+
const tool = srcLabel(shown[0] && shown[0].source_tool);
|
|
753
1273
|
if (r.kind === "sym:file") {
|
|
754
|
-
// "returning to a file" is a behavior sourced from either side; the FILENAME is the shared context.
|
|
755
1274
|
P(` You kept returning to the file ${r.anchor} across ${r.relatedN} separate`);
|
|
756
|
-
P(` sessions${where} — the same file, on different days.`);
|
|
1275
|
+
P(` ${tool} sessions${where} — the same file, on different days.`);
|
|
757
1276
|
} else {
|
|
758
|
-
// errors here are compiler/runtime output — sourced to the SESSION, never "you hit".
|
|
759
1277
|
const anyUser = shown.some(isUserTyped);
|
|
760
1278
|
if (anyUser) {
|
|
761
1279
|
P(` ${plainAnchor(r.kind, r.anchor)} came up across ${r.relatedN} separate`);
|
|
762
|
-
P(` sessions${where} — the same specific error, on different days.`);
|
|
1280
|
+
P(` ${tool} sessions${where} — the same specific error, on different days.`);
|
|
763
1281
|
} else {
|
|
764
|
-
P(` Your sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
|
|
1282
|
+
P(` Your ${tool} sessions hit ${plainAnchor(r.kind, r.anchor)} across ${r.relatedN}`);
|
|
765
1283
|
P(` separate sessions${where} — the same specific error resurfaced across days.`);
|
|
766
1284
|
}
|
|
767
1285
|
}
|
|
768
1286
|
P();
|
|
769
1287
|
for (const w of shown) {
|
|
770
1288
|
P(
|
|
771
|
-
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)}${projLabel(w.root)}`,
|
|
1289
|
+
` ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcTag(w)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
|
|
772
1290
|
);
|
|
773
1291
|
P(` "${redact(w.verbatim)}"`);
|
|
774
1292
|
}
|
|
@@ -776,17 +1294,19 @@ async function main() {
|
|
|
776
1294
|
P(` reproduce: re-read the cited sessions fresh from disk at the cited`);
|
|
777
1295
|
P(` turns → anchor present verbatim in ${r.rep.confirmed}/${r.rep.total} cited sessions`);
|
|
778
1296
|
P(
|
|
779
|
-
` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}).
|
|
1297
|
+
` (${r.rep.total - r.rep.confirmed} false join${r.rep.total - r.rep.confirmed === 1 ? "" : "s"}). Sources:`,
|
|
780
1298
|
);
|
|
781
1299
|
for (const w of shown) {
|
|
782
|
-
const
|
|
783
|
-
if (
|
|
1300
|
+
const p = srcPointer(w);
|
|
1301
|
+
if (p) P(` ${p}`);
|
|
784
1302
|
}
|
|
785
1303
|
} else {
|
|
786
|
-
// CELL C — measured spiral. State the mechanical fact; the quoted turns self-adjudicate.
|
|
787
1304
|
const proj = projTail(r.root);
|
|
788
1305
|
const where = proj ? ` in ${proj}` : "";
|
|
789
|
-
|
|
1306
|
+
const tool = srcLabel(r.source_tool);
|
|
1307
|
+
P(
|
|
1308
|
+
` ${plainAnchor(r.kind, r.anchor)} recurred ${r.occurrences}× inside one ${tool} session${where}`,
|
|
1309
|
+
);
|
|
790
1310
|
P(
|
|
791
1311
|
` over a ${r.duration_min}-minute window (wall-clock), across ${r.distinct_turns} turns with`,
|
|
792
1312
|
);
|
|
@@ -796,7 +1316,7 @@ async function main() {
|
|
|
796
1316
|
for (const w of r.witnesses) {
|
|
797
1317
|
const mark = isUserTyped(w) ? "●" : "○";
|
|
798
1318
|
P(
|
|
799
|
-
` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)}${projLabel(w.root)}`,
|
|
1319
|
+
` ${mark} ${short(w.session_id)} · turn ${w.turn_idx} · ${shortTs(w.ts)} · ${srcLabel(w.source_tool)}${projLabel(w.root)}`,
|
|
800
1320
|
);
|
|
801
1321
|
P(` "${redact(w.verbatim)}"`);
|
|
802
1322
|
}
|
|
@@ -805,23 +1325,49 @@ async function main() {
|
|
|
805
1325
|
P(
|
|
806
1326
|
` anchor present verbatim at all ${r.rep.confirmed}/${r.rep.total} cited turns (of ${r.occurrences} total`,
|
|
807
1327
|
);
|
|
808
|
-
P(
|
|
809
|
-
|
|
810
|
-
|
|
1328
|
+
P(
|
|
1329
|
+
` occurrences); the ${r.duration_min}min span = last.ts − first.ts, both quoted. Source:`,
|
|
1330
|
+
);
|
|
1331
|
+
const p = srcPointer(r.witnesses[0]);
|
|
1332
|
+
if (p) P(` ${p}`);
|
|
811
1333
|
}
|
|
812
1334
|
P();
|
|
813
1335
|
P(" " + RULE);
|
|
814
1336
|
}
|
|
815
1337
|
|
|
816
|
-
|
|
1338
|
+
// --- honest connect-a-2nd-agent invite (only when a single vendor is present)
|
|
1339
|
+
renderConnectInvite(P, presentVendors);
|
|
1340
|
+
renderFooter(P, presentVendors);
|
|
817
1341
|
process.stdout.write(out.join("\n") + "\n");
|
|
818
1342
|
}
|
|
819
1343
|
|
|
820
|
-
|
|
1344
|
+
// When only ONE agent has usable history, the cross-vendor verdict cannot exist yet. Say so honestly
|
|
1345
|
+
// and invite a second connection — the multi-vendor connect is the whole point of this cell.
|
|
1346
|
+
function renderConnectInvite(P, presentVendors) {
|
|
1347
|
+
if (presentVendors.length !== 1) return;
|
|
1348
|
+
const have = srcLabel(presentVendors[0]);
|
|
1349
|
+
const others = ["Cursor", "Codex", "Aider"].filter((x) => x !== have);
|
|
1350
|
+
P();
|
|
1351
|
+
P(` You've connected ONE agent so far: ${have}.`);
|
|
1352
|
+
P(` The signal no model vendor can build for you is CROSS-VENDOR — which tool`);
|
|
1353
|
+
P(` actually LANDS a pattern you keep reopening in another. Connect a 2nd agent`);
|
|
1354
|
+
P(` (${others.join(" / ")}) and the next autopsy will show where the SAME error or`);
|
|
1355
|
+
P(` file follows you across tools — reproduced from each tool's own local logs.`);
|
|
1356
|
+
P();
|
|
1357
|
+
P(" " + RULE);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function renderFooter(P, presentVendors) {
|
|
1361
|
+
const stores = [];
|
|
1362
|
+
if (presentVendors.includes(SRC.claude)) stores.push("~/.claude/projects");
|
|
1363
|
+
if (presentVendors.includes(SRC.codex)) stores.push("~/.codex/sessions");
|
|
1364
|
+
if (presentVendors.includes(SRC.cursor)) stores.push("Cursor state.vscdb");
|
|
1365
|
+
if (presentVendors.includes(SRC.aider)) stores.push("~/.aider.chat.history.md");
|
|
1366
|
+
const storeList = stores.length ? stores.join(", ") : "your local agent logs";
|
|
821
1367
|
P();
|
|
822
1368
|
P(" This receipt was generated locally, read-only, with zero network calls —");
|
|
823
|
-
P(
|
|
824
|
-
P(" Every number above re-derives from the raw logs at the cited turns.");
|
|
1369
|
+
P(` rendered from agent logs already on your disk (${storeList}).`);
|
|
1370
|
+
P(" Every number above re-derives from the raw logs at the cited turns, per vendor.");
|
|
825
1371
|
P();
|
|
826
1372
|
if (RAW) {
|
|
827
1373
|
P(" --raw: third-party emails / domains / IPs / secrets are shown UNREDACTED");
|
|
@@ -831,9 +1377,9 @@ function renderFooter(P) {
|
|
|
831
1377
|
P(" spans are redacted to [email]/[domain]/[ip]/[token]. Run with --raw to reveal.");
|
|
832
1378
|
}
|
|
833
1379
|
P();
|
|
834
|
-
P(" Honest scope: this AUTOPSY is local. The rest of skalpel is
|
|
835
|
-
P(" the per-turn hook and session-end ingest still upload transcripts
|
|
836
|
-
P(" Zero-network ingest is a later cycle; this receipt is the first step.");
|
|
1380
|
+
P(" Honest scope: this AUTOPSY is local and cross-vendor. The rest of skalpel is");
|
|
1381
|
+
P(" not yet — the per-turn hook and session-end ingest still upload transcripts");
|
|
1382
|
+
P(" today. Zero-network ingest is a later cycle; this receipt is the first step.");
|
|
837
1383
|
P();
|
|
838
1384
|
}
|
|
839
1385
|
|