@swestash/agent-lens 0.8.2 → 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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
5
- import { dirname as dirname6, join as join13 } from "path";
5
+ import { dirname as dirname6, join as join12 } 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
@@ -1315,6 +1338,12 @@ function resolveDataDir(repoRoot = findRepoRoot()) {
1315
1338
  return join(repoRoot, "data");
1316
1339
  return userDataDir();
1317
1340
  }
1341
+ function resolveArchiveDir(repoRoot = findRepoRoot()) {
1342
+ return join(resolveDataDir(repoRoot), "archive");
1343
+ }
1344
+ function triageDbFor(dbPath) {
1345
+ return join(dirname(dbPath), "triage.db");
1346
+ }
1318
1347
  function resolveConfigFile(repoRoot, dataDir) {
1319
1348
  const candidates = [
1320
1349
  process.env.AGENT_LENS_CONFIG,
@@ -1662,7 +1691,7 @@ function collectSource(source, archiveBase, excludedDirs, runTs, stats, log) {
1662
1691
  }
1663
1692
  function collectAll(opts = {}) {
1664
1693
  const log = opts.log ?? ((m) => console.log(m));
1665
- const archiveBase = opts.archiveBase ?? join3(resolveDataDir(findRepoRoot()), "archive");
1694
+ const archiveBase = opts.archiveBase ?? resolveArchiveDir();
1666
1695
  const sources = opts.sources ?? loadSources();
1667
1696
  const excludedDirs = (opts.excludes ?? loadExcludes()).map(encodeProjectPath);
1668
1697
  const runTs = opts.runTimestamp ?? collectTimestamp();
@@ -2082,6 +2111,7 @@ function resetSchema(db) {
2082
2111
  DROP TRIGGER IF EXISTS events_ad;
2083
2112
  DROP TRIGGER IF EXISTS events_ai;
2084
2113
  DROP TABLE IF EXISTS events_fts;
2114
+ DROP TABLE IF EXISTS file_changes;
2085
2115
  DROP TABLE IF EXISTS findings;
2086
2116
  DROP TABLE IF EXISTS classifications;
2087
2117
  DROP TABLE IF EXISTS token_usage;
@@ -2170,13 +2200,13 @@ function asString(v) {
2170
2200
  function asInt(v) {
2171
2201
  return typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : 0;
2172
2202
  }
2173
- function parentSessionFromPath(path2) {
2174
- const parts = path2.split("/");
2203
+ function parentSessionFromPath(path3) {
2204
+ const parts = path3.split("/");
2175
2205
  const i = parts.indexOf("subagents");
2176
2206
  return i > 0 ? parts[i - 1] : null;
2177
2207
  }
2178
- function workflowRunFromPath(path2) {
2179
- const parts = path2.split("/");
2208
+ function workflowRunFromPath(path3) {
2209
+ const parts = path3.split("/");
2180
2210
  const i = parts.indexOf("workflows");
2181
2211
  return i >= 0 && i + 1 < parts.length ? parts[i + 1] : null;
2182
2212
  }
@@ -2204,18 +2234,18 @@ var ClaudeCodeAdapter = class {
2204
2234
  const addFrom = (projectsDir, isVersion) => {
2205
2235
  const found = [];
2206
2236
  recurseJsonl(projectsDir, found);
2207
- for (const path2 of found) {
2208
- const parts = path2.split("/");
2209
- const sessionId = basename2(path2, ".jsonl");
2237
+ for (const path3 of found) {
2238
+ const parts = path3.split("/");
2239
+ const sessionId = basename2(path3, ".jsonl");
2210
2240
  const encodedDir = parts[parts.length - 2] ?? "";
2211
2241
  files.push({
2212
- path: path2,
2242
+ path: path3,
2213
2243
  sessionId,
2214
2244
  encodedDir,
2215
2245
  isVersion,
2216
2246
  sourceId,
2217
- parentSessionId: parentSessionFromPath(path2),
2218
- workflowRunId: workflowRunFromPath(path2)
2247
+ parentSessionId: parentSessionFromPath(path3),
2248
+ workflowRunId: workflowRunFromPath(path3)
2219
2249
  });
2220
2250
  }
2221
2251
  };
@@ -2653,8 +2683,8 @@ function execGeneratedScript(cmd) {
2653
2683
  const e = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2654
2684
  const interp = new RegExp(`\\b(?:sh|bash|zsh|ksh|dash|source)\\s+["']?${e}\\b|(?:^|[\\n;|&(])\\s*\\.\\s+["']?${e}\\b`);
2655
2685
  const hasPath = /^(\.\/|\/|~\/)/.test(t);
2656
- const path2 = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
2657
- 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))
2658
2688
  return t;
2659
2689
  }
2660
2690
  return null;
@@ -3194,8 +3224,107 @@ function classifyErrors(db, dirty) {
3194
3224
  return { count, version: ERROR_CLASSIFIER_VERSION };
3195
3225
  }
3196
3226
 
3197
- // ../ingest/dist/pipeline.js
3227
+ // ../ingest/dist/filechanges.js
3198
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";
3199
3328
  function newStats() {
3200
3329
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
3201
3330
  }
@@ -3233,7 +3362,7 @@ function skillSummary(body) {
3233
3362
  return null;
3234
3363
  }
3235
3364
  function skillVersionId(name, body) {
3236
- 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);
3237
3366
  }
3238
3367
  function pruneExcluded(db, excludedPaths) {
3239
3368
  if (!excludedPaths.length)
@@ -3268,6 +3397,7 @@ function pruneExcluded(db, excludedPaths) {
3268
3397
  db.pragma("foreign_keys = OFF");
3269
3398
  db.transaction(() => {
3270
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)");
3271
3401
  for (const t of ["token_usage", "tool_calls", "turns", "events"])
3272
3402
  db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
3273
3403
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
@@ -3501,6 +3631,7 @@ function rebuildDerived(db, dirty) {
3501
3631
  db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
3502
3632
  db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
3503
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)");
3504
3635
  if (!incremental) {
3505
3636
  db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
3506
3637
  }
@@ -3615,7 +3746,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
3615
3746
  }
3616
3747
  let projectId = null;
3617
3748
  if (sessionMeta.cwd) {
3618
- 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);
3619
3750
  stmts.upsertProject.run({
3620
3751
  id: projectId,
3621
3752
  agent_id: adapter.agentId,
@@ -3654,7 +3785,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
3654
3785
  import { basename as basename3, join as join6 } from "path";
3655
3786
 
3656
3787
  // ../ingest/dist/redact.js
3657
- import { createHash as createHash4, randomBytes } from "crypto";
3788
+ import { createHash as createHash5, randomBytes } from "crypto";
3658
3789
  function parseExcludes(csv) {
3659
3790
  return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
3660
3791
  }
@@ -3663,17 +3794,17 @@ function isExcludedArchivePath(filePath, excluded) {
3663
3794
  }
3664
3795
 
3665
3796
  // ../ingest/dist/fileread.js
3666
- import { createHash as createHash5 } from "crypto";
3797
+ import { createHash as createHash6 } from "crypto";
3667
3798
  import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
3668
3799
  import { StringDecoder } from "string_decoder";
3669
3800
  var STREAM_THRESHOLD = 8 * 1024 * 1024;
3670
3801
  var CHUNK2 = 64 * 1024;
3671
3802
  function sha256(buf) {
3672
- return createHash5("sha256").update(buf).digest("hex");
3803
+ return createHash6("sha256").update(buf).digest("hex");
3673
3804
  }
3674
- function sha256File(path2) {
3675
- const fd = openSync2(path2, "r");
3676
- const h = createHash5("sha256");
3805
+ function sha256File(path3) {
3806
+ const fd = openSync2(path3, "r");
3807
+ const h = createHash6("sha256");
3677
3808
  const buf = Buffer.allocUnsafe(CHUNK2);
3678
3809
  try {
3679
3810
  let n;
@@ -3684,8 +3815,8 @@ function sha256File(path2) {
3684
3815
  }
3685
3816
  return h.digest("hex");
3686
3817
  }
3687
- function* streamLines(path2) {
3688
- const fd = openSync2(path2, "r");
3818
+ function* streamLines(path3) {
3819
+ const fd = openSync2(path3, "r");
3689
3820
  const decoder = new StringDecoder("utf8");
3690
3821
  const buf = Buffer.allocUnsafe(CHUNK2);
3691
3822
  let leftover = "";
@@ -3737,8 +3868,8 @@ function discoverSidecars(sourceArchiveDir) {
3737
3868
  }
3738
3869
  return out;
3739
3870
  }
3740
- function sessionFromPath(path2) {
3741
- const parts = path2.split("/");
3871
+ function sessionFromPath(path3) {
3872
+ const parts = path3.split("/");
3742
3873
  const i = parts.indexOf("workflows");
3743
3874
  return i > 0 ? parts[i - 1] : null;
3744
3875
  }
@@ -3785,25 +3916,25 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3785
3916
  total_tokens=excluded.total_tokens, total_tool_calls=excluded.total_tool_calls,
3786
3917
  duration_ms=excluded.duration_ms, started_at=excluded.started_at, ended_at=excluded.ended_at,
3787
3918
  ingested_at=excluded.ingested_at`);
3788
- for (const path2 of paths) {
3919
+ for (const path3 of paths) {
3789
3920
  stats.files++;
3790
- const st = statSync2(path2);
3921
+ const st = statSync2(path3);
3791
3922
  const mtimeMs = Math.trunc(st.mtimeMs);
3792
- const prev = full ? void 0 : getState.get(path2);
3923
+ const prev = full ? void 0 : getState.get(path3);
3793
3924
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3794
3925
  stats.skipped++;
3795
3926
  continue;
3796
3927
  }
3797
3928
  let buf;
3798
3929
  try {
3799
- buf = readFileSync4(path2);
3930
+ buf = readFileSync4(path3);
3800
3931
  } catch {
3801
3932
  stats.malformed++;
3802
3933
  continue;
3803
3934
  }
3804
3935
  const hash2 = sha256(buf);
3805
3936
  if (prev && prev.sha256 === hash2) {
3806
- 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 });
3807
3938
  stats.skipped++;
3808
3939
  continue;
3809
3940
  }
@@ -3818,14 +3949,14 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3818
3949
  stats.malformed++;
3819
3950
  continue;
3820
3951
  }
3821
- const runId = strOrNull(d.runId) ?? basename3(path2, ".json");
3952
+ const runId = strOrNull(d.runId) ?? basename3(path3, ".json");
3822
3953
  const startedAt = toIso(d.startTime) ?? toIso(d.timestamp);
3823
3954
  const durationMs2 = intOrNull(d.durationMs);
3824
3955
  const endedAt = startedAt && durationMs2 != null ? new Date(Date.parse(startedAt) + durationMs2).toISOString() : null;
3825
3956
  upsert.run({
3826
3957
  run_id: runId,
3827
3958
  source_id: sourceId,
3828
- session_id: sessionFromPath(path2),
3959
+ session_id: sessionFromPath(path3),
3829
3960
  task_id: strOrNull(d.taskId),
3830
3961
  workflow_name: strOrNull(d.workflowName),
3831
3962
  status: strOrNull(d.status),
@@ -3843,7 +3974,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
3843
3974
  ended_at: endedAt,
3844
3975
  ingested_at: now2
3845
3976
  });
3846
- 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 });
3847
3978
  stats.upserted++;
3848
3979
  }
3849
3980
  }
@@ -3899,25 +4030,25 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3899
4030
  source_id=excluded.source_id, agent_type=excluded.agent_type,
3900
4031
  agent_description=excluded.agent_description, spawn_depth=excluded.spawn_depth,
3901
4032
  tool_use_id=excluded.tool_use_id, ingested_at=excluded.ingested_at`);
3902
- for (const path2 of paths) {
4033
+ for (const path3 of paths) {
3903
4034
  stats.files++;
3904
- const st = statSync3(path2);
4035
+ const st = statSync3(path3);
3905
4036
  const mtimeMs = Math.trunc(st.mtimeMs);
3906
- const prev = full ? void 0 : getState.get(path2);
4037
+ const prev = full ? void 0 : getState.get(path3);
3907
4038
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3908
4039
  stats.skipped++;
3909
4040
  continue;
3910
4041
  }
3911
4042
  let buf;
3912
4043
  try {
3913
- buf = readFileSync5(path2);
4044
+ buf = readFileSync5(path3);
3914
4045
  } catch {
3915
4046
  stats.malformed++;
3916
4047
  continue;
3917
4048
  }
3918
4049
  const hash2 = sha256(buf);
3919
4050
  if (prev && prev.sha256 === hash2) {
3920
- 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 });
3921
4052
  stats.skipped++;
3922
4053
  continue;
3923
4054
  }
@@ -3932,7 +4063,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3932
4063
  stats.malformed++;
3933
4064
  continue;
3934
4065
  }
3935
- const sessionId = basename4(path2, ".meta.json");
4066
+ const sessionId = basename4(path3, ".meta.json");
3936
4067
  upsert.run({
3937
4068
  session_id: sessionId,
3938
4069
  source_id: sourceId,
@@ -3942,7 +4073,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2,
3942
4073
  tool_use_id: strOrNull2(d.toolUseId),
3943
4074
  ingested_at: now2
3944
4075
  });
3945
- 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 });
3946
4077
  stats.upserted++;
3947
4078
  }
3948
4079
  }
@@ -3979,8 +4110,8 @@ function discoverResults(sourceArchiveDir) {
3979
4110
  }
3980
4111
  return out;
3981
4112
  }
3982
- function sessionFromPath2(path2) {
3983
- const parts = path2.split("/");
4113
+ function sessionFromPath2(path3) {
4114
+ const parts = path3.split("/");
3984
4115
  const i = parts.indexOf("tool-results");
3985
4116
  return i > 0 ? parts[i - 1] : null;
3986
4117
  }
@@ -3997,40 +4128,40 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full
3997
4128
  VALUES (@session_id, @name, @path, @bytes, @text, @ingested_at)
3998
4129
  ON CONFLICT(session_id, name) DO UPDATE SET
3999
4130
  path=excluded.path, bytes=excluded.bytes, text=excluded.text, ingested_at=excluded.ingested_at`);
4000
- for (const path2 of paths) {
4001
- const sessionId = sessionFromPath2(path2);
4131
+ for (const path3 of paths) {
4132
+ const sessionId = sessionFromPath2(path3);
4002
4133
  if (!sessionId)
4003
4134
  continue;
4004
4135
  stats.files++;
4005
- const st = statSync4(path2);
4136
+ const st = statSync4(path3);
4006
4137
  const mtimeMs = Math.trunc(st.mtimeMs);
4007
- const prev = full ? void 0 : getState.get(path2);
4138
+ const prev = full ? void 0 : getState.get(path3);
4008
4139
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
4009
4140
  stats.skipped++;
4010
4141
  continue;
4011
4142
  }
4012
4143
  let buf;
4013
4144
  try {
4014
- buf = readFileSync6(path2);
4145
+ buf = readFileSync6(path3);
4015
4146
  } catch {
4016
4147
  stats.malformed++;
4017
4148
  continue;
4018
4149
  }
4019
4150
  const hash2 = sha256(buf);
4020
4151
  if (prev && prev.sha256 === hash2) {
4021
- 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 });
4022
4153
  stats.skipped++;
4023
4154
  continue;
4024
4155
  }
4025
4156
  upsert.run({
4026
4157
  session_id: sessionId,
4027
- name: basename5(path2, ".txt"),
4028
- path: path2,
4158
+ name: basename5(path3, ".txt"),
4159
+ path: path3,
4029
4160
  bytes: st.size,
4030
4161
  text: buf.toString("utf8"),
4031
4162
  ingested_at: now2
4032
4163
  });
4033
- 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 });
4034
4165
  stats.upserted++;
4035
4166
  }
4036
4167
  }
@@ -4050,8 +4181,7 @@ function parseIngestArgs(argv) {
4050
4181
  }
4051
4182
  function runIngest(argv = process.argv.slice(2)) {
4052
4183
  const args = parseIngestArgs(argv);
4053
- const dataDir = resolveDataDir(findRepoRoot());
4054
- const archiveRoot = args.archive || process.env.AGENT_LENS_ARCHIVE || join9(dataDir, "archive");
4184
+ const archiveRoot = args.archive || resolveArchiveDir();
4055
4185
  const { path: dbPath } = resolveDbPath(args.db);
4056
4186
  if (!existsSync4(archiveRoot)) {
4057
4187
  console.error(`agent-lens-ingest: archive not found: ${archiveRoot} (run 'agent-lens collect' first)`);
@@ -4130,6 +4260,7 @@ function runIngest(argv = process.argv.slice(2)) {
4130
4260
  const classified = classify(db, args.full ? null : expanded);
4131
4261
  const detected = detect(db, args.full ? null : expanded);
4132
4262
  classifyErrors(db, args.full ? null : expanded);
4263
+ const fileChanges = deriveFileChanges(db, args.full ? null : expanded);
4133
4264
  const count = (sql) => db.prepare(sql).get().n;
4134
4265
  const sessions = count("SELECT COUNT(*) n FROM sessions");
4135
4266
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -4155,6 +4286,7 @@ function runIngest(argv = process.argv.slice(2)) {
4155
4286
  session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
4156
4287
  tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
4157
4288
  findings=${detected.count} (${sevLine})
4289
+ file_changes=${fileChanges.count}
4158
4290
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
4159
4291
  }
4160
4292
  function runMetrics(argv = process.argv.slice(2)) {
@@ -4171,13 +4303,14 @@ function runMetrics(argv = process.argv.slice(2)) {
4171
4303
  const r = classify(db);
4172
4304
  const d = detect(db);
4173
4305
  const e = classifyErrors(db);
4306
+ const fc = deriveFileChanges(db);
4174
4307
  db.close();
4175
- 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}`);
4176
4309
  }
4177
4310
 
4178
4311
  // ../server/dist/run.js
4179
4312
  import { existsSync as existsSync7 } from "fs";
4180
- import { dirname as dirname5, join as join11 } from "path";
4313
+ import { dirname as dirname5 } from "path";
4181
4314
  import { fileURLToPath as fileURLToPath2 } from "url";
4182
4315
 
4183
4316
  // ../server/dist/db.js
@@ -4503,7 +4636,13 @@ function getSession(db, id) {
4503
4636
  session.tool_error_count = toolRows.filter((t) => t.status === "error").length;
4504
4637
  session.tool_rejection_count = toolRows.filter((t) => t.status === "error" && t.error_type && errorKind(t.error_type) === "rejection").length;
4505
4638
  session.tool_failure_count = session.tool_error_count - session.tool_rejection_count;
4506
- 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 };
4507
4646
  }
4508
4647
  function xmlTag(text, tag) {
4509
4648
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
@@ -4639,6 +4778,107 @@ function listSkills(db, f = {}) {
4639
4778
  r.sources = r.sources ? String(r.sources).split(",").filter(Boolean) : [];
4640
4779
  return rows;
4641
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
+ }
4642
4882
  function getSkill(db, name) {
4643
4883
  const versions = db.prepare(`SELECT sk.id, sk.base_dir, sk.summary, sk.body, sk.body_bytes, sk.first_seen, sk.last_seen,
4644
4884
  (SELECT COUNT(*) FROM tool_calls tc WHERE tc.skill_id = sk.id) AS call_count
@@ -5059,7 +5299,7 @@ function runRefresh() {
5059
5299
  return null;
5060
5300
  try {
5061
5301
  const collected = collectAll();
5062
- const archiveRoot = process.env.AGENT_LENS_ARCHIVE || join10(dataDir, "archive");
5302
+ const archiveRoot = resolveArchiveDir();
5063
5303
  if (!existsSync5(archiveRoot))
5064
5304
  mkdirSync5(archiveRoot, { recursive: true });
5065
5305
  runIngest([]);
@@ -5355,6 +5595,28 @@ async function createApp(db, opts = {}) {
5355
5595
  return reply.code(404).send({ error: "not found" });
5356
5596
  return result;
5357
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
+ });
5358
5620
  app.get("/api/sessions/:id/export.md", async (req, reply) => {
5359
5621
  const { id } = req.params;
5360
5622
  const level = parseRedactionLevel(req.query.redact);
@@ -5389,7 +5651,7 @@ async function startServer(overrides = {}) {
5389
5651
  console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
5390
5652
  process.exit(1);
5391
5653
  }
5392
- const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join11(dirname5(dbPath), "triage.db");
5654
+ const triageDbPath = triageDbFor(dbPath);
5393
5655
  const db = openReadonly(dbPath);
5394
5656
  const enforceLoopbackHost = host === "127.0.0.1" || host === "localhost";
5395
5657
  const app = await createApp(db, { webDist, triageDbPath, enforceLoopbackHost });
@@ -5406,11 +5668,11 @@ async function startServer(overrides = {}) {
5406
5668
 
5407
5669
  // src/watch.ts
5408
5670
  import { existsSync as existsSync8 } from "fs";
5409
- import { join as join12 } from "path";
5671
+ import { join as join11 } from "path";
5410
5672
  import chokidar from "chokidar";
5411
5673
  async function runWatch(opts = {}) {
5412
5674
  const sources = loadSources();
5413
- const lockPath = join12(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5675
+ const lockPath = join11(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5414
5676
  let running = false;
5415
5677
  let pending = false;
5416
5678
  const cycle = () => {
@@ -5443,7 +5705,7 @@ async function runWatch(opts = {}) {
5443
5705
  }
5444
5706
  };
5445
5707
  cycle();
5446
- const watchPaths = sources.map((s) => join12(s.configDir, "projects")).filter((p) => existsSync8(p));
5708
+ const watchPaths = sources.map((s) => join11(s.configDir, "projects")).filter((p) => existsSync8(p));
5447
5709
  if (watchPaths.length === 0) {
5448
5710
  console.error("agent-lens watch: no source 'projects' directories found \u2014 nothing to watch");
5449
5711
  }
@@ -5471,14 +5733,14 @@ async function runWatch(opts = {}) {
5471
5733
  // src/index.ts
5472
5734
  function version() {
5473
5735
  try {
5474
- const pkg = JSON.parse(readFileSync8(join13(dirname6(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
5736
+ const pkg = JSON.parse(readFileSync8(join12(dirname6(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
5475
5737
  return pkg.version ?? "0.0.0";
5476
5738
  } catch {
5477
5739
  return "0.0.0";
5478
5740
  }
5479
5741
  }
5480
5742
  function withLock(fn) {
5481
- const lockPath = join13(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5743
+ const lockPath = join12(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
5482
5744
  const lock = acquireLock(lockPath);
5483
5745
  if (!lock) {
5484
5746
  console.error("agent-lens: another collect/ingest run is in progress \u2014 skipping");
@@ -5561,17 +5823,17 @@ cli.command("config", "Show the effective configuration (sources, paths, server)
5561
5823
  }
5562
5824
  const { port, host, portOrigin, hostOrigin } = server;
5563
5825
  const { path: dbPath, origin: dbOrigin } = resolveDbPath();
5564
- const archive = process.env.AGENT_LENS_ARCHIVE || join13(dataDir, "archive");
5565
- const triageDb = process.env.AGENT_LENS_TRIAGE_DB || join13(dirname6(dbPath), "triage.db");
5826
+ const archive = resolveArchiveDir(repoRoot);
5827
+ const triageDb = triageDbFor(dbPath);
5566
5828
  const keepDays = process.env.AGENT_LENS_VERSIONS_KEEP_DAYS || "90";
5567
5829
  const keepOrigin = process.env.AGENT_LENS_VERSIONS_KEEP_DAYS ? "env" : "default";
5568
5830
  console.log("agent-lens config (precedence: flag > env > config file > default)\n");
5569
5831
  console.log("Paths");
5570
5832
  console.log(` config file ${configFile ?? "(none \u2014 using built-in default source)"}`);
5571
5833
  console.log(` data dir ${dataDir}${process.env.AGENT_LENS_DATA ? " [env]" : ""}`);
5572
- console.log(` archive ${archive}${process.env.AGENT_LENS_ARCHIVE ? " [env]" : ""}`);
5834
+ console.log(` archive ${archive} [fixed: <data dir>/archive]`);
5573
5835
  console.log(` db ${dbPath} [${dbOrigin}]`);
5574
- console.log(` triage db ${triageDb}${process.env.AGENT_LENS_TRIAGE_DB ? " [env]" : ""}`);
5836
+ console.log(` triage db ${triageDb} [fixed: beside the db]`);
5575
5837
  console.log("\nServer");
5576
5838
  console.log(` host ${host} [${hostOrigin}]`);
5577
5839
  console.log(` port ${port} [${portOrigin}]`);