agent-polygraph 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,300 @@
1
+ // Claude Code transcripts: ~/.claude/projects/<project>/<session>.jsonl
2
+ // Subagent transcripts live in <session>/subagents/*.jsonl and are merged into the parent.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { readJsonl } from "../jsonl.js";
8
+
9
+ export const name = "claude";
10
+
11
+ export function defaultRoot() {
12
+ return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "projects");
13
+ }
14
+
15
+ /** List sessions as { id, files: [main, ...subagents], mtime }. */
16
+ export function discover(root = defaultRoot()) {
17
+ const out = [];
18
+ let projects = [];
19
+ try {
20
+ projects = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
21
+ } catch {
22
+ return out;
23
+ }
24
+ for (const proj of projects) {
25
+ const dir = path.join(root, proj.name);
26
+ for (const f of safeReaddir(dir)) {
27
+ if (!f.endsWith(".jsonl")) continue;
28
+ const id = f.slice(0, -6);
29
+ const main = path.join(dir, f);
30
+ const subDir = path.join(dir, id, "subagents");
31
+ const subs = safeReaddir(subDir)
32
+ .filter((s) => s.endsWith(".jsonl"))
33
+ .map((s) => path.join(subDir, s));
34
+ out.push({ agent: name, id, files: [main, ...subs], mtime: mtime(main) });
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Cheap prefilter before JSON.parse. Whitespace-tolerant: anonymizers and exporters often
41
+ // re-serialize transcripts as `"type": "user"`.
42
+ // Hook runs are recorded as attachments carrying the command, its output and exit code.
43
+ const WANTED = /"type"\s*:\s*"(?:user|assistant|hook_success|hook_non_blocking_error|hook_blocking_error)"/;
44
+
45
+ // Event model (shared with codex.js): a command's start (`cmd`) and what was learned about
46
+ // it (`result`) are separate events, placed where they were observed. Edits are only
47
+ // emitted once their tool result confirms them. Analysis relies on event order.
48
+ export async function parse(entry) {
49
+ const session = { agent: name, id: entry.id, file: entry.files[0], cwd: null, models: new Set(), versions: new Set(), events: [], start: null, end: null };
50
+ const stats = { lines: 0, kept: 0 };
51
+ let userCmd = null; // a command you typed with `!`, waiting for its output
52
+ const pendingCmds = new Map(); // tool_use id -> cmd event awaiting its result
53
+ const pendingEdits = new Map(); // tool_use id -> edit events awaiting confirmation
54
+ const background = new Map(); // tool_use id -> backgrounded cmd event
55
+ const outputFiles = new Map(); // background output file -> cmd event
56
+ const reads = new Map(); // Read tool_use id -> cmd event whose output file it reads
57
+ // seq breaks timestamp ties in the order things were requested or observed
58
+ let seq = 0;
59
+ const push = (ev) => {
60
+ ev.seq ??= seq++;
61
+ session.events.push(ev);
62
+ };
63
+ const result = (of, ts, fields) => push({ kind: "result", of, ts, ...fields });
64
+
65
+ for (const [i, file] of entry.files.entries()) {
66
+ const isSub = i > 0;
67
+ for await (const rec of readJsonl(file, (line) => WANTED.test(line), stats)) {
68
+ const ts = Date.parse(rec.timestamp) || null;
69
+ if (rec.version && !isSub) session.versions.add(rec.version);
70
+ if (ts) {
71
+ session.start = Math.min(session.start ?? ts, ts);
72
+ session.end = Math.max(session.end ?? ts, ts);
73
+ }
74
+ if (!session.cwd && rec.cwd && !isSub) session.cwd = rec.cwd;
75
+ if (rec.gitBranch && rec.gitBranch !== "HEAD" && !isSub) session.branch = rec.gitBranch;
76
+ const msg = rec.message || {};
77
+ const content = msg.content;
78
+
79
+ // a hook (PostToolUse, Stop, …) that ran a command: tests run by hooks count too
80
+ if (rec.type === "attachment") {
81
+ const a = rec.attachment || {};
82
+ if (a.command) {
83
+ const ev = { kind: "cmd", ts, cmd: String(a.command), sub: isSub, hook: a.hookEvent || true };
84
+ push(ev);
85
+ result(ev, ts, { exitCode: typeof a.exitCode === "number" ? a.exitCode : null, isError: null, output: (String(a.stdout ?? "") + "\n" + String(a.stderr ?? "")).slice(-6000), ms: a.durationMs ?? null });
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (rec.type === "assistant") {
91
+ if (msg.model && !msg.model.startsWith("<")) session.models.add(msg.model);
92
+ if (!Array.isArray(content)) continue;
93
+ for (const block of content) {
94
+ if (block.type === "text" && !isSub) {
95
+ push({ kind: "say", ts, text: block.text });
96
+ } else if (block.type === "tool_use") {
97
+ if (block.name === "Read" && outputFiles.has(block.input?.file_path)) reads.set(block.id, outputFiles.get(block.input.file_path));
98
+ const evs = toolEvents(block, ts, isSub);
99
+ if (evs.length && evs[0].kind === "cmd") {
100
+ evs[0].id = block.id;
101
+ if (rec.gitBranch && rec.gitBranch !== "HEAD") evs[0].branch = rec.gitBranch;
102
+ // `tail -5 …/tasks/x.output` through the shell reads a background run's output too
103
+ const read = [...outputFiles.keys()].find((f) => evs[0].cmd.includes(f));
104
+ if (read) reads.set(block.id, outputFiles.get(read));
105
+ push(evs[0]);
106
+ pendingCmds.set(block.id, evs[0]);
107
+ } else if (evs.length) {
108
+ // an edit takes effect in request order, before any later tool call runs;
109
+ // it is only emitted if its result confirms it
110
+ for (const e of evs) e.seq = seq++;
111
+ pendingEdits.set(block.id, evs);
112
+ }
113
+ }
114
+ }
115
+ } else if (rec.type === "user") {
116
+ if (Array.isArray(content)) {
117
+ for (const block of content) {
118
+ if (block.type === "tool_result") {
119
+ const text = fullText(resultText(block.content));
120
+ const readOf = reads.get(block.tool_use_id);
121
+ if (readOf) {
122
+ // the agent reading a background run's output file: evidence about that run
123
+ reads.delete(block.tool_use_id);
124
+ result(readOf, ts, { exitCode: null, isError: null, output: text.slice(-6000), partial: true });
125
+ if (!pendingCmds.has(block.tool_use_id)) continue; // a Read; a shell read also gets its own result
126
+ }
127
+ const edits = pendingEdits.get(block.tool_use_id);
128
+ if (edits) {
129
+ pendingEdits.delete(block.tool_use_id);
130
+ if (block.is_error === true) continue; // rejected or failed: nothing changed
131
+ for (const e of edits) {
132
+ // Write overwrites as happily as it creates; only its result says which
133
+ if (e.write) e.op = /created successfully/i.test(text) ? "add" : /updated|overwr/i.test(text) ? "update" : "unknown";
134
+ push(e);
135
+ }
136
+ continue;
137
+ }
138
+ const ev = pendingCmds.get(block.tool_use_id);
139
+ if (!ev) continue;
140
+ pendingCmds.delete(block.tool_use_id);
141
+ // refused by a permission prompt, the auto-mode classifier or a hook: it never ran
142
+ if (block.is_error === true && DENIED.test(text)) {
143
+ result(ev, ts, { denied: true, exitCode: null, isError: true, output: "" });
144
+ continue;
145
+ }
146
+ if (/running in background|backgrounded command/i.test(text) && !/^Exit code/.test(text)) {
147
+ // result arrives later through a <task-notification> or a Read of the output file
148
+ background.set(block.tool_use_id, ev);
149
+ const file = /(\/\S+\.output)\b/.exec(text)?.[1];
150
+ if (file) outputFiles.set(file, ev);
151
+ continue;
152
+ }
153
+ const m = /^Exit code (-?\d+)/.exec(text);
154
+ const isError = block.is_error === true;
155
+ result(ev, ts, { exitCode: m ? Number(m[1]) : isError ? null : 0, isError, output: text.slice(-6000), ms: ts && ev.ts ? ts - ev.ts : null });
156
+ } else if (block.type === "text" && !isSub && /^\[Request interrupted by user/.test(block.text || "")) {
157
+ push({ kind: "interrupt", ts });
158
+ }
159
+ }
160
+ }
161
+ if (typeof content === "string" && content.startsWith("<task-notification")) {
162
+ const id = /<tool-use-id>([^<]+)</.exec(content)?.[1];
163
+ const ev = background.get(id);
164
+ if (ev) {
165
+ const code = /exit code (-?\d+)/i.exec(content);
166
+ const status = /<status>(\w+)</.exec(content)?.[1];
167
+ const file = /<output-file>([^<]+)</.exec(content)?.[1];
168
+ if (file) outputFiles.set(file, ev);
169
+ result(ev, ts, {
170
+ exitCode: code ? Number(code[1]) : status === "completed" ? 0 : status === "failed" ? 1 : null,
171
+ isError: null,
172
+ output: "",
173
+ ms: ts && ev.ts ? ts - ev.ts : null,
174
+ });
175
+ }
176
+ }
177
+ // commands you typed yourself with `!`: <bash-input>, then <bash-stdout>/<bash-stderr>
178
+ if (typeof content === "string" && /^\s*<bash-input>/.test(content)) {
179
+ const cmd = unescape(/<bash-input>([\s\S]*?)<\/bash-input>/.exec(content)?.[1] ?? "").trim();
180
+ if (cmd) {
181
+ userCmd = { kind: "cmd", ts, cmd, sub: false, byUser: true };
182
+ push(userCmd);
183
+ }
184
+ continue;
185
+ }
186
+ if (typeof content === "string" && /^\s*<bash-std(?:out|err)>/.test(content) && userCmd) {
187
+ const out = [...content.matchAll(/<bash-std(?:out|err)>([\s\S]*?)<\/bash-std(?:out|err)>/g)].map((m) => unescape(m[1])).join("\n");
188
+ result(userCmd, ts, { exitCode: null, isError: null, output: out.slice(-6000), ms: ts && userCmd.ts ? ts - userCmd.ts : null });
189
+ userCmd = null;
190
+ continue;
191
+ }
192
+ if (!isSub && isHumanPrompt(rec)) {
193
+ push({ kind: "user", ts, text: typeof content === "string" ? content : textOf(content) });
194
+ }
195
+ }
196
+ }
197
+ }
198
+ // Edits whose outcome never arrived may or may not have happened.
199
+ for (const evs of pendingEdits.values()) push({ kind: "uncertain", ts: evs[0].ts, seq: evs[0].seq, reason: "edit with no result" });
200
+ session.versions = [...session.versions];
201
+ // lines in the file but none we recognize: probably a log format we don't know yet
202
+ session.unrecognized = stats.lines > 5 && stats.kept === 0;
203
+ // stable: events with equal timestamps keep the order they were observed in
204
+ session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || a.seq - b.seq);
205
+ session.models = [...session.models];
206
+ return session;
207
+ }
208
+
209
+ // Answers the agent gets instead of a command's output when it wasn't allowed to run
210
+ const DENIED = /^(?:Permission for this action was denied|Permission to use \S+ (?:with command .* )?has been denied|The user doesn't want to proceed with this tool use|\S+ operation blocked by hook|.{0,40}hook (?:blocked|denied)|User rejected)/is;
211
+
212
+ /**
213
+ * Output too large for the transcript is saved to a file, and the transcript keeps only
214
+ * a preview (`<persisted-output>… Full output saved to: /path …`). The runner's summary is
215
+ * at the end, so read the end of the saved file when it still exists.
216
+ */
217
+ function fullText(text) {
218
+ if (!text.includes("<persisted-output>")) return text;
219
+ const file = /Full output saved to: (\S+?\.txt)\b/.exec(text)?.[1];
220
+ if (!file) return text;
221
+ try {
222
+ const fd = fs.openSync(file, "r");
223
+ try {
224
+ const size = fs.fstatSync(fd).size;
225
+ const n = Math.min(size, 16384);
226
+ const buf = Buffer.alloc(n);
227
+ fs.readSync(fd, buf, 0, n, size - n);
228
+ return text + "\n" + buf.toString("utf8");
229
+ } finally {
230
+ fs.closeSync(fd);
231
+ }
232
+ } catch {
233
+ return text;
234
+ }
235
+ }
236
+
237
+ function isHumanPrompt(rec) {
238
+ if (rec.isMeta || rec.isSidechain) return false;
239
+ const kind = rec.origin?.kind;
240
+ if (kind) return kind === "human";
241
+ const c = rec.message?.content;
242
+ const text = typeof c === "string" ? c : Array.isArray(c) && c.every((b) => b.type === "text") ? textOf(c) : null;
243
+ if (!text) return false;
244
+ return !/^\s*(?:<[a-z_-]+[\s>]|\[(?:Request interrupted|Image)|Caveat:)/.test(text);
245
+ }
246
+
247
+ function toolEvents(block, ts, isSub) {
248
+ const input = block.input || {};
249
+ switch (block.name) {
250
+ case "Bash":
251
+ case "bash":
252
+ case "PowerShell": // Claude Code on Windows; same result format as Bash
253
+ case "powershell":
254
+ return input.command ? [{ kind: "cmd", ts, cmd: String(input.command), sub: isSub }] : [];
255
+ case "Edit":
256
+ return [edit(input.file_path, input.old_string, input.new_string, ts)];
257
+ case "MultiEdit":
258
+ return (input.edits || []).map((e) => edit(input.file_path, e.old_string, e.new_string, ts));
259
+ case "Write":
260
+ return [{ kind: "edit", ts, path: input.file_path || "", removed: null, added: lines(input.content), write: true }];
261
+ case "NotebookEdit":
262
+ return [{ kind: "edit", ts, path: input.notebook_path || "", removed: [], added: lines(input.new_source) }];
263
+ default:
264
+ return [];
265
+ }
266
+ }
267
+
268
+ function edit(p, oldS, newS, ts) {
269
+ return { kind: "edit", ts, path: p || "", removed: lines(oldS), added: lines(newS) };
270
+ }
271
+
272
+ const lines = (s) => (typeof s === "string" && s.length ? s.split("\n") : []);
273
+
274
+ function resultText(c) {
275
+ if (typeof c === "string") return c;
276
+ if (Array.isArray(c)) return c.map((b) => (typeof b === "string" ? b : b.text || "")).join("\n");
277
+ return "";
278
+ }
279
+
280
+ const unescape = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
281
+
282
+ function textOf(content) {
283
+ return Array.isArray(content) ? content.filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
284
+ }
285
+
286
+ function safeReaddir(d) {
287
+ try {
288
+ return fs.readdirSync(d);
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
294
+ function mtime(f) {
295
+ try {
296
+ return fs.statSync(f).mtimeMs;
297
+ } catch {
298
+ return 0;
299
+ }
300
+ }