@swestash/agent-lens 0.8.3 → 0.9.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.
@@ -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
@@ -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,107 @@ 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/pipeline.js
3327
+ import { createHash as createHash4 } from "crypto";
3205
3328
  function newStats() {
3206
3329
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
3207
3330
  }
@@ -3239,7 +3362,7 @@ function skillSummary(body) {
3239
3362
  return null;
3240
3363
  }
3241
3364
  function skillVersionId(name, body) {
3242
- return createHash3("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
3365
+ return createHash4("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
3243
3366
  }
3244
3367
  function pruneExcluded(db, excludedPaths) {
3245
3368
  if (!excludedPaths.length)
@@ -3274,6 +3397,7 @@ function pruneExcluded(db, excludedPaths) {
3274
3397
  db.pragma("foreign_keys = OFF");
3275
3398
  db.transaction(() => {
3276
3399
  db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
3400
+ db.exec("DELETE FROM file_changes WHERE session_id IN (SELECT id FROM _prune)");
3277
3401
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
3278
3402
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
3279
3403
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -3507,6 +3631,7 @@ function rebuildDerived(db, dirty) {
3507
3631
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
3508
3632
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
3509
3633
  db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
3634
+ db.exec("DELETE FROM file_changes WHERE session_id NOT IN (SELECT id FROM sessions)");
3510
3635
  if (!incremental) {
3511
3636
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
3512
3637
  }
@@ -3621,7 +3746,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3621
3746
  }
3622
3747
  let projectId = null;
3623
3748
  if (sessionMeta.cwd) {
3624
- projectId = createHash3("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3749
+ projectId = createHash4("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
3625
3750
  stmts.upsertProject.run({
3626
3751
  id: projectId,
3627
3752
  agent_id: adapter.agentId,
@@ -3660,7 +3785,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
3660
3785
  import { basename as basename3, join as join6 } from "path";
3661
3786
 
3662
3787
  // ../ingest/dist/redact.js
3663
- import { createHash as createHash4, randomBytes } from "crypto";
3788
+ import { createHash as createHash5, randomBytes } from "crypto";
3664
3789
  function parseExcludes(csv) {
3665
3790
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
3666
3791
  }
@@ -3669,17 +3794,17 @@ function isExcludedArchivePath(filePath, excluded) {
3669
3794
  }
3670
3795
 
3671
3796
  // ../ingest/dist/fileread.js
3672
- import { createHash as createHash5 } from "crypto";
3797
+ import { createHash as createHash6 } from "crypto";
3673
3798
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
3674
3799
  import { StringDecoder } from "string_decoder";
3675
3800
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
3676
3801
  var CHUNK2 = 64 * 1024;
3677
3802
  function sha256(buf) {
3678
- return createHash5("sha256").update(buf).digest("hex");
3803
+ return createHash6("sha256").update(buf).digest("hex");
3679
3804
  }
3680
- function sha256File(path2) {
3681
- const fd = openSync2(path2, "r");
3682
- const h = createHash5("sha256");
3805
+ function sha256File(path3) {
3806
+ const fd = openSync2(path3, "r");
3807
+ const h = createHash6("sha256");
3683
3808
  const buf = Buffer.allocUnsafe(CHUNK2);
3684
3809
  try {
3685
3810
  let n;
@@ -3690,8 +3815,8 @@ function sha256File(path2) {
3690
3815
  }
3691
3816
  return h.digest("hex");
3692
3817
  }
3693
- function* streamLines(path2) {
3694
- const fd = openSync2(path2, "r");
3818
+ function* streamLines(path3) {
3819
+ const fd = openSync2(path3, "r");
3695
3820
  const decoder = new StringDecoder("utf8");
3696
3821
  const buf = Buffer.allocUnsafe(CHUNK2);
3697
3822
  let leftover = "";
@@ -3743,8 +3868,8 @@ function discoverSidecars(sourceArchiveDir) {
3743
3868
  }
3744
3869
  return out;
3745
3870
  }
3746
- function sessionFromPath(path2) {
3747
- const parts = path2.split("/");
3871
+ function sessionFromPath(path3) {
3872
+ const parts = path3.split("/");
3748
3873
  const i = parts.indexOf("workflows");
3749
3874
  return i > 0 ? parts[i - 1] : null;
3750
3875
  }
@@ -3791,25 +3916,25 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3791
3916
  total_tokens=excluded.total_tokens, total_tool_calls=excluded.total_tool_calls,
3792
3917
  duration_ms=excluded.duration_ms, started_at=excluded.started_at, ended_at=excluded.ended_at,
3793
3918
  ingested_at=excluded.ingested_at`);
3794
- for (const path2 of paths) {
3919
+ for (const path3 of paths) {
3795
3920
  stats.files++;
3796
- const st = statSync2(path2);
3921
+ const st = statSync2(path3);
3797
3922
  const mtimeMs = Math.trunc(st.mtimeMs);
3798
- const prev = full ? void 0 : getState.get(path2);
3923
+ const prev = full ? void 0 : getState.get(path3);
3799
3924
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3800
3925
  stats.skipped++;
3801
3926
  continue;
3802
3927
  }
3803
3928
  let buf;
3804
3929
  try {
3805
- buf = readFileSync4(path2);
3930
+ buf = readFileSync4(path3);
3806
3931
  } catch {
3807
3932
  stats.malformed++;
3808
3933
  continue;
3809
3934
  }
3810
3935
  const hash2 = sha256(buf);
3811
3936
  if (prev && prev.sha256 === hash2) {
3812
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3937
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3813
3938
  stats.skipped++;
3814
3939
  continue;
3815
3940
  }
@@ -3824,14 +3949,14 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3824
3949
  stats.malformed++;
3825
3950
  continue;
3826
3951
  }
3827
- const runId = strOrNull(d.runId) ?? basename3(path2, ".json");
3952
+ const runId = strOrNull(d.runId) ?? basename3(path3, ".json");
3828
3953
  const startedAt = toIso(d.startTime) ?? toIso(d.timestamp);
3829
3954
  const durationMs2 = intOrNull(d.durationMs);
3830
3955
  const endedAt = startedAt && durationMs2 != null ? new Date(Date.parse(startedAt) + durationMs2).toISOString() : null;
3831
3956
  upsert.run({
3832
3957
  run_id: runId,
3833
3958
  source_id: sourceId,
3834
- session_id: sessionFromPath(path2),
3959
+ session_id: sessionFromPath(path3),
3835
3960
  task_id: strOrNull(d.taskId),
3836
3961
  workflow_name: strOrNull(d.workflowName),
3837
3962
  status: strOrNull(d.status),
@@ -3849,7 +3974,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3849
3974
  ended_at: endedAt,
3850
3975
  ingested_at: now2
3851
3976
  });
3852
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3977
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3853
3978
  stats.upserted++;
3854
3979
  }
3855
3980
  }
@@ -3905,25 +4030,25 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3905
4030
  source_id=excluded.source_id, agent_type=excluded.agent_type,
3906
4031
  agent_description=excluded.agent_description, spawn_depth=excluded.spawn_depth,
3907
4032
  tool_use_id=excluded.tool_use_id, ingested_at=excluded.ingested_at`);
3908
- for (const path2 of paths) {
4033
+ for (const path3 of paths) {
3909
4034
  stats.files++;
3910
- const st = statSync3(path2);
4035
+ const st = statSync3(path3);
3911
4036
  const mtimeMs = Math.trunc(st.mtimeMs);
3912
- const prev = full ? void 0 : getState.get(path2);
4037
+ const prev = full ? void 0 : getState.get(path3);
3913
4038
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3914
4039
  stats.skipped++;
3915
4040
  continue;
3916
4041
  }
3917
4042
  let buf;
3918
4043
  try {
3919
- buf = readFileSync5(path2);
4044
+ buf = readFileSync5(path3);
3920
4045
  } catch {
3921
4046
  stats.malformed++;
3922
4047
  continue;
3923
4048
  }
3924
4049
  const hash2 = sha256(buf);
3925
4050
  if (prev && prev.sha256 === hash2) {
3926
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4051
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3927
4052
  stats.skipped++;
3928
4053
  continue;
3929
4054
  }
@@ -3938,7 +4063,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3938
4063
  stats.malformed++;
3939
4064
  continue;
3940
4065
  }
3941
- const sessionId = basename4(path2, ".meta.json");
4066
+ const sessionId = basename4(path3, ".meta.json");
3942
4067
  upsert.run({
3943
4068
  session_id: sessionId,
3944
4069
  source_id: sourceId,
@@ -3948,7 +4073,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3948
4073
  tool_use_id: strOrNull2(d.toolUseId),
3949
4074
  ingested_at: now2
3950
4075
  });
3951
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4076
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
3952
4077
  stats.upserted++;
3953
4078
  }
3954
4079
  }
@@ -3985,8 +4110,8 @@ function discoverResults(sourceArchiveDir) {
3985
4110
  }
3986
4111
  return out;
3987
4112
  }
3988
- function sessionFromPath2(path2) {
3989
- const parts = path2.split("/");
4113
+ function sessionFromPath2(path3) {
4114
+ const parts = path3.split("/");
3990
4115
  const i = parts.indexOf("tool-results");
3991
4116
  return i > 0 ? parts[i - 1] : null;
3992
4117
  }
@@ -4003,40 +4128,40 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full
4003
4128
  VALUES (@session_id, @name, @path, @bytes, @text, @ingested_at)
4004
4129
  ON CONFLICT(session_id, name) DO UPDATE SET
4005
4130
  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);
4131
+ for (const path3 of paths) {
4132
+ const sessionId = sessionFromPath2(path3);
4008
4133
  if (!sessionId)
4009
4134
  continue;
4010
4135
  stats.files++;
4011
- const st = statSync4(path2);
4136
+ const st = statSync4(path3);
4012
4137
  const mtimeMs = Math.trunc(st.mtimeMs);
4013
- const prev = full ? void 0 : getState.get(path2);
4138
+ const prev = full ? void 0 : getState.get(path3);
4014
4139
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
4015
4140
  stats.skipped++;
4016
4141
  continue;
4017
4142
  }
4018
4143
  let buf;
4019
4144
  try {
4020
- buf = readFileSync6(path2);
4145
+ buf = readFileSync6(path3);
4021
4146
  } catch {
4022
4147
  stats.malformed++;
4023
4148
  continue;
4024
4149
  }
4025
4150
  const hash2 = sha256(buf);
4026
4151
  if (prev && prev.sha256 === hash2) {
4027
- setState.run({ file_path: path2, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4152
+ setState.run({ file_path: path3, size: st.size, mtime_ms: mtimeMs, sha256: hash2, ingested_at: now2 });
4028
4153
  stats.skipped++;
4029
4154
  continue;
4030
4155
  }
4031
4156
  upsert.run({
4032
4157
  session_id: sessionId,
4033
- name: basename5(path2, ".txt"),
4034
- path: path2,
4158
+ name: basename5(path3, ".txt"),
4159
+ path: path3,
4035
4160
  bytes: st.size,
4036
4161
  text: buf.toString("utf8"),
4037
4162
  ingested_at: now2
4038
4163
  });
4039
- 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 });
4040
4165
  stats.upserted++;
4041
4166
  }
4042
4167
  }
@@ -4135,6 +4260,7 @@ function runIngest(argv = process.argv.slice(2)) {
4135
4260
  const classified = classify(db, args.full ? null : expanded);
4136
4261
  const detected = detect(db, args.full ? null : expanded);
4137
4262
  classifyErrors(db, args.full ? null : expanded);
4263
+ const fileChanges = deriveFileChanges(db, args.full ? null : expanded);
4138
4264
  const count = (sql) => db.prepare(sql).get().n;
4139
4265
  const sessions = count("SELECT COUNT(*) n FROM sessions");
4140
4266
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -4160,6 +4286,7 @@ function runIngest(argv = process.argv.slice(2)) {
4160
4286
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
4161
4287
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
4162
4288
  findings=${detected.count} (${sevLine})
4289
+ file_changes=${fileChanges.count}
4163
4290
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
4164
4291
  }
4165
4292
  function runMetrics(argv = process.argv.slice(2)) {
@@ -4176,8 +4303,9 @@ function runMetrics(argv = process.argv.slice(2)) {
4176
4303
  const r = classify(db);
4177
4304
  const d = detect(db);
4178
4305
  const e = classifyErrors(db);
4306
+ const fc = deriveFileChanges(db);
4179
4307
  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}`);
4308
+ 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
4309
  }
4182
4310
 
4183
4311
  // ../server/dist/run.js
@@ -4508,7 +4636,13 @@ function getSession(db, id) {
4508
4636
  session.tool_error_count = toolRows.filter((t) => t.status === "error").length;
4509
4637
  session.tool_rejection_count = toolRows.filter((t) => t.status === "error" && t.error_type && errorKind(t.error_type) === "rejection").length;
4510
4638
  session.tool_failure_count = session.tool_error_count - session.tool_rejection_count;
4511
- return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
4639
+ let fileChanges = [];
4640
+ if (tableExists(db, "file_changes")) {
4641
+ fileChanges = db.prepare(`SELECT fc.id, fc.tool_call_id, fc.turn_id, fc.event_uuid, fc.file_path, fc.tool_name,
4642
+ fc.lines_added, fc.lines_removed, fc.timestamp
4643
+ FROM file_changes fc WHERE fc.session_id = ? ORDER BY fc.timestamp`).all(id);
4644
+ }
4645
+ return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings, file_changes: fileChanges };
4512
4646
  }
4513
4647
  function xmlTag(text, tag) {
4514
4648
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -4644,6 +4778,107 @@ function listSkills(db, f = {}) {
4644
4778
  r.sources = r.sources ? String(r.sources).split(",").filter(Boolean) : [];
4645
4779
  return rows;
4646
4780
  }
4781
+ function listFiles(db, f = {}) {
4782
+ if (!tableExists(db, "file_changes"))
4783
+ return { total: 0, files: [] };
4784
+ const where = [];
4785
+ const params = [];
4786
+ if (f.q && f.q.trim())
4787
+ where.push("fc.file_path LIKE ?"), params.push(`%${f.q.trim()}%`);
4788
+ if (f.project)
4789
+ where.push("fc.project_id = ?"), params.push(f.project);
4790
+ if (f.source)
4791
+ where.push("EXISTS (SELECT 1 FROM sessions s WHERE s.id = fc.session_id AND s.source_id = ?)"), params.push(f.source);
4792
+ const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
4793
+ const grouped = `SELECT fc.project_id, fc.file_path,
4794
+ COUNT(DISTINCT fc.session_id) AS sessions,
4795
+ COUNT(*) AS changes,
4796
+ SUM(fc.lines_added) AS lines_added,
4797
+ SUM(fc.lines_removed) AS lines_removed,
4798
+ MIN(fc.timestamp) AS first_ts,
4799
+ MAX(fc.timestamp) AS last_ts
4800
+ FROM file_changes fc${whereSql}
4801
+ GROUP BY fc.project_id, fc.file_path`;
4802
+ const total = db.prepare(`SELECT COUNT(*) n FROM (${grouped})`).get(...params).n;
4803
+ const ORDER = {
4804
+ last_ts: "last_ts",
4805
+ first_ts: "first_ts",
4806
+ changes: "changes",
4807
+ sessions: "sessions",
4808
+ path: "file_path"
4809
+ };
4810
+ const col = ORDER[f.sort ?? "last_ts"] ?? "last_ts";
4811
+ const dir = f.dir === "asc" ? "ASC" : "DESC";
4812
+ const limit = Math.min(f.limit ?? 50, 200);
4813
+ const offset = f.offset ?? 0;
4814
+ const files = db.prepare(`SELECT g.*, p.path AS project_path FROM (${grouped}) g
4815
+ LEFT JOIN projects p ON p.id = g.project_id
4816
+ ORDER BY ${col} ${dir}, g.file_path ASC LIMIT ? OFFSET ?`).all(...params, limit, offset);
4817
+ return { total, files };
4818
+ }
4819
+ function getFileTimeline(db, filePath, projectId) {
4820
+ if (!tableExists(db, "file_changes"))
4821
+ return null;
4822
+ const params = [filePath];
4823
+ let scope = "fc.file_path = ?";
4824
+ if (projectId)
4825
+ scope += " AND fc.project_id = ?", params.push(projectId);
4826
+ const rows = db.prepare(`SELECT fc.id, fc.session_id, fc.turn_id, fc.event_uuid, fc.tool_name, fc.lines_added,
4827
+ fc.lines_removed, fc.timestamp, fc.project_id,
4828
+ s.ai_title, s.slug, s.source_id, s.started_at, t.seq AS turn_seq, t.prompt_preview,
4829
+ p.path AS project_path
4830
+ FROM file_changes fc
4831
+ JOIN sessions s ON s.id = fc.session_id
4832
+ LEFT JOIN turns t ON t.id = fc.turn_id
4833
+ LEFT JOIN projects p ON p.id = fc.project_id
4834
+ WHERE ${scope} ORDER BY fc.timestamp`).all(...params);
4835
+ if (!rows.length)
4836
+ return null;
4837
+ const cat = tableExists(db, "classifications") ? db.prepare("SELECT category FROM classifications WHERE scope = 'session' AND target_id = ?") : null;
4838
+ const bySession = /* @__PURE__ */ new Map();
4839
+ for (const r of rows) {
4840
+ let g = bySession.get(r.session_id);
4841
+ if (!g) {
4842
+ g = {
4843
+ session_id: r.session_id,
4844
+ title: r.ai_title || r.slug || null,
4845
+ source_id: r.source_id,
4846
+ started_at: r.started_at,
4847
+ category: cat ? cat.get(r.session_id)?.category ?? null : null,
4848
+ changes: []
4849
+ };
4850
+ bySession.set(r.session_id, g);
4851
+ }
4852
+ g.changes.push({
4853
+ id: r.id,
4854
+ turn_id: r.turn_id,
4855
+ turn_seq: r.turn_seq,
4856
+ prompt_preview: r.prompt_preview,
4857
+ event_uuid: r.event_uuid,
4858
+ tool_name: r.tool_name,
4859
+ lines_added: r.lines_added,
4860
+ lines_removed: r.lines_removed,
4861
+ timestamp: r.timestamp
4862
+ });
4863
+ }
4864
+ const sessions = [...bySession.values()].sort((a, b) => {
4865
+ const la = a.changes[a.changes.length - 1].timestamp ?? "";
4866
+ const lb = b.changes[b.changes.length - 1].timestamp ?? "";
4867
+ return la < lb ? 1 : la > lb ? -1 : 0;
4868
+ });
4869
+ return {
4870
+ file_path: filePath,
4871
+ project_id: rows[0].project_id ?? null,
4872
+ project_path: rows[0].project_path ?? null,
4873
+ sessions_count: sessions.length,
4874
+ changes_count: rows.length,
4875
+ lines_added: rows.reduce((a, r) => a + (r.lines_added ?? 0), 0),
4876
+ lines_removed: rows.reduce((a, r) => a + (r.lines_removed ?? 0), 0),
4877
+ first_ts: rows[0].timestamp ?? null,
4878
+ last_ts: rows[rows.length - 1].timestamp ?? null,
4879
+ sessions
4880
+ };
4881
+ }
4647
4882
  function getSkill(db, name) {
4648
4883
  const versions = db.prepare(`SELECT sk.id, sk.base_dir, sk.summary, sk.body, sk.body_bytes, sk.first_seen, sk.last_seen,
4649
4884
  (SELECT COUNT(*) FROM tool_calls tc WHERE tc.skill_id = sk.id) AS call_count
@@ -5360,6 +5595,28 @@ async function createApp(db, opts = {}) {
5360
5595
  return reply.code(404).send({ error: "not found" });
5361
5596
  return result;
5362
5597
  });
5598
+ app.get("/api/files", async (req) => {
5599
+ const q = req.query;
5600
+ const sort = ["last_ts", "first_ts", "changes", "sessions", "path"].find((s) => s === q.sort);
5601
+ return listFiles(db, {
5602
+ q: q.q,
5603
+ source: q.source,
5604
+ project: q.project,
5605
+ sort,
5606
+ dir: q.dir === "asc" ? "asc" : "desc",
5607
+ limit: Math.min(Number(q.limit) || 50, 200),
5608
+ offset: Number(q.offset) || 0
5609
+ });
5610
+ });
5611
+ app.get("/api/file", async (req, reply) => {
5612
+ const q = req.query;
5613
+ if (!q.path)
5614
+ return reply.code(400).send({ error: "missing path" });
5615
+ const result = getFileTimeline(db, q.path, q.project || void 0);
5616
+ if (!result)
5617
+ return reply.code(404).send({ error: "not found" });
5618
+ return result;
5619
+ });
5363
5620
  app.get("/api/sessions/:id/export.md", async (req, reply) => {
5364
5621
  const { id } = req.params;
5365
5622
  const level = parseRedactionLevel(req.query.redact);