@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,482 @@
1
+ // M6: Qoder CN local-footprint inventory — "what has Qoder indexed/collected
2
+ // from your repos". TS-only mode on the single-command CLI (--footprint, the
3
+ // --watch precedent): NOT an audit-rule pipeline, an inventory report.
4
+ //
5
+ // PRIVACY POSTURE (this command's whole point, binding design decision): the
6
+ // report LISTS what Qoder collected — repos, absolute file paths, chunk
7
+ // counts, index timestamps — and NEVER reads or prints file CONTENT.
8
+ // - vector chat.db: only chunk_table_512 metadata columns (path + line
9
+ // ranges) and index_record_512.record_time are SELECTed, by explicit
10
+ // column name — never SELECT *; embedding/vector blobs are not touched.
11
+ // - completion .zap segments (which DO hold recoverable source text,
12
+ // research §1.1): counted as files + bytes only; their bytes are never
13
+ // interpreted as text, so no source fragment can reach the output. The
14
+ // per-repo file census comes from chat.db's chunk table instead, which is
15
+ // authoritative and metadata-only.
16
+ // - agent_memory rows: only the workspace NAME columns (scope, scope_id);
17
+ // title/content are never read.
18
+ // - memories/<uid>/projects: file NAMES only; bodies are never opened.
19
+ //
20
+ // Ground truth: docs/superpowers/research/2026-09-20-trae-qoder-kimi-gemini-
21
+ // formats.md §1 + live schema introspection 2026-09-21 (chunk_table_512,
22
+ // index_record_512, file_record, node, edge, agent_memory — mirrored verbatim
23
+ // in test/footprint-fixtures.ts). chunk_table_512 carries NO time column; the
24
+ // vector index timestamps come from index_record_512.record_time (epoch
25
+ // seconds) and are omitted when that column is absent.
26
+ //
27
+ // SQLite access follows the M4 precedent (parsers/zcode.ts): every db is
28
+ // copied (+ -wal/-shm when present) into a fresh tmpdir and the COPY is opened
29
+ // read-write — Qoder may be running and its live dbs are in WAL mode. Every
30
+ // source degrades independently: an unreadable db becomes a report warning,
31
+ // never a crash and never a silent zero.
32
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { tmpdir } from "node:os";
35
+ import { basename, join } from "node:path";
36
+ import Table from "cli-table3";
37
+ import { comparePaths } from "./discovery.js";
38
+ import { loadNodeSqlite } from "./parsers/zcode.js";
39
+ export function defaultQoderRoot() {
40
+ return join(homedir(), ".qoder-cn");
41
+ }
42
+ // --- small helpers ----------------------------------------------------------------
43
+ function isRecord(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+ function epochToIso(raw) {
47
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
48
+ return null;
49
+ }
50
+ // >= 10^12 reads as milliseconds, else seconds (zcode.ts heuristic)
51
+ return new Date(raw >= 1e12 ? raw : raw * 1000).toISOString();
52
+ }
53
+ // Single-value aggregate (COUNT/MIN/MAX ... "AS n"); null on any error —
54
+ // callers turn that into a report warning, never an exception.
55
+ function scalar(db, sql) {
56
+ try {
57
+ const row = db.prepare(sql).all()[0];
58
+ if (isRecord(row) && typeof row["n"] === "number") {
59
+ return row["n"];
60
+ }
61
+ return null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function hasColumn(db, table, column) {
68
+ try {
69
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
70
+ return (Array.isArray(cols) &&
71
+ cols.some((c) => isRecord(c) && c["name"] === column));
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ const PAGE_SIZE = 500;
78
+ function eachRow(db, sql, visit) {
79
+ const stmt = db.prepare(`${sql} LIMIT ? OFFSET ?`);
80
+ let offset = 0;
81
+ for (;;) {
82
+ let rows;
83
+ try {
84
+ rows = stmt.all(PAGE_SIZE, offset);
85
+ }
86
+ catch {
87
+ return;
88
+ }
89
+ if (!Array.isArray(rows) || rows.length === 0) {
90
+ return;
91
+ }
92
+ for (const row of rows) {
93
+ if (isRecord(row)) {
94
+ visit(row);
95
+ }
96
+ }
97
+ if (rows.length < PAGE_SIZE) {
98
+ return;
99
+ }
100
+ offset += PAGE_SIZE;
101
+ }
102
+ }
103
+ function listSubDirs(dir) {
104
+ try {
105
+ return readdirSync(dir, { withFileTypes: true })
106
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith("."))
107
+ .map((e) => e.name)
108
+ .sort(comparePaths);
109
+ }
110
+ catch {
111
+ return []; // absent/racy dir -> nothing to inventory
112
+ }
113
+ }
114
+ // Repo dirs are "<name>_<hash>": 32-hex md5 (vector/completion) or 64-hex
115
+ // sha256 (git/graph). Strips the hash; names without one pass through.
116
+ export function repoNameFromDir(dir) {
117
+ const m = /^(.*)_[0-9a-f]{32,64}$/i.exec(dir);
118
+ return m?.[1] ?? dir;
119
+ }
120
+ // *.md paths RELATIVE to the project dir (names only — bodies never opened).
121
+ function listMdFiles(dir, rel = "") {
122
+ const out = [];
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(dir, { withFileTypes: true });
126
+ }
127
+ catch {
128
+ return out;
129
+ }
130
+ for (const e of entries) {
131
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
132
+ if (e.isDirectory()) {
133
+ if (!e.isSymbolicLink()) {
134
+ out.push(...listMdFiles(join(dir, e.name), relPath));
135
+ }
136
+ }
137
+ else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
138
+ out.push(relPath);
139
+ }
140
+ }
141
+ return out.sort(comparePaths);
142
+ }
143
+ // project dir name (original case) -> md file list, merged across <uid> dirs.
144
+ function scanMemoryProjects(memoriesDir) {
145
+ const projects = new Map();
146
+ for (const uid of listSubDirs(memoriesDir)) {
147
+ const projectsDir = join(memoriesDir, uid, "projects");
148
+ for (const proj of listSubDirs(projectsDir)) {
149
+ const files = listMdFiles(join(projectsDir, proj));
150
+ const prev = projects.get(proj) ?? [];
151
+ projects.set(proj, prev.concat(files));
152
+ }
153
+ }
154
+ return projects;
155
+ }
156
+ // A memory project belongs to a repo when its dir name IS the repo name or
157
+ // ends with "-<repo>" (live layout: D-Project-<path-with-dashes>). Matching is
158
+ // case-insensitive; returns the matched project names for unmatched-tracking.
159
+ function matchMemoryProjects(projects, repoName) {
160
+ const repoLower = repoName.toLowerCase();
161
+ const files = [];
162
+ const matched = [];
163
+ for (const [name, list] of projects) {
164
+ const lower = name.toLowerCase();
165
+ if (lower === repoLower || lower.endsWith(`-${repoLower}`)) {
166
+ matched.push(name);
167
+ files.push(...list);
168
+ }
169
+ }
170
+ return { files: files.sort(comparePaths), matched };
171
+ }
172
+ // M4 copy-then-read: copy db (+ -wal/-shm) into the run's tmpdir and open the
173
+ // COPY read-write; the original store is never written. Fails soft.
174
+ function readDbCopy(mod, work, n, dbPath, read) {
175
+ const copyPath = join(work, `${n}-${basename(dbPath)}`);
176
+ let db = null;
177
+ try {
178
+ copyFileSync(dbPath, copyPath);
179
+ for (const suffix of ["-wal", "-shm"]) {
180
+ if (existsSync(dbPath + suffix)) {
181
+ copyFileSync(dbPath + suffix, copyPath + suffix);
182
+ }
183
+ }
184
+ db = new mod.DatabaseSync(copyPath);
185
+ read(db);
186
+ return null;
187
+ }
188
+ catch (err) {
189
+ return err instanceof Error ? err.message : String(err);
190
+ }
191
+ finally {
192
+ if (db !== null) {
193
+ try {
194
+ db.close();
195
+ }
196
+ catch {
197
+ // already closed / failed open
198
+ }
199
+ }
200
+ }
201
+ }
202
+ // --- inventory ----------------------------------------------------------------------
203
+ export function qoderFootprint(root) {
204
+ const base = root || defaultQoderRoot();
205
+ const warnings = [];
206
+ const repos = new Map();
207
+ const getRepo = (name) => {
208
+ let repo = repos.get(name);
209
+ if (!repo) {
210
+ repo = {
211
+ repo: name,
212
+ vectorChunks: 0,
213
+ vectorFiles: 0,
214
+ vectorFilePaths: [],
215
+ completionStores: 0,
216
+ completionBytes: 0,
217
+ memoryFiles: [],
218
+ vectorFirstSeen: null,
219
+ vectorLastSeen: null,
220
+ };
221
+ repos.set(name, repo);
222
+ }
223
+ return repo;
224
+ };
225
+ const live = existsSync(base);
226
+ const memories = live ? scanMemoryProjects(join(base, "shared_client", "memories")) : new Map();
227
+ const matchedProjects = new Set();
228
+ const mod = loadNodeSqlite();
229
+ if (live && mod === null) {
230
+ warnings.push("node:sqlite unavailable on this Node runtime (>= 22.5 required): index db counts skipped");
231
+ }
232
+ const agentMemories = { count: 0, workspaces: [] };
233
+ let copyCounter = 0;
234
+ const work = live ? mkdtempSync(join(tmpdir(), "agentaudit-qoder-")) : "";
235
+ try {
236
+ if (live) {
237
+ // 1. vector index: chunk_table_512 (paths + line ranges, no content)
238
+ const v5 = join(base, "shared_client", "index", "vector", "v5");
239
+ for (const dir of listSubDirs(v5)) {
240
+ const dbPath = join(v5, dir, "chat.db");
241
+ if (!existsSync(dbPath)) {
242
+ continue;
243
+ }
244
+ const repoName = repoNameFromDir(dir);
245
+ const repo = getRepo(repoName);
246
+ const mem = matchMemoryProjects(memories, repoName);
247
+ repo.memoryFiles = mem.files;
248
+ for (const name of mem.matched) {
249
+ matchedProjects.add(name);
250
+ }
251
+ if (mod === null) {
252
+ continue; // warning already recorded
253
+ }
254
+ copyCounter += 1;
255
+ const n = copyCounter;
256
+ const err = readDbCopy(mod, work, n, dbPath, (db) => {
257
+ const chunks = scalar(db, "SELECT COUNT(*) AS n FROM chunk_table_512");
258
+ const files = scalar(db, "SELECT COUNT(DISTINCT file_path) AS n FROM chunk_table_512");
259
+ if (chunks === null) {
260
+ warnings.push(`${dir}/chat.db: chunk_table_512 unreadable (schema changed?)`);
261
+ return;
262
+ }
263
+ repo.vectorChunks = chunks;
264
+ repo.vectorFiles = files ?? 0;
265
+ const paths = [];
266
+ eachRow(db, "SELECT DISTINCT file_path FROM chunk_table_512 ORDER BY file_path", (row) => {
267
+ const p = row["file_path"];
268
+ if (typeof p === "string" && p) {
269
+ paths.push(p);
270
+ }
271
+ });
272
+ repo.vectorFilePaths = paths.sort(comparePaths);
273
+ // chunk_table has no time column; record_time lives on the
274
+ // per-file index records (epoch seconds). Omitted when absent.
275
+ if (hasColumn(db, "index_record_512", "record_time")) {
276
+ repo.vectorFirstSeen = epochToIso(scalar(db, "SELECT MIN(record_time) AS n FROM index_record_512"));
277
+ repo.vectorLastSeen = epochToIso(scalar(db, "SELECT MAX(record_time) AS n FROM index_record_512"));
278
+ }
279
+ });
280
+ if (err !== null) {
281
+ warnings.push(`${dir}/chat.db: unreadable (${err})`);
282
+ }
283
+ }
284
+ // 2. completion index: .zap segment census — files + bytes ONLY
285
+ const completionV4 = join(base, "shared_client", "index", "completion", "v4");
286
+ for (const dir of listSubDirs(completionV4)) {
287
+ const repo = getRepo(repoNameFromDir(dir));
288
+ const store = join(completionV4, dir, "store");
289
+ let entries;
290
+ try {
291
+ entries = readdirSync(store, { withFileTypes: true });
292
+ }
293
+ catch {
294
+ continue; // no store dir -> zero segments for this repo
295
+ }
296
+ for (const e of entries) {
297
+ if (!e.isFile() || !e.name.endsWith(".zap")) {
298
+ continue;
299
+ }
300
+ repo.completionStores += 1;
301
+ try {
302
+ repo.completionBytes += statSync(join(store, e.name)).size;
303
+ }
304
+ catch {
305
+ // segment removed mid-scan: count the file, not the bytes
306
+ }
307
+ }
308
+ }
309
+ // 3. git index: file_record work queue (paths + hashes)
310
+ const gitV1 = join(base, "shared_client", "index", "git", "v1");
311
+ for (const dir of listSubDirs(gitV1)) {
312
+ const dbPath = join(gitV1, dir, "commit_store.db");
313
+ if (!existsSync(dbPath) || mod === null) {
314
+ continue;
315
+ }
316
+ const repo = getRepo(repoNameFromDir(dir));
317
+ copyCounter += 1;
318
+ const n = copyCounter;
319
+ const err = readDbCopy(mod, work, n, dbPath, (db) => {
320
+ const rows = scalar(db, "SELECT COUNT(*) AS n FROM file_record");
321
+ if (rows === null) {
322
+ warnings.push(`${dir}/commit_store.db: file_record unreadable (schema changed?)`);
323
+ return;
324
+ }
325
+ repo.gitRows = rows;
326
+ });
327
+ if (err !== null) {
328
+ warnings.push(`${dir}/commit_store.db: unreadable (${err})`);
329
+ }
330
+ }
331
+ // 4. symbol graph: node/edge counts
332
+ const graphV4 = join(base, "shared_client", "index", "graph", "v4");
333
+ for (const dir of listSubDirs(graphV4)) {
334
+ const dbPath = join(graphV4, dir, "graph.db");
335
+ if (!existsSync(dbPath) || mod === null) {
336
+ continue;
337
+ }
338
+ const repo = getRepo(repoNameFromDir(dir));
339
+ copyCounter += 1;
340
+ const n = copyCounter;
341
+ const err = readDbCopy(mod, work, n, dbPath, (db) => {
342
+ const nodes = scalar(db, "SELECT COUNT(*) AS n FROM node");
343
+ const edges = scalar(db, "SELECT COUNT(*) AS n FROM edge");
344
+ if (nodes === null && edges === null) {
345
+ warnings.push(`${dir}/graph.db: node/edge unreadable (schema changed?)`);
346
+ return;
347
+ }
348
+ if (nodes !== null) {
349
+ repo.graphNodes = nodes;
350
+ }
351
+ if (edges !== null) {
352
+ repo.graphEdges = edges;
353
+ }
354
+ });
355
+ if (err !== null) {
356
+ warnings.push(`${dir}/graph.db: unreadable (${err})`);
357
+ }
358
+ }
359
+ // 5. agent memory: workspace NAME columns only (scope, scope_id);
360
+ // title/content/keywords are never selected.
361
+ const localDb = join(base, "shared_client", "cache", "db", "local.db");
362
+ if (existsSync(localDb) && mod !== null) {
363
+ copyCounter += 1;
364
+ const n = copyCounter;
365
+ const perWorkspace = new Map();
366
+ const err = readDbCopy(mod, work, n, localDb, (db) => {
367
+ let total = 0;
368
+ eachRow(db, "SELECT scope, scope_id FROM agent_memory ORDER BY rowid", (row) => {
369
+ total += 1;
370
+ const ws = typeof row["scope_id"] === "string" && row["scope_id"]
371
+ ? row["scope_id"]
372
+ : "(unknown)";
373
+ perWorkspace.set(ws, (perWorkspace.get(ws) ?? 0) + 1);
374
+ });
375
+ agentMemories.count = total;
376
+ agentMemories.workspaces = [...perWorkspace.entries()]
377
+ .map(([workspace, memories2]) => ({ workspace, memories: memories2 }))
378
+ .sort((a, b) => comparePaths(a.workspace, b.workspace));
379
+ });
380
+ if (err !== null) {
381
+ warnings.push(`local.db: agent_memory unreadable (${err})`);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ finally {
387
+ if (work) {
388
+ rmSync(work, { recursive: true, force: true });
389
+ }
390
+ }
391
+ // Memory projects that match no indexed repo are still local footprint.
392
+ const unmatchedMemoryProjects = [...memories.entries()]
393
+ .filter(([name]) => !matchedProjects.has(name))
394
+ .map(([project, files]) => ({ project, fileCount: files.length }))
395
+ .sort((a, b) => comparePaths(a.project, b.project));
396
+ return {
397
+ root: base,
398
+ repos: [...repos.values()].sort((a, b) => comparePaths(a.repo, b.repo)),
399
+ agentMemories,
400
+ unmatchedMemoryProjects,
401
+ warnings,
402
+ generatedAt: new Date().toISOString(),
403
+ };
404
+ }
405
+ // --- terminal rendering ---------------------------------------------------------------
406
+ function fmt(n) {
407
+ return n.toLocaleString("en-US");
408
+ }
409
+ function humanBytes(n) {
410
+ if (n < 1024) {
411
+ return `${n} B`;
412
+ }
413
+ if (n < 1024 ** 2) {
414
+ return `${(n / 1024).toFixed(1)} KB`;
415
+ }
416
+ if (n < 1024 ** 3) {
417
+ return `${(n / 1024 ** 2).toFixed(1)} MB`;
418
+ }
419
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
420
+ }
421
+ function timeRange(lo, hi) {
422
+ if (lo === null && hi === null) {
423
+ return "—";
424
+ }
425
+ const day = (s) => s.slice(0, 10);
426
+ if (lo !== null && hi !== null) {
427
+ return lo === hi ? day(lo) : `${day(lo)}..${day(hi)}`;
428
+ }
429
+ return day((lo ?? hi));
430
+ }
431
+ export function renderFootprint(report) {
432
+ const lines = [];
433
+ lines.push("──── agent-audit footprint ────");
434
+ lines.push(`qoder · root ${report.root}`);
435
+ if (report.repos.length === 0) {
436
+ lines.push("no Qoder index data found");
437
+ }
438
+ else {
439
+ const chunks = report.repos.reduce((acc, r) => acc + r.vectorChunks, 0);
440
+ const files = report.repos.reduce((acc, r) => acc + r.vectorFiles, 0);
441
+ const zap = report.repos.reduce((acc, r) => acc + r.completionStores, 0);
442
+ const bytes = report.repos.reduce((acc, r) => acc + r.completionBytes, 0);
443
+ lines.push(`repos ${report.repos.length} · chunks ${fmt(chunks)} · files ${fmt(files)}` +
444
+ ` · zap ${fmt(zap)} files (${humanBytes(bytes)})` +
445
+ ` · memory projects ${fmt(report.repos.reduce((acc, r) => acc + r.memoryFiles.length, 0))} files`);
446
+ const table = new Table({
447
+ head: ["REPO", "CHUNKS", "FILES", "ZAP", "GIT", "GRAPH N/E", "MEM", "INDEXED"],
448
+ style: { head: [], border: [] },
449
+ });
450
+ for (const r of report.repos) {
451
+ table.push([
452
+ r.repo,
453
+ fmt(r.vectorChunks),
454
+ fmt(r.vectorFiles),
455
+ `${r.completionStores} / ${humanBytes(r.completionBytes)}`,
456
+ r.gitRows === undefined ? "—" : fmt(r.gitRows),
457
+ r.graphNodes === undefined && r.graphEdges === undefined
458
+ ? "—"
459
+ : `${fmt(r.graphNodes ?? 0)}/${fmt(r.graphEdges ?? 0)}`,
460
+ String(r.memoryFiles.length),
461
+ timeRange(r.vectorFirstSeen, r.vectorLastSeen),
462
+ ]);
463
+ }
464
+ lines.push(table.toString());
465
+ if (report.agentMemories.count > 0) {
466
+ lines.push(`agent memory notes: ${fmt(report.agentMemories.count)} across ` +
467
+ `${report.agentMemories.workspaces.length} workspace(s): ` +
468
+ report.agentMemories.workspaces.map((w) => w.workspace).join(", "));
469
+ }
470
+ if (report.unmatchedMemoryProjects.length > 0) {
471
+ const names = report.unmatchedMemoryProjects.map((p) => p.project);
472
+ const shown = names.slice(0, 3).join(", ");
473
+ lines.push(`memory projects outside the vector index: ${names.length}` +
474
+ (names.length > 3 ? ` (${shown}, …)` : names.length > 0 ? ` (${shown})` : ""));
475
+ }
476
+ }
477
+ for (const w of report.warnings) {
478
+ lines.push(`[!] ${w}`);
479
+ }
480
+ lines.push("metadata only — file contents are never read or printed");
481
+ return lines.map((l) => `${l}\n`).join("");
482
+ }
@@ -0,0 +1,184 @@
1
+ // Streaming parser: Claude Code session JSONL -> unified events.
2
+ // Faithful port of src/agentaudit/parsers/claude_code.py (Python is the spec).
3
+ import { createReadStream } from "node:fs";
4
+ import { basename, dirname } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { FileWrite, McpToolCall, NetworkRequest, ShellCommand, isConfigPath, } from "../events.js";
7
+ const WRITE_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]);
8
+ const NETWORK_TOOLS = new Set(["WebFetch", "WebSearch"]);
9
+ // Python: @dataclass ParseStats(lines_total, lines_skipped, events)
10
+ export class ParseStats {
11
+ linesTotal = 0;
12
+ linesSkipped = 0;
13
+ events = 0;
14
+ }
15
+ function isRecord(value) {
16
+ // Python isinstance(x, dict) — JSON arrays are NOT dicts.
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ // Python _parse_ts: non-string/empty -> None; try parse; invalid -> None.
20
+ // (Python replaces "Z" with "+00:00" for fromisoformat; new Date() parses the
21
+ // Z suffix natively, so a plain parse keeps the same accept/reject outcome.)
22
+ // Python _parse_ts = datetime.fromisoformat (after Z→+00:00): accepts ISO
23
+ // date/datetime forms only. JS new Date() is far looser (epoch-ms strings,
24
+ // "Sep 19 2026", ...) — guard with an ISO-shape check so the two stay aligned
25
+ // (timestamps flow into report output and the T8 equivalence gate).
26
+ const ISO_SHAPE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
27
+ // Exported for the v0.2.x agent parsers (kimi.ts reuses the identical
28
+ // ISO-shape guard for string timestamps).
29
+ export function parseTs(raw) {
30
+ if (typeof raw !== "string" || raw === "" || !ISO_SHAPE.test(raw)) {
31
+ return null;
32
+ }
33
+ const date = new Date(raw.includes("T") || raw.includes(" ") ? raw.replace(" ", "T") : `${raw}T00:00:00Z`);
34
+ return Number.isNaN(date.getTime()) ? null : date;
35
+ }
36
+ // Python Path.stem: strip the last extension only when it is a real one
37
+ // (0 < dot < len-1), so dotfiles keep their name.
38
+ function stem(path) {
39
+ const name = basename(path);
40
+ const dot = name.lastIndexOf(".");
41
+ const suffix = dot > 0 && dot < name.length - 1 ? name.slice(dot) : "";
42
+ return suffix ? name.slice(0, name.length - suffix.length) : name;
43
+ }
44
+ // Python _to_event: map one tool_use block to a unified event, or null to skip.
45
+ function toEvent(name, rawInput, sid, project, ts, cwd) {
46
+ const inp = isRecord(rawInput) ? rawInput : {};
47
+ if (name === "Bash") {
48
+ const cmd = inp["command"];
49
+ if (typeof cmd === "string" && cmd) {
50
+ return new ShellCommand(sid, project, ts, cmd, cwd);
51
+ }
52
+ return null;
53
+ }
54
+ if (WRITE_TOOLS.has(name)) {
55
+ // Python `or`: missing/empty file_path falls through to notebook_path
56
+ const p = (inp["file_path"] || inp["notebook_path"]);
57
+ if (typeof p !== "string" || !p) {
58
+ return null;
59
+ }
60
+ let content = typeof inp["content"] === "string" ? inp["content"] : null;
61
+ if (content === null && typeof inp["new_string"] === "string") {
62
+ content = inp["new_string"];
63
+ }
64
+ return new FileWrite(sid, project, ts, p, isConfigPath(p), content);
65
+ }
66
+ if (NETWORK_TOOLS.has(name)) {
67
+ const url = (inp["url"] || inp["query"]);
68
+ if (typeof url === "string" && url) {
69
+ return new NetworkRequest(sid, project, ts, url);
70
+ }
71
+ return null;
72
+ }
73
+ if (name.startsWith("mcp__")) {
74
+ // Python name.split("__", 2): at most 2 splits, remainder stays in part 3.
75
+ // (JS split with a limit truncates instead, so split fully and rejoin.)
76
+ const parts = name.split("__");
77
+ let server;
78
+ let tool;
79
+ if (parts.length >= 3) {
80
+ server = parts[1];
81
+ tool = parts.slice(2).join("__");
82
+ }
83
+ else {
84
+ server = "?";
85
+ tool = name;
86
+ }
87
+ return new McpToolCall(sid, project, ts, server, tool, pyJsonDumps(inp).slice(0, 200));
88
+ }
89
+ return null;
90
+ }
91
+ // Python json.dumps defaults: separators (", ", ": ") and ensure_ascii=True
92
+ // (non-ASCII escaped as \uXXXX). JSON.stringify emits neither, which would
93
+ // make args_hint evidence differ from the Python implementation.
94
+ // Also reused by demo.ts, whose output file must be byte-identical to the
95
+ // Python demo's json.dumps records.
96
+ export function pyJsonDumps(v) {
97
+ if (v === null)
98
+ return "null";
99
+ if (typeof v === "string") {
100
+ return JSON.stringify(v).replace(/[\u007f-￿]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
101
+ }
102
+ if (Array.isArray(v))
103
+ return "[" + v.map(pyJsonDumps).join(", ") + "]";
104
+ if (typeof v === "object") {
105
+ const entries = Object.entries(v)
106
+ .map(([k, val]) => JSON.stringify(k) + ": " + pyJsonDumps(val));
107
+ return "{" + entries.join(", ") + "}";
108
+ }
109
+ return JSON.stringify(v); // numbers, booleans
110
+ }
111
+ // Python iter_events as an async generator: readline over a UTF-8 stream
112
+ // (invalid sequences decode to U+FFFD, same as Python errors="replace").
113
+ // File errors (missing path, EISDIR, ...) REJECT so the engine can catch
114
+ // per file the way the Python engine catches OSError.
115
+ export async function* iterEvents(path, stats = new ParseStats()) {
116
+ // Python: fallback_project = path.parent.name (basename, NOT stem — dotted
117
+ // dir names keep their dots); fallback_session = path.stem
118
+ const fallbackProject = basename(dirname(path));
119
+ const fallbackSession = stem(path);
120
+ const input = createReadStream(path, { encoding: "utf8" });
121
+ const rl = createInterface({ input, crlfDelay: Infinity });
122
+ let failure = null;
123
+ input.on("error", (err) => {
124
+ if (failure === null) {
125
+ failure = err;
126
+ }
127
+ // make sure the pending iteration ends even if readline swallows the error
128
+ rl.close();
129
+ });
130
+ try {
131
+ for await (const line of rl) {
132
+ if (failure !== null) {
133
+ throw failure;
134
+ }
135
+ // Python strips the line and skips blanks BEFORE lines_total += 1,
136
+ // so blank lines are never counted.
137
+ const trimmed = line.trim();
138
+ if (!trimmed) {
139
+ continue;
140
+ }
141
+ stats.linesTotal += 1;
142
+ let rec;
143
+ try {
144
+ rec = JSON.parse(trimmed);
145
+ }
146
+ catch {
147
+ stats.linesSkipped += 1;
148
+ continue;
149
+ }
150
+ if (!isRecord(rec)) {
151
+ stats.linesSkipped += 1;
152
+ continue;
153
+ }
154
+ // Python `or` fallbacks: falsy sessionId falls back to the file stem
155
+ const sid = rec["sessionId"] || fallbackSession;
156
+ const ts = parseTs(rec["timestamp"]);
157
+ const rawCwd = rec["cwd"];
158
+ const cwd = typeof rawCwd === "string" ? rawCwd : null;
159
+ const project = cwd ? cwd : fallbackProject;
160
+ const msg = rec["message"];
161
+ const content = isRecord(msg) ? msg["content"] : undefined;
162
+ if (!Array.isArray(content)) {
163
+ continue;
164
+ }
165
+ for (const block of content) {
166
+ if (!isRecord(block) || block["type"] !== "tool_use") {
167
+ continue;
168
+ }
169
+ const ev = toEvent(block["name"] || "", block["input"], sid, project, ts, cwd);
170
+ if (ev !== null) {
171
+ stats.events += 1;
172
+ yield ev;
173
+ }
174
+ }
175
+ }
176
+ if (failure !== null) {
177
+ throw failure;
178
+ }
179
+ }
180
+ finally {
181
+ rl.close();
182
+ input.destroy();
183
+ }
184
+ }