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.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/polygraph.js +274 -0
- package/package.json +41 -4
- package/src/analyze.js +621 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +240 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +402 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/safe-config.js +85 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +73 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Codex CLI rollouts: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
2
|
+
// Handles both the classic tool format (shell / apply_patch calls) and "code mode",
|
|
3
|
+
// where the model writes a JS cell that calls tools.exec_command / tools.apply_patch.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { readJsonl } from "../jsonl.js";
|
|
9
|
+
import { isTestCommand, ECHOES } from "../classify.js";
|
|
10
|
+
|
|
11
|
+
export const name = "codex";
|
|
12
|
+
|
|
13
|
+
export function defaultRoot() {
|
|
14
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function discover(root = defaultRoot()) {
|
|
18
|
+
const files = [];
|
|
19
|
+
walk(root, files);
|
|
20
|
+
const byId = new Map();
|
|
21
|
+
const children = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
const meta = firstMeta(file);
|
|
24
|
+
const entry = { agent: name, id: meta?.id || path.basename(file, ".jsonl"), files: [file], mtime: mtime(file) };
|
|
25
|
+
if (meta?.parent_thread_id) children.push([meta.parent_thread_id, file]);
|
|
26
|
+
else byId.set(entry.id, entry);
|
|
27
|
+
}
|
|
28
|
+
for (const [parent, file] of children) {
|
|
29
|
+
const p = byId.get(parent);
|
|
30
|
+
if (p) p.files.push(file);
|
|
31
|
+
else byId.set(file, { agent: name, id: path.basename(file, ".jsonl"), files: [file], mtime: mtime(file) });
|
|
32
|
+
}
|
|
33
|
+
return [...byId.values()];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// large records we never use; whitespace-tolerant like the Claude prefilter
|
|
37
|
+
const SKIP = /"type"\s*:\s*"(?:token_count|reasoning|world_state|compacted|token_usage_record|Reasoning|AgentMessage|UserMessage|McpToolCall|Extension|ImageView|SubAgentActivity|WebSearch)"/;
|
|
38
|
+
|
|
39
|
+
// Same event model as claude.js: `cmd` (start) and `result` are separate events placed
|
|
40
|
+
// where they were observed, edits are emitted once the tool reports success, and the
|
|
41
|
+
// actions inside one code-mode cell keep their source order.
|
|
42
|
+
export async function parse(entry) {
|
|
43
|
+
const session = { agent: name, id: entry.id, file: entry.files[0], cwd: null, models: new Set(), versions: new Set(), events: [], start: null, end: null };
|
|
44
|
+
const push = (ev) => session.events.push(ev);
|
|
45
|
+
const stats = { lines: 0, kept: 0 };
|
|
46
|
+
let sawMeta = false;
|
|
47
|
+
|
|
48
|
+
for (const [i, file] of entry.files.entries()) {
|
|
49
|
+
const isSub = i > 0;
|
|
50
|
+
const calls = new Map(); // call_id -> pending call
|
|
51
|
+
const running = new Map(); // exec session id -> cmd event still running
|
|
52
|
+
const cells = new Map(); // code-mode cell id -> call whose script is still running
|
|
53
|
+
// Codex also records every finished command as a CommandExecution item with its own exit
|
|
54
|
+
// code and full output. That is ground truth: it beats anything reconstructed from a
|
|
55
|
+
// cell's printed text. Items and command starts are matched by command text.
|
|
56
|
+
const execs = []; // finished items not yet matched to a started command
|
|
57
|
+
const open = []; // started commands still waiting for their item
|
|
58
|
+
const resolve = (ev, item) => {
|
|
59
|
+
ev.resolved = true;
|
|
60
|
+
if (!ev.workdir && item.cwd) ev.workdir = item.cwd;
|
|
61
|
+
push({ kind: "result", of: ev, ts: Math.max(item.ts ?? 0, ev.ts ?? 0), exitCode: item.exitCode, isError: null, output: item.output, ms: item.ms });
|
|
62
|
+
};
|
|
63
|
+
const started = (ev) => {
|
|
64
|
+
const k = execs.findIndex((it) => it.key === cmdKey(ev.cmd) && (it.ts ?? 0) >= (ev.calledAt ?? ev.ts ?? 0) - 1000);
|
|
65
|
+
if (k >= 0) resolve(ev, execs.splice(k, 1)[0]);
|
|
66
|
+
else open.push(ev);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for await (const rec of readJsonl(file, (line) => !SKIP.test(line), stats)) {
|
|
70
|
+
const ts = Date.parse(rec.timestamp) || null;
|
|
71
|
+
if (ts) {
|
|
72
|
+
session.start = Math.min(session.start ?? ts, ts);
|
|
73
|
+
session.end = Math.max(session.end ?? ts, ts);
|
|
74
|
+
}
|
|
75
|
+
const p = rec.payload || {};
|
|
76
|
+
if (rec.type === "session_meta") {
|
|
77
|
+
sawMeta = true;
|
|
78
|
+
if (!isSub && p.cli_version) session.versions.add(p.cli_version);
|
|
79
|
+
if (!isSub && p.cwd) session.cwd = p.cwd;
|
|
80
|
+
if (!isSub && p.git?.branch) session.branch = p.git.branch;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (rec.type === "turn_context") {
|
|
84
|
+
if (p.model) session.models.add(p.model);
|
|
85
|
+
if (!session.cwd && p.cwd) session.cwd = p.cwd;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (rec.type === "event_msg") {
|
|
89
|
+
if (p.type === "turn_aborted" && p.reason === "interrupted" && !isSub) push({ kind: "interrupt", ts });
|
|
90
|
+
if (p.type === "item_completed" && p.item?.type === "CommandExecution") {
|
|
91
|
+
const item = execItem(p.item, Number(p.completed_at_ms) || ts);
|
|
92
|
+
if (item) {
|
|
93
|
+
const k = open.findIndex((ev) => !ev.resolved && cmdKey(ev.cmd) === item.key);
|
|
94
|
+
if (k >= 0) resolve(open.splice(k, 1)[0], item);
|
|
95
|
+
else execs.push(item);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (rec.type !== "response_item") continue;
|
|
101
|
+
|
|
102
|
+
switch (p.type) {
|
|
103
|
+
case "message": {
|
|
104
|
+
if (isSub) break;
|
|
105
|
+
const text = (p.content || []).map((c) => c.text || "").join("\n");
|
|
106
|
+
if (p.role === "user" && isHumanPrompt(text)) push({ kind: "user", ts, text });
|
|
107
|
+
else if (p.role === "assistant" && text) push({ kind: "say", ts, text });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "custom_tool_call":
|
|
111
|
+
case "function_call": {
|
|
112
|
+
const input = typeof (p.input ?? p.arguments) === "string" ? p.input ?? p.arguments : JSON.stringify(p.input ?? p.arguments ?? "");
|
|
113
|
+
const call = { ts, actions: [], stdinSessions: [], emitted: false, sub: isSub };
|
|
114
|
+
if (p.name === "exec" || p.name === "js") {
|
|
115
|
+
call.actions = cellActions(input, ts, isSub);
|
|
116
|
+
for (const a of call.actions) if (a.ev) a.ev.callId = p.call_id;
|
|
117
|
+
// Promise.all keeps input order; output printed per-promise (.then) or from
|
|
118
|
+
// race/any arrives in completion order and can't be matched to commands
|
|
119
|
+
call.parallel = /Promise\.(?:race|any)\b/.test(input) || (/Promise\.(?:all|allSettled)\b/.test(input) && /\.then\s*\(/.test(input));
|
|
120
|
+
call.stdinSessions = [...input.matchAll(/write_stdin\(\s*\{[^}]*?session_id"?\s*:\s*(\d+)/g)].map((m) => m[1]);
|
|
121
|
+
} else if (p.name === "apply_patch") {
|
|
122
|
+
call.actions = [{ type: "patch", edits: patchEdits(input, ts) }];
|
|
123
|
+
} else if (["shell", "exec_command", "local_shell", "container.exec", "unified_exec"].includes(p.name)) {
|
|
124
|
+
const cmd = classicCommand(input);
|
|
125
|
+
if (cmd && /^\s*apply_patch\b/.test(cmd)) call.actions = [{ type: "patch", edits: patchEdits(cmd, ts) }];
|
|
126
|
+
else if (cmd) {
|
|
127
|
+
let workdir;
|
|
128
|
+
try {
|
|
129
|
+
workdir = JSON.parse(input).workdir;
|
|
130
|
+
} catch {}
|
|
131
|
+
call.actions = [{ type: "cmd", ev: cmdEvent(cmd, ts, isSub, typeof workdir === "string" ? workdir : undefined) }];
|
|
132
|
+
emitStarts(call, push, started); // a plain shell call starts right away
|
|
133
|
+
}
|
|
134
|
+
} else if (p.name === "wait") {
|
|
135
|
+
const m = /cell_id"?\s*:\s*"?(\w+)/.exec(input);
|
|
136
|
+
if (m && cells.has(m[1])) call.cell = m[1];
|
|
137
|
+
} else if (p.name === "write_stdin") {
|
|
138
|
+
const m = /session_id"?\s*:\s*(\d+)/.exec(input);
|
|
139
|
+
if (m) call.stdinSessions = [m[1]];
|
|
140
|
+
}
|
|
141
|
+
if (call.actions.length || call.stdinSessions.length || call.cell) calls.set(p.call_id, call);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "custom_tool_call_output":
|
|
145
|
+
case "function_call_output": {
|
|
146
|
+
const call = calls.get(p.call_id);
|
|
147
|
+
if (!call) break;
|
|
148
|
+
calls.delete(p.call_id);
|
|
149
|
+
const text = outputText(p.output);
|
|
150
|
+
// `wait` on a still-running cell delivers that cell's results
|
|
151
|
+
const target = call.cell ? cells.get(call.cell) : call;
|
|
152
|
+
if (!target) break;
|
|
153
|
+
target.text = (target.text ? target.text + "\n" : "") + text;
|
|
154
|
+
const still = /Script running with cell ID (\w+)/.exec(text);
|
|
155
|
+
if (still) cells.set(still[1], target);
|
|
156
|
+
else if (call.cell) cells.delete(call.cell);
|
|
157
|
+
settle(target, ts, push, running, Boolean(still), started);
|
|
158
|
+
pollResults(call, text, ts, push, running);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
|
|
165
|
+
session.models = [...session.models];
|
|
166
|
+
session.versions = [...session.versions];
|
|
167
|
+
// content but no session header we recognize: probably a log format we don't know yet
|
|
168
|
+
session.unrecognized = stats.lines > 5 && !sawMeta;
|
|
169
|
+
return session;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cmdEvent(cmd, ts, sub, workdir) {
|
|
173
|
+
return { kind: "cmd", ts, cmd, sub, workdir };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** exec_command and apply_patch calls in a JS cell, in source order. */
|
|
177
|
+
function cellActions(js, ts, sub) {
|
|
178
|
+
const actions = [];
|
|
179
|
+
for (const { pos, cmd, workdir } of execCommands(js, true)) actions.push({ pos, type: "cmd", ev: cmdEvent(cmd, ts, sub, workdir) });
|
|
180
|
+
const patchAt = js.search(/apply_patch\s*\(/);
|
|
181
|
+
const edits = patchEdits(js, ts);
|
|
182
|
+
if (edits.length) actions.push({ pos: patchAt >= 0 ? patchAt : js.length, type: "patch", edits });
|
|
183
|
+
return actions.sort((a, b) => a.pos - b.pos);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function emitStarts(call, push, started) {
|
|
187
|
+
if (call.emitted) return;
|
|
188
|
+
call.emitted = true;
|
|
189
|
+
for (const a of call.actions) {
|
|
190
|
+
if (a.type !== "cmd") continue;
|
|
191
|
+
push(a.ev);
|
|
192
|
+
started?.(a.ev);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cmdKey = (cmd) => String(cmd).replace(/\s+/g, " ").trim();
|
|
197
|
+
|
|
198
|
+
/** A finished CommandExecution item, normalized. */
|
|
199
|
+
function execItem(it, ts) {
|
|
200
|
+
const c = it.command;
|
|
201
|
+
const cmd = Array.isArray(c) ? (c.length >= 3 && /^-l?c$/.test(c[1]) ? c[c.length - 1] : c.join(" ")) : c;
|
|
202
|
+
if (!cmd || it.exit_code == null) return null;
|
|
203
|
+
const output = String(it.aggregated_output || [it.stdout, it.stderr].filter(Boolean).join("\n") || it.formatted_output || "").slice(-6000);
|
|
204
|
+
const d = it.duration;
|
|
205
|
+
const ms = d && typeof d === "object" ? Math.round((d.secs ?? 0) * 1000 + (d.nanos ?? 0) / 1e6) : null;
|
|
206
|
+
const cwd = typeof it.cwd === "string" ? decodeURIComponent(it.cwd.replace(/^file:\/\//, "")) : undefined;
|
|
207
|
+
return { key: cmdKey(cmd), exitCode: Number(it.exit_code), output, ts, ms, cwd };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
const PATCH_FAILED = /apply_patch verification failed|failed to apply|invalid patch|patch rejected|no such file|did not match/i;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Turn a call's output into events. In a cell, each command gets only its own output
|
|
215
|
+
* chunk; when chunks can't be matched to commands, the result stays unknown instead of
|
|
216
|
+
* sharing one command's output with another.
|
|
217
|
+
*/
|
|
218
|
+
function settle(call, ts, push, running, stillRunning, started) {
|
|
219
|
+
const text = call.text || "";
|
|
220
|
+
const cmds = call.actions.filter((a) => a.type === "cmd");
|
|
221
|
+
const chunks = jsonChunks(text);
|
|
222
|
+
const codedChunks = chunks.filter((c) => typeof c.exit_code === "number");
|
|
223
|
+
const wall = /Wall time:?\s*([\d.]+)\s*s/.exec(text);
|
|
224
|
+
|
|
225
|
+
// a lone command still running hands back a session id to poll with write_stdin
|
|
226
|
+
const sid = cmds.length === 1 && !codedChunks.length ? chunks.find((c) => c.session_id != null)?.session_id ?? /"session_id"\s*:\s*(\d+)/.exec(text)?.[1] : null;
|
|
227
|
+
const polling = sid != null;
|
|
228
|
+
if (polling && !cmds[0].done) running.set(String(sid), cmds[0]);
|
|
229
|
+
|
|
230
|
+
// per-command results, by position, when the mapping is unambiguous
|
|
231
|
+
let perCmd = null;
|
|
232
|
+
if (polling) perCmd = null;
|
|
233
|
+
else if (cmds.length === 1) {
|
|
234
|
+
const codes = codedChunks.length
|
|
235
|
+
? codedChunks.map((c) => c.exit_code)
|
|
236
|
+
: [...text.matchAll(/"exit_code"\s*:\s*(-?\d+)|Process exited with code (-?\d+)|Exit code:\s*(-?\d+)/g)].map((m) => Number(m[1] ?? m[2] ?? m[3]));
|
|
237
|
+
const code = codes.length && codes.every((c) => c === codes[0]) ? codes[0] : null;
|
|
238
|
+
// prefer the command's own (decoded) output over the raw cell text
|
|
239
|
+
const own = chunks.map((c) => c.output).filter((o) => typeof o === "string").join("\n");
|
|
240
|
+
perCmd = [{ exitCode: code, output: (own || text).slice(-6000) }];
|
|
241
|
+
} else if (cmds.length > 1 && chunks.length === cmds.length && (chunks.every((c, j) => c.i === j) || !call.parallel)) {
|
|
242
|
+
// one chunk per command, by explicit index or in source order. A chunk without an exit
|
|
243
|
+
// code but with a session id is still running: its result comes from a later poll.
|
|
244
|
+
perCmd = chunks.map((c) => {
|
|
245
|
+
const output = String(c.output ?? "").slice(-6000);
|
|
246
|
+
if (typeof c.exit_code === "number") return { exitCode: c.exit_code, output };
|
|
247
|
+
if (c.session_id != null) return { pending: String(c.session_id), output };
|
|
248
|
+
return { exitCode: null, output };
|
|
249
|
+
});
|
|
250
|
+
} else if (cmds.length > 1 && !call.parallel && codedChunks.length === cmds.length) {
|
|
251
|
+
perCmd = codedChunks.map((c) => ({ exitCode: c.exit_code, output: String(c.output ?? "").slice(-6000) }));
|
|
252
|
+
} else if (cmds.length > 1 && indexed(text, cmds.length)) {
|
|
253
|
+
// labelled sections, one per command: `0: …` or `=== RESULT 1 exit=0 ===`
|
|
254
|
+
perCmd = indexed(text, cmds.length).map((r) => ({ exitCode: r.exitCode, output: r.output.slice(-6000) }));
|
|
255
|
+
} else if (cmds.length > 1 && codedChunks.length >= cmds.length && codedChunks.every((c) => c.exit_code === 0)) {
|
|
256
|
+
// can't tell which output is whose, but every command exited 0: share the status only
|
|
257
|
+
perCmd = cmds.map(() => ({ exitCode: 0, output: "" }));
|
|
258
|
+
} else if (cmds.length > 1) {
|
|
259
|
+
// Unlabelled output from several commands. Only a test runner prints a runner summary,
|
|
260
|
+
// so with exactly one test command and no command that could echo saved text (a log,
|
|
261
|
+
// a fixture), any summary in the output is that test's.
|
|
262
|
+
const tests = cmds.filter((a) => isTestCommand(a.ev.cmd));
|
|
263
|
+
const echoes = cmds.some((a) => !isTestCommand(a.ev.cmd) && ECHOES.test(a.ev.cmd));
|
|
264
|
+
if (tests.length === 1 && !echoes) perCmd = cmds.map((a) => ({ exitCode: null, output: a === tests[0] ? text.slice(-6000) : "" }));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!call.emitted) {
|
|
268
|
+
// emit the cell's actions in source order, each command followed by its result
|
|
269
|
+
call.emitted = true;
|
|
270
|
+
let k = 0;
|
|
271
|
+
for (const a of call.actions) {
|
|
272
|
+
if (a.type === "patch") {
|
|
273
|
+
if (!PATCH_FAILED.test(text)) for (const e of a.edits) push({ ...e, ts: ts ?? e.ts });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// one timestamp for the whole cell, so sorting can't reorder its actions
|
|
277
|
+
a.ev.calledAt = a.ev.ts;
|
|
278
|
+
a.ev.ts = ts ?? a.ev.ts;
|
|
279
|
+
push(a.ev);
|
|
280
|
+
started?.(a.ev); // its CommandExecution item, if it already arrived
|
|
281
|
+
a.k = k++;
|
|
282
|
+
if (!stillRunning && perCmd) emitResult(a, perCmd[a.k], ts, wall, cmds.length, push, running);
|
|
283
|
+
}
|
|
284
|
+
} else if (!stillRunning) {
|
|
285
|
+
for (const a of cmds) if (!a.done && perCmd) emitResult(a, perCmd[a.k ?? 0], ts, wall, cmds.length, push, running);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// a cell that finished without a usable mapping: its commands' results are unknown
|
|
289
|
+
if (!stillRunning && !polling) for (const a of cmds) if (!a.done && !a.pending && !a.ev.resolved) emitResult(a, { exitCode: null, output: "" }, ts, wall, cmds.length, push);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function emitResult(action, r, ts, wall, n, push, running) {
|
|
293
|
+
if (!r || action.done || action.ev.resolved) return;
|
|
294
|
+
if (r.pending) {
|
|
295
|
+
// still running: the result arrives when the agent polls this session
|
|
296
|
+
running?.set(r.pending, action);
|
|
297
|
+
action.pending = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
action.done = true;
|
|
301
|
+
const began = action.ev.calledAt ?? action.ev.ts;
|
|
302
|
+
const ms = n === 1 && wall ? Math.round(Number(wall[1]) * 1000) : ts && began ? ts - began : null;
|
|
303
|
+
push({ kind: "result", of: action.ev, ts, exitCode: r.exitCode, isError: null, output: r.output, ms });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** write_stdin polls deliver the result of a command started earlier. */
|
|
307
|
+
function pollResults(call, text, ts, push, running) {
|
|
308
|
+
for (const sid of call.stdinSessions) {
|
|
309
|
+
const action = running.get(sid);
|
|
310
|
+
if (!action || action.ev.resolved) continue;
|
|
311
|
+
const coded = jsonChunks(text).filter((c) => typeof c.exit_code === "number");
|
|
312
|
+
const code = coded.length ? coded[coded.length - 1].exit_code : /"exit_code"\s*:\s*(-?\d+)/.exec(text)?.[1];
|
|
313
|
+
if (code == null) continue;
|
|
314
|
+
running.delete(sid);
|
|
315
|
+
action.done = true;
|
|
316
|
+
push({ kind: "result", of: action.ev, ts, exitCode: Number(code), isError: null, output: text.slice(-6000), ms: ts && action.ev.ts ? ts - (action.ev.calledAt ?? action.ev.ts) : null });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Split `0: …\n1: …` output into one section per index, if there are exactly n of them. */
|
|
321
|
+
function indexed(text, n) {
|
|
322
|
+
const body = text.replace(/^[\s\S]*?\nOutput:\n/, "\n");
|
|
323
|
+
const sections = (marks, base, codeOf) => {
|
|
324
|
+
if (marks.length !== n || marks.some((m, i) => Number(m[1]) !== i + base)) return null;
|
|
325
|
+
return marks.map((m, i) => ({ output: body.slice(m.index + m[0].length, i + 1 < n ? marks[i + 1].index : undefined), exitCode: codeOf(m) }));
|
|
326
|
+
};
|
|
327
|
+
// `=== RESULT 1 exit=0 ===`, numbered from 0 or 1, with the command's exit code
|
|
328
|
+
const results = [...body.matchAll(/(?:^|\n)=+\s*RESULT\s+(\d+)(?:\s+exit=(-?\d+))?\s*=+[^\n]*/gi)];
|
|
329
|
+
const code = (m) => (m[2] !== undefined ? Number(m[2]) : null);
|
|
330
|
+
const labelled = sections(results, 0, code) || sections(results, 1, code);
|
|
331
|
+
if (labelled) return labelled;
|
|
332
|
+
return sections([...body.matchAll(/(?:^|\n)(\d+): ?/g)], 0, () => null);
|
|
333
|
+
}
|
|
334
|
+
/** JSON objects printed one per line by code-mode cells ({"exit_code":0,"output":"…"}). */
|
|
335
|
+
function jsonChunks(text) {
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const line of text.split("\n")) {
|
|
338
|
+
const t = line.trim();
|
|
339
|
+
if (!t.startsWith("{") || !t.endsWith("}")) continue;
|
|
340
|
+
try {
|
|
341
|
+
let o = JSON.parse(t);
|
|
342
|
+
// Promise.allSettled results arrive wrapped: {"i":0,"result":{"status":"fulfilled","value":{…}}}
|
|
343
|
+
const i = Number.isInteger(o?.i) ? o.i : undefined;
|
|
344
|
+
if (o?.result && typeof o.result === "object") o = o.result;
|
|
345
|
+
if (o?.status && o.value && typeof o.value === "object") o = o.value;
|
|
346
|
+
if (o && typeof o === "object" && ("exit_code" in o || "session_id" in o || "output" in o)) out.push(i === undefined ? o : { ...o, i });
|
|
347
|
+
} catch {}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isHumanPrompt(text) {
|
|
353
|
+
if (!text.trim()) return false;
|
|
354
|
+
return !/^\s*(?:<[a-z_-]+[\s>]|# AGENTS\.md|\[Image)/i.test(text);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function outputText(out) {
|
|
358
|
+
if (typeof out === "string") {
|
|
359
|
+
// classic format wraps {"output": "...", "metadata": {"exit_code": n}}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
if (Array.isArray(out)) return out.map((c) => c.text || "").join("\n");
|
|
363
|
+
if (out && typeof out === "object") return JSON.stringify(out);
|
|
364
|
+
return "";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function classicCommand(args) {
|
|
368
|
+
let a = args;
|
|
369
|
+
if (typeof a === "string") {
|
|
370
|
+
try {
|
|
371
|
+
a = JSON.parse(a);
|
|
372
|
+
} catch {
|
|
373
|
+
return a;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const c = a?.command ?? a?.cmd;
|
|
377
|
+
if (Array.isArray(c)) return c.length >= 3 && /^-l?c$/.test(c[1]) ? c[c.length - 1] : c.join(" ");
|
|
378
|
+
return typeof c === "string" ? c : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Pull string-literal `cmd` arguments out of a code-mode JS cell (with positions if asked). */
|
|
382
|
+
export function execCommands(js, withPos = false) {
|
|
383
|
+
if (typeof js !== "string") return [];
|
|
384
|
+
const cmds = [];
|
|
385
|
+
const rx = /exec_command\(\s*\{[^]*?\b"?cmd"?\s*:\s*/g;
|
|
386
|
+
let m;
|
|
387
|
+
while ((m = rx.exec(js))) {
|
|
388
|
+
const lit = readStringLiteral(js, rx.lastIndex);
|
|
389
|
+
if (lit == null) continue;
|
|
390
|
+
// the call's own `workdir` argument, if it has one (before the next call starts)
|
|
391
|
+
const rest = js.slice(rx.lastIndex, js.indexOf("exec_command(", rx.lastIndex) >>> 0 || undefined);
|
|
392
|
+
const wd = /\bworkdir"?\s*:\s*/.exec(rest);
|
|
393
|
+
const workdir = wd ? readStringLiteral(rest, wd.index + wd[0].length) : undefined;
|
|
394
|
+
cmds.push(withPos ? { pos: m.index, cmd: lit, workdir } : lit);
|
|
395
|
+
}
|
|
396
|
+
return cmds;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readStringLiteral(src, i) {
|
|
400
|
+
const q = src[i];
|
|
401
|
+
if (q !== '"' && q !== "'" && q !== "`") return null;
|
|
402
|
+
let out = "";
|
|
403
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
404
|
+
const ch = src[j];
|
|
405
|
+
if (ch === "\\") {
|
|
406
|
+
const n = src[++j];
|
|
407
|
+
out += n === "n" ? "\n" : n === "t" ? "\t" : n === "u" ? String.fromCharCode(parseInt(src.slice(j + 1, (j += 4) + 1), 16)) : n;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (ch === q) return out;
|
|
411
|
+
out += ch;
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Turn an apply_patch envelope into edit events with removed/added lines. */
|
|
417
|
+
export function patchEdits(text, ts) {
|
|
418
|
+
if (typeof text !== "string" || !text.includes("*** ")) return [];
|
|
419
|
+
// In JSON or JS string literals the patch's newlines are escaped.
|
|
420
|
+
let body = text;
|
|
421
|
+
const start = body.indexOf("*** Begin Patch");
|
|
422
|
+
if (start >= 0) body = body.slice(start);
|
|
423
|
+
if (!body.includes("\n") || /\\n\*\*\* /.test(body)) body = body.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"');
|
|
424
|
+
|
|
425
|
+
const edits = [];
|
|
426
|
+
let cur = null;
|
|
427
|
+
for (const line of body.split("\n")) {
|
|
428
|
+
const h = /^\*\*\* (Add|Update|Delete) File: (.+?)\s*$/.exec(line);
|
|
429
|
+
if (h) {
|
|
430
|
+
cur = { kind: "edit", ts, path: h[2], removed: [], added: [], op: h[1].toLowerCase() };
|
|
431
|
+
edits.push(cur);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!cur || line.startsWith("***") || line.startsWith("@@")) continue;
|
|
435
|
+
if (line.startsWith("-")) cur.removed.push(line.slice(1));
|
|
436
|
+
else if (line.startsWith("+")) cur.added.push(line.slice(1));
|
|
437
|
+
}
|
|
438
|
+
return edits;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function firstMeta(file) {
|
|
442
|
+
try {
|
|
443
|
+
const fd = fs.openSync(file, "r");
|
|
444
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
445
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
446
|
+
fs.closeSync(fd);
|
|
447
|
+
const s = buf.subarray(0, n).toString("utf8");
|
|
448
|
+
const id = /"payload"\s*:\s*\{[^]*?"id"\s*:\s*"([^"]+)"/.exec(s)?.[1];
|
|
449
|
+
const parent = /"parent_thread_id"\s*:\s*"([^"]+)"/.exec(s.split("\n")[0])?.[1];
|
|
450
|
+
return { id, parent_thread_id: parent };
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function walk(dir, out) {
|
|
457
|
+
let entries = [];
|
|
458
|
+
try {
|
|
459
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
460
|
+
} catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
for (const e of entries) {
|
|
464
|
+
const p = path.join(dir, e.name);
|
|
465
|
+
if (e.isDirectory()) walk(p, out);
|
|
466
|
+
else if (e.name.endsWith(".jsonl")) out.push(p);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function mtime(f) {
|
|
471
|
+
try {
|
|
472
|
+
return fs.statSync(f).mtimeMs;
|
|
473
|
+
} catch {
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
}
|