@swestash/agent-lens 0.8.3 → 0.9.1

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5
- import { dirname as dirname6, join as join12 } from "path";
4
+ import { existsSync as existsSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5
+ import { dirname as dirname7, join as join13 } from "path";
6
6
  import { fileURLToPath as fileURLToPath3 } from "url";
7
7
 
8
8
  // ../../node_modules/.pnpm/cac@6.7.14/node_modules/cac/dist/index.mjs
@@ -591,7 +591,7 @@ var CAC = class extends EventEmitter {
591
591
  var cac = (name = "") => new CAC(name);
592
592
 
593
593
  // ../core/dist/schema.js
594
- var SCHEMA_VERSION = 13;
594
+ var SCHEMA_VERSION = 14;
595
595
  var SCHEMA_SQL = (
596
596
  /* sql */
597
597
  `
@@ -811,6 +811,29 @@ CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
811
811
  CREATE INDEX IF NOT EXISTS idx_findings_rule ON findings(rule_id);
812
812
  CREATE INDEX IF NOT EXISTS idx_findings_tool_call ON findings(tool_call_id);
813
813
 
814
+ -- File modifications derived from tool calls (ADR-022). One row per (tool_call, file): today
815
+ -- Edit/Write/NotebookEdit are 1:1, but the hash id leaves room for multi-file sources (e.g. a
816
+ -- future Bash-write heuristic) without a schema break. Failed calls (status='error') are excluded
817
+ -- at derivation \u2014 they didn't change the file. Re-runnable like findings: incremental runs DELETE
818
+ -- the dirty sessions' rows and re-INSERT (delete-then-insert). Populated by ingest's
819
+ -- deriveFileChanges (filechanges.ts).
820
+ CREATE TABLE IF NOT EXISTS file_changes (
821
+ id TEXT PRIMARY KEY, -- sha1(tool_call_id + NUL + file_path), 16 hex
822
+ tool_call_id TEXT NOT NULL REFERENCES tool_calls(id),
823
+ session_id TEXT NOT NULL REFERENCES sessions(id),
824
+ turn_id TEXT, -- no FK: rebuildDerived recreates turns (same stance as findings)
825
+ event_uuid TEXT, -- jump-to-transcript anchor (#ev-<uuid>)
826
+ project_id TEXT REFERENCES projects(id), -- from the session, for per-project grouping
827
+ file_path TEXT NOT NULL, -- normalized absolute path
828
+ tool_name TEXT NOT NULL, -- Edit | Write | NotebookEdit
829
+ lines_added INTEGER, -- newline-count delta (NULL when unknowable)
830
+ lines_removed INTEGER,
831
+ timestamp TEXT, -- ISO8601, from the tool call's event
832
+ derive_version INTEGER NOT NULL DEFAULT 1
833
+ );
834
+ CREATE INDEX IF NOT EXISTS idx_file_changes_session ON file_changes(session_id);
835
+ CREATE INDEX IF NOT EXISTS idx_file_changes_file ON file_changes(project_id, file_path, timestamp);
836
+
814
837
  -- Workflow-tool run results, ingested from the sidecar JSON the runner writes next to the launching
815
838
  -- session (\u2026/projects/<enc>/<sessionId>/workflows/wf_<id>.json). The transcript only carries the
816
839
  -- "launched in background" ack for an async run; this sidecar is the authoritative record of how the
@@ -2054,8 +2077,8 @@ function runService(action, params) {
2054
2077
  }
2055
2078
 
2056
2079
  // ../ingest/dist/run.js
2057
- import { readFileSync as readFileSync7, statSync as statSync5, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
2058
- import { dirname as dirname4, join as join9 } from "path";
2080
+ import { readFileSync as readFileSync7, statSync as statSync5, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
2081
+ import { dirname as dirname5, join as join10 } from "path";
2059
2082
 
2060
2083
  // ../ingest/dist/db.js
2061
2084
  import Database from "better-sqlite3";
@@ -2088,6 +2111,7 @@ function resetSchema(db) {
2088
2111
  DROP TRIGGER IF EXISTS events_ad;
2089
2112
  DROP TRIGGER IF EXISTS events_ai;
2090
2113
  DROP TABLE IF EXISTS events_fts;
2114
+ DROP TABLE IF EXISTS file_changes;
2091
2115
  DROP TABLE IF EXISTS findings;
2092
2116
  DROP TABLE IF EXISTS classifications;
2093
2117
  DROP TABLE IF EXISTS token_usage;
@@ -2176,13 +2200,13 @@ function asString(v) {
2176
2200
  function asInt(v) {
2177
2201
  return typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : 0;
2178
2202
  }
2179
- function parentSessionFromPath(path2) {
2180
- const parts = path2.split("/");
2203
+ function parentSessionFromPath(path3) {
2204
+ const parts = path3.split("/");
2181
2205
  const i = parts.indexOf("subagents");
2182
2206
  return i > 0 ? parts[i - 1] : null;
2183
2207
  }
2184
- function workflowRunFromPath(path2) {
2185
- const parts = path2.split("/");
2208
+ function workflowRunFromPath(path3) {
2209
+ const parts = path3.split("/");
2186
2210
  const i = parts.indexOf("workflows");
2187
2211
  return i >= 0 && i + 1 < parts.length ? parts[i + 1] : null;
2188
2212
  }
@@ -2210,18 +2234,18 @@ var ClaudeCodeAdapter = class {
2210
2234
  const addFrom = (projectsDir, isVersion) => {
2211
2235
  const found = [];
2212
2236
  recurseJsonl(projectsDir, found);
2213
- for (const path2 of found) {
2214
- const parts = path2.split("/");
2215
- const sessionId = basename2(path2, ".jsonl");
2237
+ for (const path3 of found) {
2238
+ const parts = path3.split("/");
2239
+ const sessionId = basename2(path3, ".jsonl");
2216
2240
  const encodedDir = parts[parts.length - 2] ?? "";
2217
2241
  files.push({
2218
- path: path2,
2242
+ path: path3,
2219
2243
  sessionId,
2220
2244
  encodedDir,
2221
2245
  isVersion,
2222
2246
  sourceId,
2223
- parentSessionId: parentSessionFromPath(path2),
2224
- workflowRunId: workflowRunFromPath(path2)
2247
+ parentSessionId: parentSessionFromPath(path3),
2248
+ workflowRunId: workflowRunFromPath(path3)
2225
2249
  });
2226
2250
  }
2227
2251
  };
@@ -2659,8 +2683,8 @@ function execGeneratedScript(cmd) {
2659
2683
  const e = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2660
2684
  const interp = new RegExp(`\\b(?:sh|bash|zsh|ksh|dash|source)\\s+["']?${e}\\b|(?:^|[\\n;|&(])\\s*\\.\\s+["']?${e}\\b`);
2661
2685
  const hasPath = /^(\.\/|\/|~\/)/.test(t);
2662
- const path2 = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2663
- if (interp.test(cmd) || path2.test(cmd))
2686
+ const path3 = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2687
+ if (interp.test(cmd) || path3.test(cmd))
2664
2688
  return t;
2665
2689
  }
2666
2690
  return null;
@@ -3200,8 +3224,195 @@ function classifyErrors(db, dirty) {
3200
3224
  return { count, version: ERROR_CLASSIFIER_VERSION };
3201
3225
  }
3202
3226
 
3203
- // ../ingest/dist/pipeline.js
3227
+ // ../ingest/dist/filechanges.js
3204
3228
  import { createHash as createHash3 } from "crypto";
3229
+ import { posix as path2 } from "path";
3230
+ var FILECHANGES_VERSION = 1;
3231
+ var FILE_TOOLS = ["Edit", "Write", "NotebookEdit"];
3232
+ function fileChangeId(toolCallId, filePath) {
3233
+ return createHash3("sha1").update(toolCallId).update("\0").update(filePath).digest("hex").slice(0, 16);
3234
+ }
3235
+ function normalizeFilePath(filePath, projectPath) {
3236
+ if (typeof filePath !== "string" || !filePath)
3237
+ return null;
3238
+ if (filePath.startsWith("/"))
3239
+ return path2.normalize(filePath);
3240
+ if (!projectPath)
3241
+ return null;
3242
+ return path2.normalize(projectPath.replace(/\/$/, "") + "/" + filePath);
3243
+ }
3244
+ function lineCount(s) {
3245
+ if (typeof s !== "string")
3246
+ return null;
3247
+ if (s === "")
3248
+ return 0;
3249
+ let n = 1;
3250
+ for (let i = 0; i < s.length; i++)
3251
+ if (s.charCodeAt(i) === 10)
3252
+ n++;
3253
+ return n;
3254
+ }
3255
+ function deriveFileChanges(db, dirty) {
3256
+ const incremental = dirty != null;
3257
+ if (incremental) {
3258
+ db.exec("DROP TABLE IF EXISTS _dirty_fc");
3259
+ db.exec("CREATE TEMP TABLE _dirty_fc (id TEXT PRIMARY KEY)");
3260
+ const ins = db.prepare("INSERT OR IGNORE INTO _dirty_fc (id) VALUES (?)");
3261
+ db.transaction((ids) => {
3262
+ for (const id of ids)
3263
+ ins.run(id);
3264
+ })(dirty);
3265
+ }
3266
+ const scope = incremental ? " AND tc.session_id IN (SELECT id FROM _dirty_fc)" : "";
3267
+ const proj = /* @__PURE__ */ new Map();
3268
+ for (const r of db.prepare(`SELECT s.id id, p.id pid, p.path path FROM sessions s JOIN projects p ON p.id = s.project_id${incremental ? " WHERE s.id IN (SELECT id FROM _dirty_fc)" : ""}`).all()) {
3269
+ proj.set(r.id, { id: r.pid, path: r.path });
3270
+ }
3271
+ const rows = db.prepare(`SELECT tc.id, tc.session_id, tc.turn_id, tc.event_uuid, tc.tool_name, tc.input_json, tc.status, e.timestamp
3272
+ FROM tool_calls tc LEFT JOIN events e ON e.uuid = tc.event_uuid
3273
+ WHERE tc.tool_name IN (${FILE_TOOLS.map(() => "?").join(",")})${scope}`).all(...FILE_TOOLS);
3274
+ const insert = db.prepare(`INSERT INTO file_changes (id, tool_call_id, session_id, turn_id, event_uuid, project_id, file_path, tool_name, lines_added, lines_removed, timestamp, derive_version)
3275
+ VALUES (@id, @tool_call_id, @session_id, @turn_id, @event_uuid, @project_id, @file_path, @tool_name, @lines_added, @lines_removed, @timestamp, @derive_version)
3276
+ ON CONFLICT(id) DO NOTHING`);
3277
+ const delScope = incremental ? "DELETE FROM file_changes WHERE session_id IN (SELECT id FROM _dirty_fc)" : "DELETE FROM file_changes";
3278
+ const tx = db.transaction(() => {
3279
+ db.exec(delScope);
3280
+ for (const row of rows) {
3281
+ if (row.status === "error")
3282
+ continue;
3283
+ let input = null;
3284
+ try {
3285
+ input = row.input_json ? JSON.parse(row.input_json) : null;
3286
+ } catch {
3287
+ input = null;
3288
+ }
3289
+ if (!input)
3290
+ continue;
3291
+ const p = proj.get(row.session_id);
3292
+ const filePath = normalizeFilePath(input.file_path ?? input.notebook_path, p?.path ?? null);
3293
+ if (!filePath)
3294
+ continue;
3295
+ let added = null;
3296
+ let removed = null;
3297
+ if (row.tool_name === "Edit") {
3298
+ added = lineCount(input.new_string);
3299
+ removed = lineCount(input.old_string);
3300
+ } else if (row.tool_name === "Write") {
3301
+ added = lineCount(input.content);
3302
+ }
3303
+ insert.run({
3304
+ id: fileChangeId(row.id, filePath),
3305
+ tool_call_id: row.id,
3306
+ session_id: row.session_id,
3307
+ turn_id: row.turn_id,
3308
+ event_uuid: row.event_uuid,
3309
+ project_id: p?.id ?? null,
3310
+ file_path: filePath,
3311
+ tool_name: row.tool_name,
3312
+ lines_added: added,
3313
+ lines_removed: removed,
3314
+ timestamp: row.timestamp,
3315
+ derive_version: FILECHANGES_VERSION
3316
+ });
3317
+ }
3318
+ });
3319
+ tx();
3320
+ if (incremental)
3321
+ db.exec("DROP TABLE IF EXISTS _dirty_fc");
3322
+ const count = db.prepare("SELECT COUNT(*) n FROM file_changes").get().n;
3323
+ return { count, version: FILECHANGES_VERSION };
3324
+ }
3325
+
3326
+ // ../ingest/dist/canonicalize.js
3327
+ import { existsSync as existsSync4 } from "fs";
3328
+ import { homedir as homedir3 } from "os";
3329
+ import { createHash as createHash4 } from "crypto";
3330
+ import { dirname as dirname4, join as join6 } from "path";
3331
+ function projectId(agentId, path3) {
3332
+ return createHash4("sha1").update(`${agentId}\0${path3}`).digest("hex").slice(0, 16);
3333
+ }
3334
+ var strip = (p) => p.length > 1 ? p.replace(/\/$/, "") : p;
3335
+ function nearestGitRoot(path3, home) {
3336
+ let cur = strip(path3);
3337
+ while (cur !== home && cur !== "/" && cur.length > 0) {
3338
+ if (existsSync4(join6(cur, ".git")))
3339
+ return cur;
3340
+ const next = dirname4(cur);
3341
+ if (next === cur)
3342
+ break;
3343
+ cur = next;
3344
+ }
3345
+ return null;
3346
+ }
3347
+ function canonicalizeProjects(db, opts = {}) {
3348
+ const home = strip(opts.homeDir ?? homedir3());
3349
+ const rows = db.prepare("SELECT id, agent_id, path FROM projects").all();
3350
+ const byPath = /* @__PURE__ */ new Map();
3351
+ for (const r of rows)
3352
+ byPath.set(strip(r.path), { id: r.id, agent_id: r.agent_id });
3353
+ const withMains = new Set(db.prepare("SELECT DISTINCT project_id FROM sessions WHERE is_sidechain = 0 AND project_id IS NOT NULL").all().map((r) => r.project_id));
3354
+ const memo = /* @__PURE__ */ new Map();
3355
+ function canon(path3) {
3356
+ const p = strip(path3);
3357
+ const hit = memo.get(p);
3358
+ if (hit)
3359
+ return hit;
3360
+ memo.set(p, p);
3361
+ let out = p;
3362
+ if (p !== home) {
3363
+ const git = nearestGitRoot(p, home);
3364
+ if (git) {
3365
+ out = git;
3366
+ } else if (existsSync4(p) || !withMains.has(byPath.get(p)?.id ?? "")) {
3367
+ for (let cur = dirname4(p); cur !== "/" && cur !== home && cur.length > 0; cur = dirname4(cur)) {
3368
+ if (byPath.has(cur)) {
3369
+ out = canon(cur);
3370
+ break;
3371
+ }
3372
+ if (dirname4(cur) === cur)
3373
+ break;
3374
+ }
3375
+ }
3376
+ }
3377
+ memo.set(p, out);
3378
+ return out;
3379
+ }
3380
+ let merged = 0;
3381
+ const upsertProject = db.prepare(`INSERT INTO projects (id, agent_id, path, encoded_dir, first_seen, last_seen)
3382
+ VALUES (?, ?, ?, NULL, NULL, NULL) ON CONFLICT(agent_id, path) DO NOTHING`);
3383
+ const repointSessions = db.prepare("UPDATE sessions SET project_id = ? WHERE project_id = ?");
3384
+ const repointFileChanges = db.prepare("UPDATE file_changes SET project_id = ? WHERE project_id = ?");
3385
+ const touched = /* @__PURE__ */ new Set();
3386
+ db.transaction(() => {
3387
+ for (const r of rows) {
3388
+ const target = canon(r.path);
3389
+ if (target === strip(r.path))
3390
+ continue;
3391
+ const targetId = byPath.get(target)?.id ?? projectId(r.agent_id, target);
3392
+ if (targetId === r.id)
3393
+ continue;
3394
+ if (!byPath.has(target)) {
3395
+ upsertProject.run(targetId, r.agent_id, target);
3396
+ byPath.set(target, { id: targetId, agent_id: r.agent_id });
3397
+ }
3398
+ repointSessions.run(targetId, r.id);
3399
+ repointFileChanges.run(targetId, r.id);
3400
+ touched.add(targetId);
3401
+ merged++;
3402
+ }
3403
+ const refresh = db.prepare(`UPDATE projects SET
3404
+ first_seen = (SELECT MIN(started_at) FROM sessions WHERE project_id = projects.id),
3405
+ last_seen = (SELECT MAX(COALESCE(ended_at, started_at)) FROM sessions WHERE project_id = projects.id)
3406
+ WHERE id = ?`);
3407
+ for (const id of touched)
3408
+ refresh.run(id);
3409
+ })();
3410
+ const removed = db.prepare("DELETE FROM projects WHERE id NOT IN (SELECT DISTINCT project_id FROM sessions WHERE project_id IS NOT NULL)").run().changes;
3411
+ return { merged, removed };
3412
+ }
3413
+
3414
+ // ../ingest/dist/pipeline.js
3415
+ import { createHash as createHash5 } from "crypto";
3205
3416
  function newStats() {
3206
3417
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
3207
3418
  }
@@ -3239,7 +3450,7 @@ function skillSummary(body) {
3239
3450
  return null;
3240
3451
  }
3241
3452
  function skillVersionId(name, body) {
3242
- return createHash3("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
3453
+ return createHash5("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
3243
3454
  }
3244
3455
  function pruneExcluded(db, excludedPaths) {
3245
3456
  if (!excludedPaths.length)
@@ -3274,6 +3485,7 @@ function pruneExcluded(db, excludedPaths) {
3274
3485
  db.pragma("foreign_keys = OFF");
3275
3486
  db.transaction(() => {
3276
3487
  db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
3488
+ db.exec("DELETE FROM file_changes WHERE session_id IN (SELECT id FROM _prune)");
3277
3489
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
3278
3490
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
3279
3491
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -3507,6 +3719,7 @@ function rebuildDerived(db, dirty) {
3507
3719
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
3508
3720
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
3509
3721
  db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
3722
+ db.exec("DELETE FROM file_changes WHERE session_id NOT IN (SELECT id FROM sessions)");
3510
3723
  if (!incremental) {
3511
3724
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
3512
3725
  }
@@ -3619,11 +3832,11 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3619
3832
  result_summary: tr.result_summary ?? null
3620
3833
  });
3621
3834
  }
3622
- let projectId = null;
3835
+ let projectId2 = null;
3623
3836
  if (sessionMeta.cwd) {
3624
- projectId = createHash3("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3837
+ projectId2 = createHash5("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3625
3838
  stmts.upsertProject.run({
3626
- id: projectId,
3839
+ id: projectId2,
3627
3840
  agent_id: adapter.agentId,
3628
3841
  path: sessionMeta.cwd,
3629
3842
  encoded_dir: file.encodedDir || null,
@@ -3634,7 +3847,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3634
3847
  id: file.sessionId,
3635
3848
  agent_id: adapter.agentId,
3636
3849
  source_id: file.sourceId,
3637
- project_id: projectId,
3850
+ project_id: projectId2,
3638
3851
  slug: sessionMeta.slug ?? null,
3639
3852
  ai_title: sessionMeta.ai_title ?? null,
3640
3853
  cli_version: sessionMeta.cli_version ?? null,
@@ -3657,10 +3870,10 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3657
3870
 
3658
3871
  // ../ingest/dist/workflows.js
3659
3872
  import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
3660
- import { basename as basename3, join as join6 } from "path";
3873
+ import { basename as basename3, join as join7 } from "path";
3661
3874
 
3662
3875
  // ../ingest/dist/redact.js
3663
- import { createHash as createHash4, randomBytes } from "crypto";
3876
+ import { createHash as createHash6, randomBytes } from "crypto";
3664
3877
  function parseExcludes(csv) {
3665
3878
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
3666
3879
  }
@@ -3669,17 +3882,17 @@ function isExcludedArchivePath(filePath, excluded) {
3669
3882
  }
3670
3883
 
3671
3884
  // ../ingest/dist/fileread.js
3672
- import { createHash as createHash5 } from "crypto";
3885
+ import { createHash as createHash7 } from "crypto";
3673
3886
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
3674
3887
  import { StringDecoder } from "string_decoder";
3675
3888
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
3676
3889
  var CHUNK2 = 64 * 1024;
3677
3890
  function sha256(buf) {
3678
- return createHash5("sha256").update(buf).digest("hex");
3891
+ return createHash7("sha256").update(buf).digest("hex");
3679
3892
  }
3680
- function sha256File(path2) {
3681
- const fd = openSync2(path2, "r");
3682
- const h = createHash5("sha256");
3893
+ function sha256File(path3) {
3894
+ const fd = openSync2(path3, "r");
3895
+ const h = createHash7("sha256");
3683
3896
  const buf = Buffer.allocUnsafe(CHUNK2);
3684
3897
  try {
3685
3898
  let n;
@@ -3690,8 +3903,8 @@ function sha256File(path2) {
3690
3903
  }
3691
3904
  return h.digest("hex");
3692
3905
  }
3693
- function* streamLines(path2) {
3694
- const fd = openSync2(path2, "r");
3906
+ function* streamLines(path3) {
3907
+ const fd = openSync2(path3, "r");
3695
3908
  const decoder = new StringDecoder("utf8");
3696
3909
  const buf = Buffer.allocUnsafe(CHUNK2);
3697
3910
  let leftover = "";
@@ -3725,7 +3938,7 @@ function walkSidecars(dir, inWorkflows, out) {
3725
3938
  return;
3726
3939
  }
3727
3940
  for (const e of entries) {
3728
- const p = join6(dir, e.name);
3941
+ const p = join7(dir, e.name);
3729
3942
  if (e.isDirectory())
3730
3943
  walkSidecars(p, inWorkflows || e.name === "workflows", out);
3731
3944
  else if (e.isFile() && inWorkflows && /^wf_.+\.json$/.test(e.name))
@@ -3734,17 +3947,17 @@ function walkSidecars(dir, inWorkflows, out) {
3734
3947
  }
3735
3948
  function discoverSidecars(sourceArchiveDir) {
3736
3949
  const out = [];
3737
- walkSidecars(join6(sourceArchiveDir, "projects"), false, out);
3950
+ walkSidecars(join7(sourceArchiveDir, "projects"), false, out);
3738
3951
  try {
3739
- for (const ts of readdirSync3(join6(sourceArchiveDir, ".versions"), { withFileTypes: true }))
3952
+ for (const ts of readdirSync3(join7(sourceArchiveDir, ".versions"), { withFileTypes: true }))
3740
3953
  if (ts.isDirectory())
3741
- walkSidecars(join6(sourceArchiveDir, ts.name, "projects"), false, out);
3954
+ walkSidecars(join7(sourceArchiveDir, ts.name, "projects"), false, out);
3742
3955
  } catch {
3743
3956
  }
3744
3957
  return out;
3745
3958
  }
3746
- function sessionFromPath(path2) {
3747
- const parts = path2.split("/");
3959
+ function sessionFromPath(path3) {
3960
+ const parts = path3.split("/");
3748
3961
  const i = parts.indexOf("workflows");
3749
3962
  return i > 0 ? parts[i - 1] : null;
3750
3963
  }
@@ -3791,25 +4004,25 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3791
4004
  total_tokens=excluded.total_tokens, total_tool_calls=excluded.total_tool_calls,
3792
4005
  duration_ms=excluded.duration_ms, started_at=excluded.started_at, ended_at=excluded.ended_at,
3793
4006
  ingested_at=excluded.ingested_at`);
3794
- for (const path2 of paths) {
4007
+ for (const path3 of paths) {
3795
4008
  stats.files++;
3796
- const st = statSync2(path2);
4009
+ const st = statSync2(path3);
3797
4010
  const mtimeMs = Math.trunc(st.mtimeMs);
3798
- const prev = full ? void 0 : getState.get(path2);
4011
+ const prev = full ? void 0 : getState.get(path3);
3799
4012
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3800
4013
  stats.skipped++;
3801
4014
  continue;
3802
4015
  }
3803
4016
  let buf;
3804
4017
  try {
3805
- buf = readFileSync4(path2);
4018
+ buf = readFileSync4(path3);
3806
4019
  } catch {
3807
4020
  stats.malformed++;
3808
4021
  continue;
3809
4022
  }
3810
4023
  const hash2 = sha256(buf);
3811
4024
  if (prev && prev.sha256 === hash2) {
3812
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4025
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3813
4026
  stats.skipped++;
3814
4027
  continue;
3815
4028
  }
@@ -3824,14 +4037,14 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3824
4037
  stats.malformed++;
3825
4038
  continue;
3826
4039
  }
3827
- const runId = strOrNull(d.runId) ?? basename3(path2, ".json");
4040
+ const runId = strOrNull(d.runId) ?? basename3(path3, ".json");
3828
4041
  const startedAt = toIso(d.startTime) ?? toIso(d.timestamp);
3829
4042
  const durationMs2 = intOrNull(d.durationMs);
3830
4043
  const endedAt = startedAt && durationMs2 != null ? new Date(Date.parse(startedAt) + durationMs2).toISOString() : null;
3831
4044
  upsert.run({
3832
4045
  run_id: runId,
3833
4046
  source_id: sourceId,
3834
- session_id: sessionFromPath(path2),
4047
+ session_id: sessionFromPath(path3),
3835
4048
  task_id: strOrNull(d.taskId),
3836
4049
  workflow_name: strOrNull(d.workflowName),
3837
4050
  status: strOrNull(d.status),
@@ -3849,14 +4062,14 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3849
4062
  ended_at: endedAt,
3850
4063
  ingested_at: now2
3851
4064
  });
3852
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4065
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3853
4066
  stats.upserted++;
3854
4067
  }
3855
4068
  }
3856
4069
 
3857
4070
  // ../ingest/dist/meta.js
3858
4071
  import { readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
3859
- import { basename as basename4, join as join7 } from "path";
4072
+ import { basename as basename4, join as join8 } from "path";
3860
4073
  function newMetaStats() {
3861
4074
  return { files: 0, upserted: 0, skipped: 0, malformed: 0 };
3862
4075
  }
@@ -3868,7 +4081,7 @@ function walkMeta(dir, inSubagents, out) {
3868
4081
  return;
3869
4082
  }
3870
4083
  for (const e of entries) {
3871
- const p = join7(dir, e.name);
4084
+ const p = join8(dir, e.name);
3872
4085
  if (e.isDirectory())
3873
4086
  walkMeta(p, inSubagents || e.name === "subagents", out);
3874
4087
  else if (e.isFile() && inSubagents && /^agent-.+\.meta\.json$/.test(e.name))
@@ -3877,11 +4090,11 @@ function walkMeta(dir, inSubagents, out) {
3877
4090
  }
3878
4091
  function discoverMeta(sourceArchiveDir) {
3879
4092
  const out = [];
3880
- walkMeta(join7(sourceArchiveDir, "projects"), false, out);
4093
+ walkMeta(join8(sourceArchiveDir, "projects"), false, out);
3881
4094
  try {
3882
- for (const ts of readdirSync4(join7(sourceArchiveDir, ".versions"), { withFileTypes: true }))
4095
+ for (const ts of readdirSync4(join8(sourceArchiveDir, ".versions"), { withFileTypes: true }))
3883
4096
  if (ts.isDirectory())
3884
- walkMeta(join7(sourceArchiveDir, ts.name, "projects"), false, out);
4097
+ walkMeta(join8(sourceArchiveDir, ts.name, "projects"), false, out);
3885
4098
  } catch {
3886
4099
  }
3887
4100
  return out;
@@ -3905,25 +4118,25 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3905
4118
  source_id=excluded.source_id, agent_type=excluded.agent_type,
3906
4119
  agent_description=excluded.agent_description, spawn_depth=excluded.spawn_depth,
3907
4120
  tool_use_id=excluded.tool_use_id, ingested_at=excluded.ingested_at`);
3908
- for (const path2 of paths) {
4121
+ for (const path3 of paths) {
3909
4122
  stats.files++;
3910
- const st = statSync3(path2);
4123
+ const st = statSync3(path3);
3911
4124
  const mtimeMs = Math.trunc(st.mtimeMs);
3912
- const prev = full ? void 0 : getState.get(path2);
4125
+ const prev = full ? void 0 : getState.get(path3);
3913
4126
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3914
4127
  stats.skipped++;
3915
4128
  continue;
3916
4129
  }
3917
4130
  let buf;
3918
4131
  try {
3919
- buf = readFileSync5(path2);
4132
+ buf = readFileSync5(path3);
3920
4133
  } catch {
3921
4134
  stats.malformed++;
3922
4135
  continue;
3923
4136
  }
3924
4137
  const hash2 = sha256(buf);
3925
4138
  if (prev && prev.sha256 === hash2) {
3926
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4139
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3927
4140
  stats.skipped++;
3928
4141
  continue;
3929
4142
  }
@@ -3938,7 +4151,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3938
4151
  stats.malformed++;
3939
4152
  continue;
3940
4153
  }
3941
- const sessionId = basename4(path2, ".meta.json");
4154
+ const sessionId = basename4(path3, ".meta.json");
3942
4155
  upsert.run({
3943
4156
  session_id: sessionId,
3944
4157
  source_id: sourceId,
@@ -3948,14 +4161,14 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3948
4161
  tool_use_id: strOrNull2(d.toolUseId),
3949
4162
  ingested_at: now2
3950
4163
  });
3951
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4164
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3952
4165
  stats.upserted++;
3953
4166
  }
3954
4167
  }
3955
4168
 
3956
4169
  // ../ingest/dist/toolResults.js
3957
4170
  import { readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
3958
- import { basename as basename5, join as join8 } from "path";
4171
+ import { basename as basename5, join as join9 } from "path";
3959
4172
  function newToolResultStats() {
3960
4173
  return { files: 0, upserted: 0, skipped: 0, malformed: 0 };
3961
4174
  }
@@ -3967,7 +4180,7 @@ function walkResults(dir, inResults, out) {
3967
4180
  return;
3968
4181
  }
3969
4182
  for (const e of entries) {
3970
- const p = join8(dir, e.name);
4183
+ const p = join9(dir, e.name);
3971
4184
  if (e.isDirectory())
3972
4185
  walkResults(p, inResults || e.name === "tool-results", out);
3973
4186
  else if (e.isFile() && inResults && e.name.endsWith(".txt"))
@@ -3976,17 +4189,17 @@ function walkResults(dir, inResults, out) {
3976
4189
  }
3977
4190
  function discoverResults(sourceArchiveDir) {
3978
4191
  const out = [];
3979
- walkResults(join8(sourceArchiveDir, "projects"), false, out);
4192
+ walkResults(join9(sourceArchiveDir, "projects"), false, out);
3980
4193
  try {
3981
- for (const ts of readdirSync5(join8(sourceArchiveDir, ".versions"), { withFileTypes: true }))
4194
+ for (const ts of readdirSync5(join9(sourceArchiveDir, ".versions"), { withFileTypes: true }))
3982
4195
  if (ts.isDirectory())
3983
- walkResults(join8(sourceArchiveDir, ts.name, "projects"), false, out);
4196
+ walkResults(join9(sourceArchiveDir, ts.name, "projects"), false, out);
3984
4197
  } catch {
3985
4198
  }
3986
4199
  return out;
3987
4200
  }
3988
- function sessionFromPath2(path2) {
3989
- const parts = path2.split("/");
4201
+ function sessionFromPath2(path3) {
4202
+ const parts = path3.split("/");
3990
4203
  const i = parts.indexOf("tool-results");
3991
4204
  return i > 0 ? parts[i - 1] : null;
3992
4205
  }
@@ -4003,40 +4216,40 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full
4003
4216
  VALUES (@session_id, @name, @path, @bytes, @text, @ingested_at)
4004
4217
  ON CONFLICT(session_id, name) DO UPDATE SET
4005
4218
  path=excluded.path, bytes=excluded.bytes, text=excluded.text, ingested_at=excluded.ingested_at`);
4006
- for (const path2 of paths) {
4007
- const sessionId = sessionFromPath2(path2);
4219
+ for (const path3 of paths) {
4220
+ const sessionId = sessionFromPath2(path3);
4008
4221
  if (!sessionId)
4009
4222
  continue;
4010
4223
  stats.files++;
4011
- const st = statSync4(path2);
4224
+ const st = statSync4(path3);
4012
4225
  const mtimeMs = Math.trunc(st.mtimeMs);
4013
- const prev = full ? void 0 : getState.get(path2);
4226
+ const prev = full ? void 0 : getState.get(path3);
4014
4227
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
4015
4228
  stats.skipped++;
4016
4229
  continue;
4017
4230
  }
4018
4231
  let buf;
4019
4232
  try {
4020
- buf = readFileSync6(path2);
4233
+ buf = readFileSync6(path3);
4021
4234
  } catch {
4022
4235
  stats.malformed++;
4023
4236
  continue;
4024
4237
  }
4025
4238
  const hash2 = sha256(buf);
4026
4239
  if (prev && prev.sha256 === hash2) {
4027
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4240
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4028
4241
  stats.skipped++;
4029
4242
  continue;
4030
4243
  }
4031
4244
  upsert.run({
4032
4245
  session_id: sessionId,
4033
- name: basename5(path2, ".txt"),
4034
- path: path2,
4246
+ name: basename5(path3, ".txt"),
4247
+ path: path3,
4035
4248
  bytes: st.size,
4036
4249
  text: buf.toString("utf8"),
4037
4250
  ingested_at: now2
4038
4251
  });
4039
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4252
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4040
4253
  stats.upserted++;
4041
4254
  }
4042
4255
  }
@@ -4058,11 +4271,11 @@ function runIngest(argv = process.argv.slice(2)) {
4058
4271
  const args = parseIngestArgs(argv);
4059
4272
  const archiveRoot = args.archive || resolveArchiveDir();
4060
4273
  const { path: dbPath } = resolveDbPath(args.db);
4061
- if (!existsSync4(archiveRoot)) {
4274
+ if (!existsSync5(archiveRoot)) {
4062
4275
  console.error(`agent-lens-ingest: archive not found: ${archiveRoot} (run 'agent-lens collect' first)`);
4063
4276
  process.exit(1);
4064
4277
  }
4065
- mkdirSync4(dirname4(dbPath), { recursive: true });
4278
+ mkdirSync4(dirname5(dbPath), { recursive: true });
4066
4279
  const db = args.full ? openRaw(dbPath) : openDb(dbPath);
4067
4280
  if (args.full) {
4068
4281
  resetSchema(db);
@@ -4095,7 +4308,7 @@ function runIngest(argv = process.argv.slice(2)) {
4095
4308
  }
4096
4309
  stmts.insAgent.run(adapter.agentId, adapter.agentName);
4097
4310
  stmts.insSource.run({ id: source.label, label: source.label, agent_id: adapter.agentId, config_dir: source.configDir });
4098
- let files = adapter.discover(join9(archiveRoot, source.label), source.label);
4311
+ let files = adapter.discover(join10(archiveRoot, source.label), source.label);
4099
4312
  if (excludedDirs.length)
4100
4313
  files = files.filter((f) => !isExcludedArchivePath(f.path, excludedDirs));
4101
4314
  files.sort((a, b) => Number(a.isVersion) - Number(b.isVersion));
@@ -4127,14 +4340,16 @@ function runIngest(argv = process.argv.slice(2)) {
4127
4340
  ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash: hash2 }, now2, stats);
4128
4341
  dirty.add(file.sessionId);
4129
4342
  }
4130
- ingestWorkflowResults(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
4131
- ingestSubagentMeta(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
4132
- ingestToolResults(db, join9(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
4343
+ ingestWorkflowResults(db, join10(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
4344
+ ingestSubagentMeta(db, join10(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
4345
+ ingestToolResults(db, join10(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
4133
4346
  }
4134
4347
  const expanded = rebuildDerived(db, args.full ? null : dirty);
4348
+ canonicalizeProjects(db);
4135
4349
  const classified = classify(db, args.full ? null : expanded);
4136
4350
  const detected = detect(db, args.full ? null : expanded);
4137
4351
  classifyErrors(db, args.full ? null : expanded);
4352
+ const fileChanges = deriveFileChanges(db, args.full ? null : expanded);
4138
4353
  const count = (sql) => db.prepare(sql).get().n;
4139
4354
  const sessions = count("SELECT COUNT(*) n FROM sessions");
4140
4355
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -4160,6 +4375,7 @@ function runIngest(argv = process.argv.slice(2)) {
4160
4375
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
4161
4376
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
4162
4377
  findings=${detected.count} (${sevLine})
4378
+ file_changes=${fileChanges.count}
4163
4379
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
4164
4380
  }
4165
4381
  function runMetrics(argv = process.argv.slice(2)) {
@@ -4168,21 +4384,23 @@ function runMetrics(argv = process.argv.slice(2)) {
4168
4384
  if (argv[i] === "--db")
4169
4385
  dbArg = argv[++i] ?? "";
4170
4386
  const { path: dbPath } = resolveDbPath(dbArg);
4171
- if (!existsSync4(dbPath)) {
4387
+ if (!existsSync5(dbPath)) {
4172
4388
  console.error(`agent-lens-metrics: db not found: ${dbPath} (run ingest first)`);
4173
4389
  process.exit(1);
4174
4390
  }
4175
4391
  const db = openDb(dbPath);
4392
+ canonicalizeProjects(db);
4176
4393
  const r = classify(db);
4177
4394
  const d = detect(db);
4178
4395
  const e = classifyErrors(db);
4396
+ const fc = deriveFileChanges(db);
4179
4397
  db.close();
4180
- console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} errors_classified=${e.count} error_classifier_version=${e.version} db=${dbPath}`);
4398
+ console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} errors_classified=${e.count} error_classifier_version=${e.version} file_changes=${fc.count} filechanges_version=${FILECHANGES_VERSION} db=${dbPath}`);
4181
4399
  }
4182
4400
 
4183
4401
  // ../server/dist/run.js
4184
- import { existsSync as existsSync7 } from "fs";
4185
- import { dirname as dirname5 } from "path";
4402
+ import { existsSync as existsSync8 } from "fs";
4403
+ import { dirname as dirname6 } from "path";
4186
4404
  import { fileURLToPath as fileURLToPath2 } from "url";
4187
4405
 
4188
4406
  // ../server/dist/db.js
@@ -4508,7 +4726,13 @@ function getSession(db, id) {
4508
4726
  session.tool_error_count = toolRows.filter((t) => t.status === "error").length;
4509
4727
  session.tool_rejection_count = toolRows.filter((t) => t.status === "error" && t.error_type && errorKind(t.error_type) === "rejection").length;
4510
4728
  session.tool_failure_count = session.tool_error_count - session.tool_rejection_count;
4511
- return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
4729
+ let fileChanges = [];
4730
+ if (tableExists(db, "file_changes")) {
4731
+ fileChanges = db.prepare(`SELECT fc.id, fc.tool_call_id, fc.turn_id, fc.event_uuid, fc.file_path, fc.tool_name,
4732
+ fc.lines_added, fc.lines_removed, fc.timestamp
4733
+ FROM file_changes fc WHERE fc.session_id = ? ORDER BY fc.timestamp`).all(id);
4734
+ }
4735
+ return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings, file_changes: fileChanges };
4512
4736
  }
4513
4737
  function xmlTag(text, tag) {
4514
4738
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -4644,6 +4868,107 @@ function listSkills(db, f = {}) {
4644
4868
  r.sources = r.sources ? String(r.sources).split(",").filter(Boolean) : [];
4645
4869
  return rows;
4646
4870
  }
4871
+ function listFiles(db, f = {}) {
4872
+ if (!tableExists(db, "file_changes"))
4873
+ return { total: 0, files: [] };
4874
+ const where = [];
4875
+ const params = [];
4876
+ if (f.q && f.q.trim())
4877
+ where.push("fc.file_path LIKE ?"), params.push(`%${f.q.trim()}%`);
4878
+ if (f.project)
4879
+ where.push("fc.project_id = ?"), params.push(f.project);
4880
+ if (f.source)
4881
+ where.push("EXISTS (SELECT 1 FROM sessions s WHERE s.id = fc.session_id AND s.source_id = ?)"), params.push(f.source);
4882
+ const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
4883
+ const grouped = `SELECT fc.project_id, fc.file_path,
4884
+ COUNT(DISTINCT fc.session_id) AS sessions,
4885
+ COUNT(*) AS changes,
4886
+ SUM(fc.lines_added) AS lines_added,
4887
+ SUM(fc.lines_removed) AS lines_removed,
4888
+ MIN(fc.timestamp) AS first_ts,
4889
+ MAX(fc.timestamp) AS last_ts
4890
+ FROM file_changes fc${whereSql}
4891
+ GROUP BY fc.project_id, fc.file_path`;
4892
+ const total = db.prepare(`SELECT COUNT(*) n FROM (${grouped})`).get(...params).n;
4893
+ const ORDER = {
4894
+ last_ts: "last_ts",
4895
+ first_ts: "first_ts",
4896
+ changes: "changes",
4897
+ sessions: "sessions",
4898
+ path: "file_path"
4899
+ };
4900
+ const col = ORDER[f.sort ?? "last_ts"] ?? "last_ts";
4901
+ const dir = f.dir === "asc" ? "ASC" : "DESC";
4902
+ const limit = Math.min(f.limit ?? 50, 200);
4903
+ const offset = f.offset ?? 0;
4904
+ const files = db.prepare(`SELECT g.*, p.path AS project_path FROM (${grouped}) g
4905
+ LEFT JOIN projects p ON p.id = g.project_id
4906
+ ORDER BY ${col} ${dir}, g.file_path ASC LIMIT ? OFFSET ?`).all(...params, limit, offset);
4907
+ return { total, files };
4908
+ }
4909
+ function getFileTimeline(db, filePath, projectId2) {
4910
+ if (!tableExists(db, "file_changes"))
4911
+ return null;
4912
+ const params = [filePath];
4913
+ let scope = "fc.file_path = ?";
4914
+ if (projectId2)
4915
+ scope += " AND fc.project_id = ?", params.push(projectId2);
4916
+ const rows = db.prepare(`SELECT fc.id, fc.session_id, fc.turn_id, fc.event_uuid, fc.tool_name, fc.lines_added,
4917
+ fc.lines_removed, fc.timestamp, fc.project_id,
4918
+ s.ai_title, s.slug, s.source_id, s.started_at, t.seq AS turn_seq, t.prompt_preview,
4919
+ p.path AS project_path
4920
+ FROM file_changes fc
4921
+ JOIN sessions s ON s.id = fc.session_id
4922
+ LEFT JOIN turns t ON t.id = fc.turn_id
4923
+ LEFT JOIN projects p ON p.id = fc.project_id
4924
+ WHERE ${scope} ORDER BY fc.timestamp`).all(...params);
4925
+ if (!rows.length)
4926
+ return null;
4927
+ const cat = tableExists(db, "classifications") ? db.prepare("SELECT category FROM classifications WHERE scope = 'session' AND target_id = ?") : null;
4928
+ const bySession = /* @__PURE__ */ new Map();
4929
+ for (const r of rows) {
4930
+ let g = bySession.get(r.session_id);
4931
+ if (!g) {
4932
+ g = {
4933
+ session_id: r.session_id,
4934
+ title: r.ai_title || r.slug || null,
4935
+ source_id: r.source_id,
4936
+ started_at: r.started_at,
4937
+ category: cat ? cat.get(r.session_id)?.category ?? null : null,
4938
+ changes: []
4939
+ };
4940
+ bySession.set(r.session_id, g);
4941
+ }
4942
+ g.changes.push({
4943
+ id: r.id,
4944
+ turn_id: r.turn_id,
4945
+ turn_seq: r.turn_seq,
4946
+ prompt_preview: r.prompt_preview,
4947
+ event_uuid: r.event_uuid,
4948
+ tool_name: r.tool_name,
4949
+ lines_added: r.lines_added,
4950
+ lines_removed: r.lines_removed,
4951
+ timestamp: r.timestamp
4952
+ });
4953
+ }
4954
+ const sessions = [...bySession.values()].sort((a, b) => {
4955
+ const la = a.changes[a.changes.length - 1].timestamp ?? "";
4956
+ const lb = b.changes[b.changes.length - 1].timestamp ?? "";
4957
+ return la < lb ? 1 : la > lb ? -1 : 0;
4958
+ });
4959
+ return {
4960
+ file_path: filePath,
4961
+ project_id: rows[0].project_id ?? null,
4962
+ project_path: rows[0].project_path ?? null,
4963
+ sessions_count: sessions.length,
4964
+ changes_count: rows.length,
4965
+ lines_added: rows.reduce((a, r) => a + (r.lines_added ?? 0), 0),
4966
+ lines_removed: rows.reduce((a, r) => a + (r.lines_removed ?? 0), 0),
4967
+ first_ts: rows[0].timestamp ?? null,
4968
+ last_ts: rows[rows.length - 1].timestamp ?? null,
4969
+ sessions
4970
+ };
4971
+ }
4647
4972
  function getSkill(db, name) {
4648
4973
  const versions = db.prepare(`SELECT sk.id, sk.base_dir, sk.summary, sk.body, sk.body_bytes, sk.first_seen, sk.last_seen,
4649
4974
  (SELECT COUNT(*) FROM tool_calls tc WHERE tc.skill_id = sk.id) AS call_count
@@ -4761,7 +5086,7 @@ function securitySummary(db) {
4761
5086
  }
4762
5087
 
4763
5088
  // ../server/dist/app.js
4764
- import { existsSync as existsSync6 } from "fs";
5089
+ import { existsSync as existsSync7 } from "fs";
4765
5090
  import Fastify from "fastify";
4766
5091
  import fastifyStatic from "@fastify/static";
4767
5092
 
@@ -5037,8 +5362,8 @@ function dashboardBreakdowns(db, f) {
5037
5362
  }
5038
5363
 
5039
5364
  // ../server/dist/refresh.js
5040
- import { existsSync as existsSync5, mkdirSync as mkdirSync5 } from "fs";
5041
- import { join as join10 } from "path";
5365
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5 } from "fs";
5366
+ import { join as join11 } from "path";
5042
5367
  function originAllowed(origin) {
5043
5368
  if (!origin)
5044
5369
  return true;
@@ -5059,13 +5384,13 @@ function writeBlocked(headers) {
5059
5384
  }
5060
5385
  function runRefresh() {
5061
5386
  const dataDir = resolveDataDir(findRepoRoot());
5062
- const lock = acquireLock(join10(dataDir, ".agent-lens.lock"));
5387
+ const lock = acquireLock(join11(dataDir, ".agent-lens.lock"));
5063
5388
  if (!lock)
5064
5389
  return null;
5065
5390
  try {
5066
5391
  const collected = collectAll();
5067
5392
  const archiveRoot = resolveArchiveDir();
5068
- if (!existsSync5(archiveRoot))
5393
+ if (!existsSync6(archiveRoot))
5069
5394
  mkdirSync5(archiveRoot, { recursive: true });
5070
5395
  runIngest([]);
5071
5396
  return { ok: true, collected };
@@ -5360,6 +5685,28 @@ async function createApp(db, opts = {}) {
5360
5685
  return reply.code(404).send({ error: "not found" });
5361
5686
  return result;
5362
5687
  });
5688
+ app.get("/api/files", async (req) => {
5689
+ const q = req.query;
5690
+ const sort = ["last_ts", "first_ts", "changes", "sessions", "path"].find((s) => s === q.sort);
5691
+ return listFiles(db, {
5692
+ q: q.q,
5693
+ source: q.source,
5694
+ project: q.project,
5695
+ sort,
5696
+ dir: q.dir === "asc" ? "asc" : "desc",
5697
+ limit: Math.min(Number(q.limit) || 50, 200),
5698
+ offset: Number(q.offset) || 0
5699
+ });
5700
+ });
5701
+ app.get("/api/file", async (req, reply) => {
5702
+ const q = req.query;
5703
+ if (!q.path)
5704
+ return reply.code(400).send({ error: "missing path" });
5705
+ const result = getFileTimeline(db, q.path, q.project || void 0);
5706
+ if (!result)
5707
+ return reply.code(404).send({ error: "not found" });
5708
+ return result;
5709
+ });
5363
5710
  app.get("/api/sessions/:id/export.md", async (req, reply) => {
5364
5711
  const { id } = req.params;
5365
5712
  const level = parseRedactionLevel(req.query.redact);
@@ -5368,7 +5715,7 @@ async function createApp(db, opts = {}) {
5368
5715
  return reply.code(404).send({ error: "not found" });
5369
5716
  reply.header("content-type", "text/markdown; charset=utf-8").header("content-disposition", `attachment; filename="${out.filename}"`).send(out.markdown);
5370
5717
  });
5371
- if (opts.webDist && existsSync6(opts.webDist)) {
5718
+ if (opts.webDist && existsSync7(opts.webDist)) {
5372
5719
  await app.register(fastifyStatic, { root: opts.webDist });
5373
5720
  app.setNotFoundHandler((req, reply) => {
5374
5721
  if (req.url.startsWith("/api/"))
@@ -5381,7 +5728,7 @@ async function createApp(db, opts = {}) {
5381
5728
 
5382
5729
  // ../server/dist/run.js
5383
5730
  async function startServer(overrides = {}) {
5384
- const here = dirname5(fileURLToPath2(import.meta.url));
5731
+ const here = dirname6(fileURLToPath2(import.meta.url));
5385
5732
  const repoRoot = findRepoRoot(here);
5386
5733
  const { path: dbPath } = resolveDbPath(overrides.db);
5387
5734
  const { host, port } = resolveServerConfig({ port: overrides.port, host: overrides.host });
@@ -5390,7 +5737,7 @@ async function startServer(overrides = {}) {
5390
5737
  console.error(`agent-lens-server: refusing to bind non-loopback host '${host}' (privacy). Set AGENT_LENS_ALLOW_NONLOCAL=1 to override.`);
5391
5738
  process.exit(1);
5392
5739
  }
5393
- if (!existsSync7(dbPath)) {
5740
+ if (!existsSync8(dbPath)) {
5394
5741
  console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
5395
5742
  process.exit(1);
5396
5743
  }
@@ -5401,7 +5748,7 @@ async function startServer(overrides = {}) {
5401
5748
  try {
5402
5749
  await app.listen({ host, port });
5403
5750
  console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);
5404
- if (!existsSync7(webDist))
5751
+ if (!existsSync8(webDist))
5405
5752
  console.log(" note: web SPA not built \u2014 run `pnpm --filter @agent-lens/web build`");
5406
5753
  } catch (err) {
5407
5754
  console.error(err);
@@ -5410,12 +5757,12 @@ async function startServer(overrides = {}) {
5410
5757
  }
5411
5758
 
5412
5759
  // src/watch.ts
5413
- import { existsSync as existsSync8 } from "fs";
5414
- import { join as join11 } from "path";
5760
+ import { existsSync as existsSync9 } from "fs";
5761
+ import { join as join12 } from "path";
5415
5762
  import chokidar from "chokidar";
5416
5763
  async function runWatch(opts = {}) {
5417
5764
  const sources = loadSources();
5418
- const lockPath = join11(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5765
+ const lockPath = join12(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5419
5766
  let running = false;
5420
5767
  let pending = false;
5421
5768
  const cycle = () => {
@@ -5448,7 +5795,7 @@ async function runWatch(opts = {}) {
5448
5795
  }
5449
5796
  };
5450
5797
  cycle();
5451
- const watchPaths = sources.map((s) => join11(s.configDir, "projects")).filter((p) => existsSync8(p));
5798
+ const watchPaths = sources.map((s) => join12(s.configDir, "projects")).filter((p) => existsSync9(p));
5452
5799
  if (watchPaths.length === 0) {
5453
5800
  console.error("agent-lens watch: no source 'projects' directories found \u2014 nothing to watch");
5454
5801
  }
@@ -5476,14 +5823,14 @@ async function runWatch(opts = {}) {
5476
5823
  // src/index.ts
5477
5824
  function version() {
5478
5825
  try {
5479
- const pkg = JSON.parse(readFileSync8(join12(dirname6(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
5826
+ const pkg = JSON.parse(readFileSync8(join13(dirname7(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
5480
5827
  return pkg.version ?? "0.0.0";
5481
5828
  } catch {
5482
5829
  return "0.0.0";
5483
5830
  }
5484
5831
  }
5485
5832
  function withLock(fn) {
5486
- const lockPath = join12(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5833
+ const lockPath = join13(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5487
5834
  const lock = acquireLock(lockPath);
5488
5835
  if (!lock) {
5489
5836
  console.error("agent-lens: another collect/ingest run is in progress \u2014 skipping");
@@ -5528,7 +5875,7 @@ cli.command("metrics", "Re-run classification over an already-ingested DB").opti
5528
5875
  });
5529
5876
  cli.command("export <sessionId>", "Write a session to a shareable Markdown file (redacted by default)").option("--out <file>", "Write to this file (default: stdout)").option("--level <level>", "Redaction level: secrets (default) | structure").option("--no-redact", "Verbatim, UNREDACTED output (opt-out)").option("--db <path>", "SQLite DB path (overrides AGENT_LENS_DB / config db)").action((sessionId, opts) => {
5530
5877
  const { path: dbPath } = resolveDbPath(opts.db);
5531
- if (!existsSync9(dbPath)) {
5878
+ if (!existsSync10(dbPath)) {
5532
5879
  console.error(`agent-lens: db not found: ${dbPath} (run ingest first)`);
5533
5880
  process.exit(1);
5534
5881
  }