@fanzhen/agent-audit 0.3.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,349 @@
1
+ // Streaming parser: Codex CLI rollout-*.jsonl -> unified events.
2
+ // v0.2.x multi-agent plan (M2); format ground truth:
3
+ // docs/superpowers/research/2026-09-20-codex-format.md (codex-rs source,
4
+ // cross-checked against the 5 local rollout files). Error/stats semantics
5
+ // mirror parsers/claude-code.ts:
6
+ // - blank lines are never counted; unparseable / non-record lines -> linesSkipped
7
+ // - unknown record `type`s (envelope level) and unknown response_item
8
+ // payload types are skipped SILENTLY (counted as nothing): Codex adds
9
+ // variants across CLI versions (world_state appeared mid-2026,
10
+ // task_started <-> turn_started renamed, ...), and `event_msg` — the UI
11
+ // channel — deliberately does NOT persist tool activity
12
+ // (codex-rs rollout/src/policy.rs::should_persist_event_msg). Tool calls
13
+ // live ONLY in `response_item` records, which is all this parser maps.
14
+ // - a missing file REJECTS with the fs error (the engine counts files_failed)
15
+ //
16
+ // Envelope (codex-rs history/src/lib.rs RolloutLine):
17
+ // {"timestamp":"<RFC3339 UTC>","ordinal":N,"type":"<item-type>","payload":{...}}
18
+ // Line order == append order == causal order; ordinals are per-file, 0..n.
19
+ // Event timestamps come from the ENVELOPE, never the payload.
20
+ //
21
+ // Identity (research doc §8):
22
+ // session id : session_meta.session_id (first session_meta wins; `id` holds
23
+ // the same thread uuid) -> filename uuid
24
+ // (rollout-<compact-ts>-<uuid>; fork names append
25
+ // _<rollout-id> AFTER the thread uuid, so the FIRST uuid-shaped
26
+ // group is the thread id) -> plain file stem (claude parity).
27
+ // project : session_meta.cwd -> parent-dir name (claude/kimi parity).
28
+ // Stable for the whole file — turn_context cwd feeds only the
29
+ // ShellCommand.cwd chain below.
30
+ // ShellCommand.cwd: args workdir/working_directory -> most recent
31
+ // turn_context.cwd at or before the line -> session_meta.cwd.
32
+ //
33
+ // Tool mapping (response_item payloads only; shapes source-verified — the
34
+ // local corpus has ZERO tool-call records, so fixtures are synthetic):
35
+ // function_call name "exec_command" -> ShellCommand. `arguments` is a
36
+ // JSON-ENCODED STRING: {"cmd": "...", "workdir"?} — decode the line,
37
+ // then the arguments string (double decode).
38
+ // function_call name "shell" (legacy) -> ShellCommand from
39
+ // {"command": [...], "workdir"?} — array form joined with spaces.
40
+ // local_shell_call -> ShellCommand from
41
+ // action.command (a PARSED array) + action.working_directory.
42
+ // custom_tool_call name "apply_patch" -> one FileWrite per V4A patch entry
43
+ // (Add = full content; Update = diff only, content null; Delete = path
44
+ // only; Move to: = destination path). See parseApplyPatch below.
45
+ // web_search_call -> NetworkRequest(action.query | action.url).
46
+ // function_call with payload.namespace (MCP server), or a "__" in the name
47
+ // (flat "<server>__<tool>"; legacy "mcp__<server>__<tool>") -> McpToolCall.
48
+ // every other function_call / custom_tool_call name (view_image, custom
49
+ // containers, ...) -> skipped for now.
50
+ //
51
+ // .jsonl.zst rollouts (newer builds compress cold sessions; codex decompresses
52
+ // transparently for itself) are NOT supported in v0.2.x — discovery EXCLUDES
53
+ // them (agents.ts), so they cost nothing; a .zst path handed here directly
54
+ // would only produce linesSkipped noise from binary garbage.
55
+ import { createReadStream } from "node:fs";
56
+ import { basename, dirname } from "node:path";
57
+ import { createInterface } from "node:readline";
58
+ import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, } from "../events.js";
59
+ import { ParseStats, parseTs, pyJsonDumps } from "./claude-code.js";
60
+ function isRecord(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ // Python Path.stem parity (same helper as claude-code.ts, kept local).
64
+ function stem(path) {
65
+ const name = basename(path);
66
+ const dot = name.lastIndexOf(".");
67
+ const suffix = dot > 0 && dot < name.length - 1 ? name.slice(dot) : "";
68
+ return suffix ? name.slice(0, name.length - suffix.length) : name;
69
+ }
70
+ const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
71
+ // Filename fallback for the session id: rollout-<compact-ts>-<thread-uuid>
72
+ // (forks: ...-<thread-uuid>_<rollout-id> — the thread id comes FIRST, and the
73
+ // compact timestamp's 4-2-2 digit groups are not uuid-shaped). Anything else
74
+ // falls back to the plain stem, exactly like claude-code.ts.
75
+ function sessionFromFilename(path) {
76
+ const s = stem(path);
77
+ const m = s.match(UUID_RE);
78
+ return m ? m[0] : s;
79
+ }
80
+ // ShellCommand.raw derivation: a string passes through, an array joins with
81
+ // spaces (shell/local_shell_call legacy forms), anything else -> null (skip).
82
+ function joinCmd(raw) {
83
+ if (typeof raw === "string") {
84
+ return raw ? raw : null;
85
+ }
86
+ if (Array.isArray(raw)) {
87
+ const parts = raw.filter((p) => typeof p === "string");
88
+ if (parts.length === 0 || parts.length !== raw.length) {
89
+ return null;
90
+ }
91
+ return parts.join(" ");
92
+ }
93
+ return null;
94
+ }
95
+ function strField(payload, key) {
96
+ const v = payload[key];
97
+ return typeof v === "string" && v ? v : null;
98
+ }
99
+ // McpToolCall.argsHint: pyJsonDumps of the PARSED arguments (claude/kimi
100
+ // parity), sliced to the 200-char hint budget. An arguments value that is not
101
+ // parseable JSON falls back to the raw string (still sliced) — evidence beats
102
+ // formatting when a tool call is malformed.
103
+ function argsHintOf(rawArgs) {
104
+ if (typeof rawArgs === "string") {
105
+ try {
106
+ return pyJsonDumps(JSON.parse(rawArgs)).slice(0, 200);
107
+ }
108
+ catch {
109
+ return rawArgs.slice(0, 200);
110
+ }
111
+ }
112
+ return pyJsonDumps(isRecord(rawArgs) ? rawArgs : {}).slice(0, 200);
113
+ }
114
+ // Line-scan parser for the apply_patch grammar (codex-rs "V4A"). Sections:
115
+ // *** Add File: <path> body lines are "+"-prefixed, content = full file
116
+ // *** Update File: <path> diff hunks (@@ / context / - / +), no full file
117
+ // *** Move to: <path> inside an Update section -> rename destination
118
+ // *** Delete File: <path> no body
119
+ // framed by *** Begin Patch / *** End Patch. Tolerant by design: a missing
120
+ // End Patch still flushes the last section, CRLF is accepted, unknown "*** "
121
+ // markers are ignored, paths are taken verbatim (Windows backslashes and
122
+ // spaces survive), and text with no sections yields [].
123
+ export function parseApplyPatch(patch) {
124
+ const entries = [];
125
+ let kind = null;
126
+ let path = "";
127
+ let moveTo = null;
128
+ const added = [];
129
+ const flush = () => {
130
+ if (kind === null) {
131
+ return;
132
+ }
133
+ if (kind === "update" && moveTo !== null) {
134
+ entries.push({ kind: "move", path: moveTo, content: null });
135
+ }
136
+ else {
137
+ entries.push({
138
+ kind,
139
+ path,
140
+ content: kind === "add" ? added.join("\n") : null,
141
+ });
142
+ }
143
+ kind = null;
144
+ moveTo = null;
145
+ added.length = 0;
146
+ };
147
+ for (const line of patch.split(/\r?\n/)) {
148
+ if (line.startsWith("*** ")) {
149
+ const m = line.match(/^\*\*\* (Add File|Update File|Delete File|Move to): ?(.*)$/);
150
+ if (m) {
151
+ const tag = m[1];
152
+ const target = m[2];
153
+ if (tag === "Move to") {
154
+ if (kind === "update") {
155
+ moveTo = target;
156
+ }
157
+ }
158
+ else {
159
+ flush();
160
+ kind = tag === "Add File" ? "add" : tag === "Update File" ? "update" : "delete";
161
+ path = target;
162
+ }
163
+ }
164
+ else if (line.trim() === "*** End Patch") {
165
+ break;
166
+ }
167
+ // "*** Begin Patch" and unknown markers: framing only, keep scanning
168
+ continue;
169
+ }
170
+ if (kind === "add" && line.startsWith("+")) {
171
+ added.push(line.slice(1));
172
+ }
173
+ // Update/Delete bodies carry no full file content — path-only events.
174
+ }
175
+ flush();
176
+ return entries;
177
+ }
178
+ // --- iterEvents --------------------------------------------------------------
179
+ export async function* iterEvents(path, stats = new ParseStats()) {
180
+ // claude-code parity fallbacks, upgraded in-place by session_meta
181
+ let sessionId = sessionFromFilename(path);
182
+ let project = basename(dirname(path));
183
+ let cwdSession = null; // session_meta.cwd
184
+ let cwdRolling = null; // most recent turn_context.cwd
185
+ let sawMeta = false; // first session_meta wins
186
+ const shellCwd = (workdir) => workdir ?? cwdRolling ?? cwdSession;
187
+ const input = createReadStream(path, { encoding: "utf8" });
188
+ const rl = createInterface({ input, crlfDelay: Infinity });
189
+ let failure = null;
190
+ input.on("error", (err) => {
191
+ if (failure === null) {
192
+ failure = err;
193
+ }
194
+ rl.close();
195
+ });
196
+ try {
197
+ for await (const line of rl) {
198
+ if (failure !== null) {
199
+ throw failure;
200
+ }
201
+ // blank lines never counted, parse failures counted — claude-code order
202
+ const trimmed = line.trim();
203
+ if (!trimmed) {
204
+ continue;
205
+ }
206
+ stats.linesTotal += 1;
207
+ let rec;
208
+ try {
209
+ rec = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ stats.linesSkipped += 1;
213
+ continue;
214
+ }
215
+ if (!isRecord(rec)) {
216
+ stats.linesSkipped += 1;
217
+ continue;
218
+ }
219
+ const type = rec["type"];
220
+ if (type === "session_meta") {
221
+ if (!sawMeta) {
222
+ sawMeta = true;
223
+ const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
224
+ // Python `or` chain: session_id preferred, `id` is the same thread
225
+ // uuid (alias drift across CLI versions)
226
+ const sid = payload["session_id"] || payload["id"];
227
+ if (typeof sid === "string" && sid) {
228
+ sessionId = sid;
229
+ }
230
+ cwdSession = strField(payload, "cwd");
231
+ if (cwdSession) {
232
+ project = cwdSession;
233
+ }
234
+ }
235
+ continue;
236
+ }
237
+ if (type === "turn_context") {
238
+ const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
239
+ const cwd = strField(payload, "cwd");
240
+ if (cwd) {
241
+ cwdRolling = cwd;
242
+ }
243
+ continue;
244
+ }
245
+ if (type !== "response_item") {
246
+ // event_msg / world_state / token_usage_record / unknown future
247
+ // variants: no audited actions — skipped silently, never counted
248
+ continue;
249
+ }
250
+ const payload = isRecord(rec["payload"]) ? rec["payload"] : {};
251
+ const itemType = payload["type"];
252
+ const ts = parseTs(rec["timestamp"]);
253
+ if (itemType === "function_call") {
254
+ const name = payload["name"];
255
+ if (typeof name !== "string" || !name) {
256
+ continue;
257
+ }
258
+ const namespace = strField(payload, "namespace");
259
+ if (namespace) {
260
+ // MCP via the newer explicit namespace field; `name` is the tool
261
+ stats.events += 1;
262
+ yield new McpToolCall(sessionId, project, ts, namespace, name, argsHintOf(payload["arguments"]));
263
+ continue;
264
+ }
265
+ if (name.includes("__")) {
266
+ // MCP via the flat name: "<server>__<tool>", legacy
267
+ // "mcp__<server>__<tool>" — same split as the claude/kimi mcp__
268
+ // handling (split fully, rejoin the remainder).
269
+ const body = name.startsWith("mcp__") ? name.slice("mcp__".length) : name;
270
+ const parts = body.split("__");
271
+ if (parts.length >= 2 && parts[0]) {
272
+ stats.events += 1;
273
+ yield new McpToolCall(sessionId, project, ts, parts[0], parts.slice(1).join("__"), argsHintOf(payload["arguments"]));
274
+ }
275
+ continue;
276
+ }
277
+ if (name === "exec_command" || name === "shell") {
278
+ // `arguments` is a JSON-encoded STRING — second decode
279
+ let args;
280
+ try {
281
+ args = JSON.parse(typeof payload["arguments"] === "string" ? payload["arguments"] : "");
282
+ }
283
+ catch {
284
+ continue; // malformed args: no event, the LINE itself was fine
285
+ }
286
+ if (!isRecord(args)) {
287
+ continue;
288
+ }
289
+ const cmd = joinCmd(name === "exec_command" ? args["cmd"] : args["command"]);
290
+ if (cmd === null) {
291
+ continue;
292
+ }
293
+ stats.events += 1;
294
+ yield new ShellCommand(sessionId, project, ts, cmd, shellCwd(strField(args, "workdir")));
295
+ }
296
+ // every other tool name (view_image, custom tools, ...): skipped
297
+ continue;
298
+ }
299
+ if (itemType === "local_shell_call") {
300
+ const action = isRecord(payload["action"]) ? payload["action"] : null;
301
+ const cmd = action ? joinCmd(action["command"]) : null;
302
+ if (!action || cmd === null) {
303
+ continue;
304
+ }
305
+ stats.events += 1;
306
+ yield new ShellCommand(sessionId, project, ts, cmd, shellCwd(strField(action, "working_directory")));
307
+ continue;
308
+ }
309
+ if (itemType === "custom_tool_call") {
310
+ if (payload["name"] === "apply_patch" && typeof payload["input"] === "string") {
311
+ for (const entry of parseApplyPatch(payload["input"])) {
312
+ if (!entry.path) {
313
+ continue; // malformed section header — nothing to audit
314
+ }
315
+ stats.events += 1;
316
+ yield new FileWrite(sessionId, project, ts, entry.path, null, entry.content);
317
+ }
318
+ }
319
+ continue; // other custom tools: skipped
320
+ }
321
+ if (itemType === "web_search_call") {
322
+ const action = isRecord(payload["action"]) ? payload["action"] : {};
323
+ // search -> query, open_page/find_in_page -> url (Python `or` chain);
324
+ // newer builds may carry queries: [str] instead of singular query
325
+ const queries = action["queries"];
326
+ const url = action["query"] ||
327
+ action["url"] ||
328
+ (Array.isArray(queries) && typeof queries[0] === "string"
329
+ ? queries[0]
330
+ : undefined);
331
+ if (typeof url === "string" && url) {
332
+ stats.events += 1;
333
+ yield new NetworkRequest(sessionId, project, ts, url);
334
+ }
335
+ continue;
336
+ }
337
+ // message / reasoning / function_call_output / unknown payload types:
338
+ // skipped silently (developer messages are injected scaffolding, outputs
339
+ // join their calls only via call_id — v0.2.x keeps evidence call-side)
340
+ }
341
+ if (failure !== null) {
342
+ throw failure;
343
+ }
344
+ }
345
+ finally {
346
+ rl.close();
347
+ input.destroy();
348
+ }
349
+ }
@@ -0,0 +1,221 @@
1
+ // Streaming parser: Kimi-Code wire.jsonl -> unified events.
2
+ // v0.2.x multi-agent plan (M1): TS canonical, no Python parity (Python is
3
+ // frozen at v0.1.1). Error/stats semantics mirror parsers/claude-code.ts:
4
+ // - blank lines are never counted; unparseable lines -> linesSkipped
5
+ // - unknown record types are skipped silently (counted as nothing)
6
+ // - a missing file REJECTS with the fs error (the engine counts files_failed)
7
+ //
8
+ // Wire layout (protocol 1.5, verified against real data 2026-09-20):
9
+ // <root>/wd_<dirname>_<hash>/session_<uuid>/agents/main/wire.jsonl
10
+ // + sibling state.json: {"id":"session_<uuid>","version":2,"cwd":"...",...}
11
+ // Every record has `type`; `context.append_loop_event` wraps an inner event
12
+ // whose `tool.call` members carry {uuid, toolCallId, name, args}. Timestamps
13
+ // are epoch MILLISECONDS on the OUTER record (`time`), not ISO strings.
14
+ //
15
+ // Identity resolution (the wire itself carries NO sessionId/workDir — checked
16
+ // across all record types on 14 real sessions):
17
+ // 1. metadata record sessionId / workDir|cwd (forward compat; not on the
18
+ // wire today, but the first metadata record wins if a future build adds it)
19
+ // 2. sibling state.json id / cwd (the real source on current data)
20
+ // 3. path fallbacks — inside the kimi layout the session-dir name (the
21
+ // "session_<uuid>" dir) and the wd-dir name ARE the identity (a file-stem
22
+ // fallback would collapse every session to "wire"); outside the layout,
23
+ // file stem / parent dir name, exactly like claude-code.ts.
24
+ //
25
+ // Tool mapping (v0.2.x scope — the four event types of the fixed model):
26
+ // Bash -> ShellCommand(args.command)
27
+ // Read/Write/Edit/NotebookEdit -> FileWrite(args.file_path|path|notebook_path,
28
+ // content = args.content|new_string, isConfigPath gate). Kimi's Read uses
29
+ // `args.path` and carries no content; the event model has no FileRead and
30
+ // sensitive-config READS matter as much as writes to the audit (D/C rules
31
+ // gate on isConfigPath), so Read joins the FileWrite mapping per the M1 plan.
32
+ // FetchURL/WebFetch/WebSearch -> NetworkRequest(args.url|query)
33
+ // mcp__* -> McpToolCall (same name convention as claude-code)
34
+ // everything else (Grep/Glob/Skill/WaitFor/TaskStop, ...) -> skipped for now
35
+ import { createReadStream, readFileSync } from "node:fs";
36
+ import { basename, dirname, join } from "node:path";
37
+ import { createInterface } from "node:readline";
38
+ import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, isConfigPath, } from "../events.js";
39
+ import { ParseStats, parseTs, pyJsonDumps } from "./claude-code.js";
40
+ const WRITE_TOOLS = new Set(["Read", "Write", "Edit", "NotebookEdit"]);
41
+ const NETWORK_TOOLS = new Set(["FetchURL", "WebFetch", "WebSearch"]);
42
+ function isRecord(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+ // Python Path.stem parity (same helper as claude-code.ts, kept local).
46
+ function stem(path) {
47
+ const name = basename(path);
48
+ const dot = name.lastIndexOf(".");
49
+ const suffix = dot > 0 && dot < name.length - 1 ? name.slice(dot) : "";
50
+ return suffix ? name.slice(0, name.length - suffix.length) : name;
51
+ }
52
+ // The wire's native `time` is epoch ms (number). Strings keep the identical
53
+ // ISO-shape guard as claude-code's parseTs (JS new Date() alone is far too
54
+ // loose); anything else -> null so report output shows "-".
55
+ function parseKimiTs(raw) {
56
+ if (typeof raw === "number") {
57
+ return Number.isFinite(raw) && raw > 0 ? new Date(raw) : null;
58
+ }
59
+ return parseTs(raw);
60
+ }
61
+ // Sibling state.json {"id","cwd"} — the real per-session identity source.
62
+ // Missing/unreadable/corrupt -> null (silent fallback, never an error).
63
+ function readStateJson(path) {
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
66
+ if (!isRecord(parsed)) {
67
+ return null;
68
+ }
69
+ const id = parsed["id"];
70
+ const cwd = parsed["cwd"];
71
+ return {
72
+ id: typeof id === "string" && id ? id : null,
73
+ cwd: typeof cwd === "string" && cwd ? cwd : null,
74
+ };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ // Python json.dumps parity for McpToolCall.argsHint comes from claude-code.ts
81
+ // (pyJsonDumps); the tool->event mapping below mirrors its toEvent().
82
+ function toKimiEvent(name, rawArgs, sid, project, ts, cwd) {
83
+ const args = isRecord(rawArgs) ? rawArgs : {};
84
+ if (name === "Bash") {
85
+ const cmd = args["command"];
86
+ if (typeof cmd === "string" && cmd) {
87
+ return new ShellCommand(sid, project, ts, cmd, cwd);
88
+ }
89
+ return null;
90
+ }
91
+ if (WRITE_TOOLS.has(name)) {
92
+ // Kimi Read uses `path`; claude-style records use `file_path`/`notebook_path`.
93
+ const p = (args["file_path"] || args["path"] || args["notebook_path"]);
94
+ if (typeof p !== "string" || !p) {
95
+ return null;
96
+ }
97
+ let content = typeof args["content"] === "string" ? args["content"] : null;
98
+ if (content === null && typeof args["new_string"] === "string") {
99
+ content = args["new_string"];
100
+ }
101
+ return new FileWrite(sid, project, ts, p, isConfigPath(p), content);
102
+ }
103
+ if (NETWORK_TOOLS.has(name)) {
104
+ const url = (args["url"] || args["query"]);
105
+ if (typeof url === "string" && url) {
106
+ return new NetworkRequest(sid, project, ts, url);
107
+ }
108
+ return null;
109
+ }
110
+ if (name.startsWith("mcp__")) {
111
+ const parts = name.split("__");
112
+ let server;
113
+ let tool;
114
+ if (parts.length >= 3) {
115
+ server = parts[1];
116
+ tool = parts.slice(2).join("__");
117
+ }
118
+ else {
119
+ server = "?";
120
+ tool = name;
121
+ }
122
+ return new McpToolCall(sid, project, ts, server, tool, pyJsonDumps(args).slice(0, 200));
123
+ }
124
+ return null; // Grep/Glob/Skill/WaitFor/... — see mapping note in the header
125
+ }
126
+ export async function* iterEvents(path, stats = new ParseStats()) {
127
+ // <sessionDir>/agents/<agentDir>/wire.jsonl — only trust the kimi layout
128
+ // (and its sibling state.json) when the path actually has that shape.
129
+ const agentsDir = dirname(dirname(path));
130
+ const inKimiLayout = basename(agentsDir) === "agents";
131
+ const sessionDir = inKimiLayout ? dirname(agentsDir) : null;
132
+ const state = sessionDir ? readStateJson(join(sessionDir, "state.json")) : null;
133
+ // fallback identity (claude-code parity, kimi-layout aware — see header)
134
+ let sessionId = inKimiLayout && sessionDir ? basename(sessionDir) : stem(path);
135
+ let project = inKimiLayout && sessionDir ? basename(dirname(sessionDir)) : basename(dirname(path));
136
+ let workDir = null; // ShellCommand.cwd — the session workDir
137
+ if (state?.id) {
138
+ sessionId = state.id;
139
+ }
140
+ if (state?.cwd) {
141
+ workDir = state.cwd;
142
+ project = state.cwd;
143
+ }
144
+ const input = createReadStream(path, { encoding: "utf8" });
145
+ const rl = createInterface({ input, crlfDelay: Infinity });
146
+ let failure = null;
147
+ let sawMetadata = false; // first metadata record wins
148
+ input.on("error", (err) => {
149
+ if (failure === null) {
150
+ failure = err;
151
+ }
152
+ rl.close();
153
+ });
154
+ try {
155
+ for await (const line of rl) {
156
+ if (failure !== null) {
157
+ throw failure;
158
+ }
159
+ // blank lines never counted, parse failures counted — claude-code order
160
+ const trimmed = line.trim();
161
+ if (!trimmed) {
162
+ continue;
163
+ }
164
+ stats.linesTotal += 1;
165
+ let rec;
166
+ try {
167
+ rec = JSON.parse(trimmed);
168
+ }
169
+ catch {
170
+ stats.linesSkipped += 1;
171
+ continue;
172
+ }
173
+ if (!isRecord(rec)) {
174
+ stats.linesSkipped += 1;
175
+ continue;
176
+ }
177
+ const type = rec["type"];
178
+ if (type === "metadata") {
179
+ if (!sawMetadata) {
180
+ sawMetadata = true;
181
+ const metaSid = rec["sessionId"];
182
+ if (typeof metaSid === "string" && metaSid) {
183
+ sessionId = metaSid;
184
+ }
185
+ const metaWorkDir = rec["workDir"] || rec["cwd"];
186
+ if (typeof metaWorkDir === "string" && metaWorkDir) {
187
+ workDir = metaWorkDir;
188
+ project = metaWorkDir;
189
+ }
190
+ }
191
+ continue;
192
+ }
193
+ // every other record kind only matters as a loop-event wrapper;
194
+ // turn.prompt / profile.bind / llm.* / agent.* etc. are skipped silently
195
+ if (type !== "context.append_loop_event") {
196
+ continue;
197
+ }
198
+ const inner = rec["event"];
199
+ if (!isRecord(inner) || inner["type"] !== "tool.call") {
200
+ // step.begin/end, content.part and tool.result carry no audited action
201
+ continue;
202
+ }
203
+ const name = inner["name"];
204
+ if (typeof name !== "string" || !name) {
205
+ continue;
206
+ }
207
+ const ev = toKimiEvent(name, inner["args"], sessionId, project, parseKimiTs(rec["time"]), workDir);
208
+ if (ev !== null) {
209
+ stats.events += 1;
210
+ yield ev;
211
+ }
212
+ }
213
+ if (failure !== null) {
214
+ throw failure;
215
+ }
216
+ }
217
+ finally {
218
+ rl.close();
219
+ input.destroy();
220
+ }
221
+ }