@swestash/agent-lens 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync5 } from "fs";
5
- import { dirname as dirname6, join as join9 } from "path";
4
+ import { readFileSync as readFileSync8 } from "fs";
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 = 8;
594
+ var SCHEMA_VERSION = 11;
595
595
  var SCHEMA_SQL = (
596
596
  /* sql */
597
597
  `
@@ -781,6 +781,61 @@ CREATE TABLE IF NOT EXISTS classifications (
781
781
  PRIMARY KEY (scope, target_id)
782
782
  );
783
783
 
784
+ -- Workflow-tool run results, ingested from the sidecar JSON the runner writes next to the launching
785
+ -- session (\u2026/projects/<enc>/<sessionId>/workflows/wf_<id>.json). The transcript only carries the
786
+ -- "launched in background" ack for an async run; this sidecar is the authoritative record of how the
787
+ -- run finished \u2014 status (completed/failed), summary, the returned result payload, and roll-up
788
+ -- tokens/tool-calls/duration/agent-count. One row per run id; UPSERT on re-ingest. Stored verbatim
789
+ -- (like tool_calls.input_json) \u2014 the DB is local; redaction is an export-time concern. --------------
790
+ CREATE TABLE IF NOT EXISTS workflow_results (
791
+ run_id TEXT PRIMARY KEY, -- wf_<id> (matches tool_calls.workflow_run_id / sessions.workflow_run_id)
792
+ source_id TEXT, -- which configured source (account) it came from
793
+ session_id TEXT, -- launching session (path segment before /workflows/)
794
+ task_id TEXT, -- async task id (the "Task ID: \u2026" in the launch ack)
795
+ workflow_name TEXT,
796
+ status TEXT, -- completed | failed | running | ...
797
+ summary TEXT,
798
+ default_model TEXT, -- model the run executed on (e.g. claude-fable-5)
799
+ result_json TEXT, -- the returned result object, verbatim JSON (null when none)
800
+ phases_json TEXT, -- [{title}] the run's phase structure (e.g. Generate \u2192 Judge)
801
+ logs_json TEXT, -- concise per-item outcome lines the runner emitted
802
+ progress_json TEXT, -- workflowProgress: the phase/agent event timeline
803
+ agent_count INTEGER, -- agents the run reports having fanned out to
804
+ total_tokens INTEGER,
805
+ total_tool_calls INTEGER,
806
+ duration_ms INTEGER,
807
+ started_at TEXT, -- ISO, from the sidecar startTime/timestamp
808
+ ended_at TEXT, -- ISO, started_at + duration
809
+ ingested_at TEXT
810
+ );
811
+ CREATE INDEX IF NOT EXISTS idx_workflow_results_session ON workflow_results(session_id);
812
+
813
+ -- Per-subagent metadata sidecar (subagents/agent-<id>.meta.json). One row per subagent session,
814
+ -- joined at read time onto sessions (LEFT JOIN, may precede the session row's arrival). Supplies the
815
+ -- authoritative agentType/description/spawnDepth \u2014 Workflow fan-out subagents carry none in-transcript.
816
+ CREATE TABLE IF NOT EXISTS session_meta (
817
+ session_id TEXT PRIMARY KEY, -- 'agent-<id>' = the subagent session id (meta filename stem)
818
+ source_id TEXT,
819
+ agent_type TEXT, -- meta.agentType (e.g. 'Explore', 'claude-code-guide')
820
+ agent_description TEXT, -- meta.description (a real human title)
821
+ spawn_depth INTEGER, -- meta.spawnDepth (optional; absent \u21D2 top-level)
822
+ tool_use_id TEXT, -- meta.toolUseId (the spawning tool_call)
823
+ ingested_at TEXT
824
+ );
825
+
826
+ -- Full un-truncated tool outputs spilled to disk (tool-results/<name>.txt) when a result was too large
827
+ -- for the transcript, which keeps only a 280-char summary naming the file. Joined at read time by
828
+ -- parsing that summary's '\u2026/tool-results/<name>.txt' marker back to (session_id, name).
829
+ CREATE TABLE IF NOT EXISTS tool_results (
830
+ session_id TEXT, -- session the tool ran in (from the archive path)
831
+ name TEXT, -- file basename without .txt (matches the marker in tool_calls.result_summary)
832
+ path TEXT, -- archive path (provenance)
833
+ bytes INTEGER, -- original size, for the UI
834
+ text TEXT, -- full un-truncated output, stored inline (self-contained DB)
835
+ ingested_at TEXT,
836
+ PRIMARY KEY (session_id, name)
837
+ );
838
+
784
839
  -- Idempotent ingest bookkeeping per archive file. --------------------------
785
840
  CREATE TABLE IF NOT EXISTS ingest_state (
786
841
  file_path TEXT PRIMARY KEY,
@@ -1148,16 +1203,17 @@ function snapshot(absPath, relParts, versionsDir, stats) {
1148
1203
  syncMeta(dst, statSync(absPath));
1149
1204
  stats.snapshots++;
1150
1205
  }
1151
- function walkJsonl(dir, out) {
1206
+ function walkCollectable(dir, out, inWorkflows = false, inToolResults = false) {
1152
1207
  for (const e of readdirSync(dir, { withFileTypes: true })) {
1153
1208
  const p = join2(dir, e.name);
1154
- if (e.isDirectory())
1155
- walkJsonl(p, out);
1156
- else if (e.isFile() && e.name.endsWith(".jsonl"))
1209
+ if (e.isDirectory()) {
1210
+ walkCollectable(p, out, inWorkflows || e.name === "workflows", inToolResults || e.name === "tool-results");
1211
+ } else if (e.isFile() && (e.name.endsWith(".jsonl") || inWorkflows || inToolResults || e.name.endsWith(".meta.json"))) {
1157
1212
  out.push(p);
1213
+ }
1158
1214
  }
1159
1215
  }
1160
- function syncFile(src, dst, relParts, archive, versionsDir, stats) {
1216
+ function syncFile(src, dst, relParts, archive, versionsDir, stats, appendable) {
1161
1217
  const srcStat = statSync(src);
1162
1218
  if (!existsSync2(dst)) {
1163
1219
  copyWhole(src, dst, srcStat);
@@ -1169,11 +1225,11 @@ function syncFile(src, dst, relParts, archive, versionsDir, stats) {
1169
1225
  return;
1170
1226
  const ssize = srcStat.size;
1171
1227
  const asize = arcStat.size;
1172
- if (ssize >= asize && prefixEquals(src, dst, asize)) {
1173
- if (ssize > asize) {
1174
- appendRange(src, dst, asize, ssize);
1175
- stats.appended++;
1176
- }
1228
+ if (ssize === asize && prefixEquals(src, dst, asize)) {
1229
+ syncMeta(dst, srcStat);
1230
+ } else if (appendable && ssize > asize && prefixEquals(src, dst, asize)) {
1231
+ appendRange(src, dst, asize, ssize);
1232
+ stats.appended++;
1177
1233
  syncMeta(dst, srcStat);
1178
1234
  } else if (ssize < asize) {
1179
1235
  const last = latestSnapshot(archive, relParts);
@@ -1214,7 +1270,7 @@ function collectSource(source, archiveBase, excludedDirs, runTs, stats, log) {
1214
1270
  const files = [];
1215
1271
  const projectsDir = join2(configDir, "projects");
1216
1272
  if (existsSync2(projectsDir))
1217
- walkJsonl(projectsDir, files);
1273
+ walkCollectable(projectsDir, files);
1218
1274
  const historySrc = join2(configDir, "history.jsonl");
1219
1275
  if (existsSync2(historySrc))
1220
1276
  files.push(historySrc);
@@ -1228,7 +1284,7 @@ function collectSource(source, archiveBase, excludedDirs, runTs, stats, log) {
1228
1284
  if (excludedDirs.some((enc) => relPosix.startsWith(`projects/${enc}/`)))
1229
1285
  continue;
1230
1286
  stats.scanned++;
1231
- syncFile(src, join2(archive, ...relParts), relParts, archive, versionsDir, stats);
1287
+ syncFile(src, join2(archive, ...relParts), relParts, archive, versionsDir, stats, base.endsWith(".jsonl"));
1232
1288
  }
1233
1289
  for (const f of ["settings.json", "settings.local.json"]) {
1234
1290
  const s = join2(configDir, f);
@@ -1627,8 +1683,8 @@ function runService(action, params) {
1627
1683
  }
1628
1684
 
1629
1685
  // ../ingest/dist/run.js
1630
- import { readFileSync as readFileSync4, statSync as statSync2, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
1631
- import { dirname as dirname4, join as join5 } from "path";
1686
+ import { readFileSync as readFileSync7, statSync as statSync5, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
1687
+ import { dirname as dirname4, join as join8 } from "path";
1632
1688
 
1633
1689
  // ../ingest/dist/db.js
1634
1690
  import Database from "better-sqlite3";
@@ -1638,9 +1694,16 @@ function openRaw(file) {
1638
1694
  db.pragma("foreign_keys = ON");
1639
1695
  return db;
1640
1696
  }
1697
+ function readSchemaVersion(db) {
1698
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1699
+ if (!row)
1700
+ return null;
1701
+ const n = Number(row.value);
1702
+ return Number.isFinite(n) ? n : null;
1703
+ }
1641
1704
  function applySchema(db) {
1642
1705
  db.exec(SCHEMA_SQL);
1643
- db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(SCHEMA_VERSION));
1706
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO NOTHING").run(String(SCHEMA_VERSION));
1644
1707
  }
1645
1708
  function openDb(file) {
1646
1709
  const db = openRaw(file);
@@ -1664,6 +1727,9 @@ function resetSchema(db) {
1664
1727
  DROP TABLE IF EXISTS projects;
1665
1728
  DROP TABLE IF EXISTS sources;
1666
1729
  DROP TABLE IF EXISTS agents;
1730
+ DROP TABLE IF EXISTS workflow_results;
1731
+ DROP TABLE IF EXISTS session_meta;
1732
+ DROP TABLE IF EXISTS tool_results;
1667
1733
  DROP TABLE IF EXISTS ingest_state;
1668
1734
  DROP TABLE IF EXISTS meta;
1669
1735
  `);
@@ -1875,9 +1941,10 @@ var ClaudeCodeAdapter = class {
1875
1941
  result_summary: null
1876
1942
  });
1877
1943
  } else if (b.type === "tool_result" && asString(b.tool_use_id)) {
1944
+ const isAskUser = tur && tur.questions && tur.answers && typeof tur.answers === "object";
1878
1945
  toolResults.push({
1879
1946
  tool_use_id: b.tool_use_id,
1880
- result_summary: summarizeResult(b.content),
1947
+ result_summary: isAskUser ? JSON.stringify({ answers: tur.answers, annotations: tur.annotations ?? {} }) : summarizeResult(b.content),
1881
1948
  ...tur ? {
1882
1949
  status: asString(tur.status),
1883
1950
  agent_type: asString(tur.agentType),
@@ -2607,6 +2674,10 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
2607
2674
  tx();
2608
2675
  }
2609
2676
 
2677
+ // ../ingest/dist/workflows.js
2678
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
2679
+ import { basename as basename3, join as join5 } from "path";
2680
+
2610
2681
  // ../ingest/dist/redact.js
2611
2682
  import { createHash as createHash2, randomBytes } from "crypto";
2612
2683
  function parseExcludes(csv) {
@@ -2661,6 +2732,334 @@ function* streamLines(path) {
2661
2732
  }
2662
2733
  }
2663
2734
 
2735
+ // ../ingest/dist/workflows.js
2736
+ function newWorkflowStats() {
2737
+ return { files: 0, upserted: 0, skipped: 0, malformed: 0 };
2738
+ }
2739
+ function walkSidecars(dir, inWorkflows, out) {
2740
+ let entries;
2741
+ try {
2742
+ entries = readdirSync3(dir, { withFileTypes: true });
2743
+ } catch {
2744
+ return;
2745
+ }
2746
+ for (const e of entries) {
2747
+ const p = join5(dir, e.name);
2748
+ if (e.isDirectory())
2749
+ walkSidecars(p, inWorkflows || e.name === "workflows", out);
2750
+ else if (e.isFile() && inWorkflows && /^wf_.+\.json$/.test(e.name))
2751
+ out.push(p);
2752
+ }
2753
+ }
2754
+ function discoverSidecars(sourceArchiveDir) {
2755
+ const out = [];
2756
+ walkSidecars(join5(sourceArchiveDir, "projects"), false, out);
2757
+ try {
2758
+ for (const ts of readdirSync3(join5(sourceArchiveDir, ".versions"), { withFileTypes: true }))
2759
+ if (ts.isDirectory())
2760
+ walkSidecars(join5(sourceArchiveDir, ts.name, "projects"), false, out);
2761
+ } catch {
2762
+ }
2763
+ return out;
2764
+ }
2765
+ function sessionFromPath(path) {
2766
+ const parts = path.split("/");
2767
+ const i = parts.indexOf("workflows");
2768
+ return i > 0 ? parts[i - 1] : null;
2769
+ }
2770
+ function toIso(x) {
2771
+ if (typeof x === "number" && Number.isFinite(x))
2772
+ return new Date(x).toISOString();
2773
+ if (typeof x === "string" && x) {
2774
+ const t = Date.parse(x);
2775
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
2776
+ }
2777
+ return null;
2778
+ }
2779
+ var jsonOrNull = (v) => v == null ? null : JSON.stringify(v);
2780
+ var intOrNull = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
2781
+ var strOrNull = (v) => typeof v === "string" && v ? v : null;
2782
+ function cleanStr(v) {
2783
+ const s = strOrNull(v);
2784
+ if (!s)
2785
+ return null;
2786
+ return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
2787
+ }
2788
+ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
2789
+ let paths = discoverSidecars(sourceArchiveDir);
2790
+ if (excludedDirs.length)
2791
+ paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
2792
+ paths.sort((a, b) => Number(a.includes("/.versions/")) - Number(b.includes("/.versions/")) || (a < b ? -1 : 1));
2793
+ const getState = db.prepare("SELECT size, mtime_ms, sha256 FROM ingest_state WHERE file_path = ?");
2794
+ const setState = db.prepare(`INSERT INTO ingest_state (file_path, size, mtime_ms, sha256, events_ingested, ingested_at)
2795
+ VALUES (@file_path, @size, @mtime_ms, @sha256, 0, @ingested_at)
2796
+ ON CONFLICT(file_path) DO UPDATE SET size=excluded.size, mtime_ms=excluded.mtime_ms, sha256=excluded.sha256, ingested_at=excluded.ingested_at`);
2797
+ const upsert = db.prepare(`INSERT INTO workflow_results
2798
+ (run_id, source_id, session_id, task_id, workflow_name, status, summary, default_model,
2799
+ result_json, phases_json, logs_json, progress_json,
2800
+ agent_count, total_tokens, total_tool_calls, duration_ms, started_at, ended_at, ingested_at)
2801
+ VALUES
2802
+ (@run_id, @source_id, @session_id, @task_id, @workflow_name, @status, @summary, @default_model,
2803
+ @result_json, @phases_json, @logs_json, @progress_json,
2804
+ @agent_count, @total_tokens, @total_tool_calls, @duration_ms, @started_at, @ended_at, @ingested_at)
2805
+ ON CONFLICT(run_id) DO UPDATE SET
2806
+ source_id=excluded.source_id, session_id=excluded.session_id, task_id=excluded.task_id,
2807
+ workflow_name=excluded.workflow_name, status=excluded.status, summary=excluded.summary,
2808
+ default_model=excluded.default_model, result_json=excluded.result_json, phases_json=excluded.phases_json,
2809
+ logs_json=excluded.logs_json, progress_json=excluded.progress_json, agent_count=excluded.agent_count,
2810
+ total_tokens=excluded.total_tokens, total_tool_calls=excluded.total_tool_calls,
2811
+ duration_ms=excluded.duration_ms, started_at=excluded.started_at, ended_at=excluded.ended_at,
2812
+ ingested_at=excluded.ingested_at`);
2813
+ for (const path of paths) {
2814
+ stats.files++;
2815
+ const st = statSync2(path);
2816
+ const mtimeMs = Math.trunc(st.mtimeMs);
2817
+ const prev = full ? void 0 : getState.get(path);
2818
+ if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
2819
+ stats.skipped++;
2820
+ continue;
2821
+ }
2822
+ let buf;
2823
+ try {
2824
+ buf = readFileSync4(path);
2825
+ } catch {
2826
+ stats.malformed++;
2827
+ continue;
2828
+ }
2829
+ const hash = sha256(buf);
2830
+ if (prev && prev.sha256 === hash) {
2831
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
2832
+ stats.skipped++;
2833
+ continue;
2834
+ }
2835
+ let d;
2836
+ try {
2837
+ d = JSON.parse(buf.toString("utf8"));
2838
+ } catch {
2839
+ stats.malformed++;
2840
+ continue;
2841
+ }
2842
+ if (!d || typeof d !== "object") {
2843
+ stats.malformed++;
2844
+ continue;
2845
+ }
2846
+ const runId = strOrNull(d.runId) ?? basename3(path, ".json");
2847
+ const startedAt = toIso(d.startTime) ?? toIso(d.timestamp);
2848
+ const durationMs2 = intOrNull(d.durationMs);
2849
+ const endedAt = startedAt && durationMs2 != null ? new Date(Date.parse(startedAt) + durationMs2).toISOString() : null;
2850
+ upsert.run({
2851
+ run_id: runId,
2852
+ source_id: sourceId,
2853
+ session_id: sessionFromPath(path),
2854
+ task_id: strOrNull(d.taskId),
2855
+ workflow_name: strOrNull(d.workflowName),
2856
+ status: strOrNull(d.status),
2857
+ summary: strOrNull(d.summary),
2858
+ default_model: cleanStr(d.defaultModel),
2859
+ result_json: jsonOrNull(d.result),
2860
+ phases_json: Array.isArray(d.phases) ? JSON.stringify(d.phases) : null,
2861
+ logs_json: Array.isArray(d.logs) ? JSON.stringify(d.logs) : null,
2862
+ progress_json: Array.isArray(d.workflowProgress) ? JSON.stringify(d.workflowProgress) : null,
2863
+ agent_count: intOrNull(d.agentCount),
2864
+ total_tokens: intOrNull(d.totalTokens),
2865
+ total_tool_calls: intOrNull(d.totalToolCalls),
2866
+ duration_ms: durationMs2,
2867
+ started_at: startedAt,
2868
+ ended_at: endedAt,
2869
+ ingested_at: now
2870
+ });
2871
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
2872
+ stats.upserted++;
2873
+ }
2874
+ }
2875
+
2876
+ // ../ingest/dist/meta.js
2877
+ import { readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
2878
+ import { basename as basename4, join as join6 } from "path";
2879
+ function newMetaStats() {
2880
+ return { files: 0, upserted: 0, skipped: 0, malformed: 0 };
2881
+ }
2882
+ function walkMeta(dir, inSubagents, out) {
2883
+ let entries;
2884
+ try {
2885
+ entries = readdirSync4(dir, { withFileTypes: true });
2886
+ } catch {
2887
+ return;
2888
+ }
2889
+ for (const e of entries) {
2890
+ const p = join6(dir, e.name);
2891
+ if (e.isDirectory())
2892
+ walkMeta(p, inSubagents || e.name === "subagents", out);
2893
+ else if (e.isFile() && inSubagents && /^agent-.+\.meta\.json$/.test(e.name))
2894
+ out.push(p);
2895
+ }
2896
+ }
2897
+ function discoverMeta(sourceArchiveDir) {
2898
+ const out = [];
2899
+ walkMeta(join6(sourceArchiveDir, "projects"), false, out);
2900
+ try {
2901
+ for (const ts of readdirSync4(join6(sourceArchiveDir, ".versions"), { withFileTypes: true }))
2902
+ if (ts.isDirectory())
2903
+ walkMeta(join6(sourceArchiveDir, ts.name, "projects"), false, out);
2904
+ } catch {
2905
+ }
2906
+ return out;
2907
+ }
2908
+ var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
2909
+ var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
2910
+ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, stats, full) {
2911
+ let paths = discoverMeta(sourceArchiveDir);
2912
+ if (excludedDirs.length)
2913
+ paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
2914
+ paths.sort((a, b) => Number(a.includes("/.versions/")) - Number(b.includes("/.versions/")) || (a < b ? -1 : 1));
2915
+ const getState = db.prepare("SELECT size, mtime_ms, sha256 FROM ingest_state WHERE file_path = ?");
2916
+ const setState = db.prepare(`INSERT INTO ingest_state (file_path, size, mtime_ms, sha256, events_ingested, ingested_at)
2917
+ VALUES (@file_path, @size, @mtime_ms, @sha256, 0, @ingested_at)
2918
+ ON CONFLICT(file_path) DO UPDATE SET size=excluded.size, mtime_ms=excluded.mtime_ms, sha256=excluded.sha256, ingested_at=excluded.ingested_at`);
2919
+ const upsert = db.prepare(`INSERT INTO session_meta
2920
+ (session_id, source_id, agent_type, agent_description, spawn_depth, tool_use_id, ingested_at)
2921
+ VALUES
2922
+ (@session_id, @source_id, @agent_type, @agent_description, @spawn_depth, @tool_use_id, @ingested_at)
2923
+ ON CONFLICT(session_id) DO UPDATE SET
2924
+ source_id=excluded.source_id, agent_type=excluded.agent_type,
2925
+ agent_description=excluded.agent_description, spawn_depth=excluded.spawn_depth,
2926
+ tool_use_id=excluded.tool_use_id, ingested_at=excluded.ingested_at`);
2927
+ for (const path of paths) {
2928
+ stats.files++;
2929
+ const st = statSync3(path);
2930
+ const mtimeMs = Math.trunc(st.mtimeMs);
2931
+ const prev = full ? void 0 : getState.get(path);
2932
+ if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
2933
+ stats.skipped++;
2934
+ continue;
2935
+ }
2936
+ let buf;
2937
+ try {
2938
+ buf = readFileSync5(path);
2939
+ } catch {
2940
+ stats.malformed++;
2941
+ continue;
2942
+ }
2943
+ const hash = sha256(buf);
2944
+ if (prev && prev.sha256 === hash) {
2945
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
2946
+ stats.skipped++;
2947
+ continue;
2948
+ }
2949
+ let d;
2950
+ try {
2951
+ d = JSON.parse(buf.toString("utf8"));
2952
+ } catch {
2953
+ stats.malformed++;
2954
+ continue;
2955
+ }
2956
+ if (!d || typeof d !== "object") {
2957
+ stats.malformed++;
2958
+ continue;
2959
+ }
2960
+ const sessionId = basename4(path, ".meta.json");
2961
+ upsert.run({
2962
+ session_id: sessionId,
2963
+ source_id: sourceId,
2964
+ agent_type: strOrNull2(d.agentType),
2965
+ agent_description: strOrNull2(d.description),
2966
+ spawn_depth: intOrNull2(d.spawnDepth),
2967
+ tool_use_id: strOrNull2(d.toolUseId),
2968
+ ingested_at: now
2969
+ });
2970
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
2971
+ stats.upserted++;
2972
+ }
2973
+ }
2974
+
2975
+ // ../ingest/dist/toolResults.js
2976
+ import { readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
2977
+ import { basename as basename5, join as join7 } from "path";
2978
+ function newToolResultStats() {
2979
+ return { files: 0, upserted: 0, skipped: 0, malformed: 0 };
2980
+ }
2981
+ function walkResults(dir, inResults, out) {
2982
+ let entries;
2983
+ try {
2984
+ entries = readdirSync5(dir, { withFileTypes: true });
2985
+ } catch {
2986
+ return;
2987
+ }
2988
+ for (const e of entries) {
2989
+ const p = join7(dir, e.name);
2990
+ if (e.isDirectory())
2991
+ walkResults(p, inResults || e.name === "tool-results", out);
2992
+ else if (e.isFile() && inResults && e.name.endsWith(".txt"))
2993
+ out.push(p);
2994
+ }
2995
+ }
2996
+ function discoverResults(sourceArchiveDir) {
2997
+ const out = [];
2998
+ walkResults(join7(sourceArchiveDir, "projects"), false, out);
2999
+ try {
3000
+ for (const ts of readdirSync5(join7(sourceArchiveDir, ".versions"), { withFileTypes: true }))
3001
+ if (ts.isDirectory())
3002
+ walkResults(join7(sourceArchiveDir, ts.name, "projects"), false, out);
3003
+ } catch {
3004
+ }
3005
+ return out;
3006
+ }
3007
+ function sessionFromPath2(path) {
3008
+ const parts = path.split("/");
3009
+ const i = parts.indexOf("tool-results");
3010
+ return i > 0 ? parts[i - 1] : null;
3011
+ }
3012
+ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full) {
3013
+ let paths = discoverResults(sourceArchiveDir);
3014
+ if (excludedDirs.length)
3015
+ paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
3016
+ paths.sort((a, b) => Number(a.includes("/.versions/")) - Number(b.includes("/.versions/")) || (a < b ? -1 : 1));
3017
+ const getState = db.prepare("SELECT size, mtime_ms, sha256 FROM ingest_state WHERE file_path = ?");
3018
+ const setState = db.prepare(`INSERT INTO ingest_state (file_path, size, mtime_ms, sha256, events_ingested, ingested_at)
3019
+ VALUES (@file_path, @size, @mtime_ms, @sha256, 0, @ingested_at)
3020
+ ON CONFLICT(file_path) DO UPDATE SET size=excluded.size, mtime_ms=excluded.mtime_ms, sha256=excluded.sha256, ingested_at=excluded.ingested_at`);
3021
+ const upsert = db.prepare(`INSERT INTO tool_results (session_id, name, path, bytes, text, ingested_at)
3022
+ VALUES (@session_id, @name, @path, @bytes, @text, @ingested_at)
3023
+ ON CONFLICT(session_id, name) DO UPDATE SET
3024
+ path=excluded.path, bytes=excluded.bytes, text=excluded.text, ingested_at=excluded.ingested_at`);
3025
+ for (const path of paths) {
3026
+ const sessionId = sessionFromPath2(path);
3027
+ if (!sessionId)
3028
+ continue;
3029
+ stats.files++;
3030
+ const st = statSync4(path);
3031
+ const mtimeMs = Math.trunc(st.mtimeMs);
3032
+ const prev = full ? void 0 : getState.get(path);
3033
+ if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
3034
+ stats.skipped++;
3035
+ continue;
3036
+ }
3037
+ let buf;
3038
+ try {
3039
+ buf = readFileSync6(path);
3040
+ } catch {
3041
+ stats.malformed++;
3042
+ continue;
3043
+ }
3044
+ const hash = sha256(buf);
3045
+ if (prev && prev.sha256 === hash) {
3046
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3047
+ stats.skipped++;
3048
+ continue;
3049
+ }
3050
+ upsert.run({
3051
+ session_id: sessionId,
3052
+ name: basename5(path, ".txt"),
3053
+ path,
3054
+ bytes: st.size,
3055
+ text: buf.toString("utf8"),
3056
+ ingested_at: now
3057
+ });
3058
+ setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now });
3059
+ stats.upserted++;
3060
+ }
3061
+ }
3062
+
2664
3063
  // ../ingest/dist/run.js
2665
3064
  function parseIngestArgs(argv) {
2666
3065
  const a = { full: false, db: "", archive: "" };
@@ -2677,16 +3076,24 @@ function parseIngestArgs(argv) {
2677
3076
  function runIngest(argv = process.argv.slice(2)) {
2678
3077
  const args = parseIngestArgs(argv);
2679
3078
  const dataDir = resolveDataDir(findRepoRoot());
2680
- const archiveRoot = args.archive || process.env.AGENT_LENS_ARCHIVE || join5(dataDir, "archive");
2681
- const dbPath = args.db || process.env.AGENT_LENS_DB || join5(dataDir, "agent-lens.db");
3079
+ const archiveRoot = args.archive || process.env.AGENT_LENS_ARCHIVE || join8(dataDir, "archive");
3080
+ const dbPath = args.db || process.env.AGENT_LENS_DB || join8(dataDir, "agent-lens.db");
2682
3081
  if (!existsSync4(archiveRoot)) {
2683
3082
  console.error(`agent-lens-ingest: archive not found: ${archiveRoot} (run 'agent-lens collect' first)`);
2684
3083
  process.exit(1);
2685
3084
  }
2686
3085
  mkdirSync4(dirname4(dbPath), { recursive: true });
2687
3086
  const db = args.full ? openRaw(dbPath) : openDb(dbPath);
2688
- if (args.full)
3087
+ if (args.full) {
2689
3088
  resetSchema(db);
3089
+ } else {
3090
+ const dbVer = readSchemaVersion(db);
3091
+ if (dbVer != null && dbVer !== SCHEMA_VERSION) {
3092
+ console.error(`agent-lens-ingest: schema mismatch \u2014 DB is v${dbVer}, this build expects v${SCHEMA_VERSION}. Run 'agent-lens ingest --full' to rebuild.`);
3093
+ db.close();
3094
+ process.exit(1);
3095
+ }
3096
+ }
2690
3097
  const adapterList = [new ClaudeCodeAdapter()];
2691
3098
  const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
2692
3099
  const sources = loadSources();
@@ -2696,6 +3103,9 @@ function runIngest(argv = process.argv.slice(2)) {
2696
3103
  const pruned = pruneExcluded(db, excludePaths);
2697
3104
  const stmts = prepareStatements(db);
2698
3105
  const stats = newStats();
3106
+ const wfStats = newWorkflowStats();
3107
+ const metaStats = newMetaStats();
3108
+ const trStats = newToolResultStats();
2699
3109
  const dirty = /* @__PURE__ */ new Set();
2700
3110
  for (const source of sources) {
2701
3111
  const adapter = adapterById.get(source.agent);
@@ -2705,13 +3115,13 @@ function runIngest(argv = process.argv.slice(2)) {
2705
3115
  }
2706
3116
  stmts.insAgent.run(adapter.agentId, adapter.agentName);
2707
3117
  stmts.insSource.run({ id: source.label, label: source.label, agent_id: adapter.agentId, config_dir: source.configDir });
2708
- let files = adapter.discover(join5(archiveRoot, source.label), source.label);
3118
+ let files = adapter.discover(join8(archiveRoot, source.label), source.label);
2709
3119
  if (excludedDirs.length)
2710
3120
  files = files.filter((f) => !isExcludedArchivePath(f.path, excludedDirs));
2711
3121
  files.sort((a, b) => Number(a.isVersion) - Number(b.isVersion));
2712
3122
  for (const file of files) {
2713
3123
  stats.files++;
2714
- const st = statSync2(file.path);
3124
+ const st = statSync5(file.path);
2715
3125
  const mtimeMs = Math.trunc(st.mtimeMs);
2716
3126
  const prev = args.full ? void 0 : stmts.getState.get(file.path);
2717
3127
  if (prev && prev.size === st.size && prev.mtime_ms === mtimeMs) {
@@ -2719,7 +3129,7 @@ function runIngest(argv = process.argv.slice(2)) {
2719
3129
  continue;
2720
3130
  }
2721
3131
  const small = st.size <= STREAM_THRESHOLD;
2722
- const buf = small ? readFileSync4(file.path) : null;
3132
+ const buf = small ? readFileSync7(file.path) : null;
2723
3133
  const hash = small ? sha256(buf) : sha256File(file.path);
2724
3134
  if (prev && prev.sha256 === hash) {
2725
3135
  stmts.setState.run({
@@ -2737,6 +3147,9 @@ function runIngest(argv = process.argv.slice(2)) {
2737
3147
  ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now, stats);
2738
3148
  dirty.add(file.sessionId);
2739
3149
  }
3150
+ ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, wfStats, args.full);
3151
+ ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now, metaStats, args.full);
3152
+ ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now, trStats, args.full);
2740
3153
  }
2741
3154
  const expanded = rebuildDerived(db, args.full ? null : dirty);
2742
3155
  const classified = classify(db, args.full ? null : expanded);
@@ -2752,9 +3165,15 @@ function runIngest(argv = process.argv.slice(2)) {
2752
3165
  cost += costForUsage(u.model, u);
2753
3166
  totalTokens += u.input_tokens + u.output_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens;
2754
3167
  }
3168
+ const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
3169
+ const metaRows = count("SELECT COUNT(*) n FROM session_meta");
3170
+ const trRows = count("SELECT COUNT(*) n FROM tool_results");
2755
3171
  db.close();
2756
3172
  console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
2757
3173
  sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
3174
+ workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
3175
+ session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
3176
+ tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
2758
3177
  tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
2759
3178
  }
2760
3179
  function runMetrics(argv = process.argv.slice(2)) {
@@ -2763,7 +3182,7 @@ function runMetrics(argv = process.argv.slice(2)) {
2763
3182
  if (argv[i] === "--db")
2764
3183
  dbArg = argv[++i] ?? "";
2765
3184
  const dataDir = resolveDataDir(findRepoRoot());
2766
- const dbPath = dbArg || process.env.AGENT_LENS_DB || join5(dataDir, "agent-lens.db");
3185
+ const dbPath = dbArg || process.env.AGENT_LENS_DB || join8(dataDir, "agent-lens.db");
2767
3186
  if (!existsSync4(dbPath)) {
2768
3187
  console.error(`agent-lens-metrics: db not found: ${dbPath} (run ingest first)`);
2769
3188
  process.exit(1);
@@ -2776,7 +3195,7 @@ function runMetrics(argv = process.argv.slice(2)) {
2776
3195
 
2777
3196
  // ../server/dist/run.js
2778
3197
  import { existsSync as existsSync7 } from "fs";
2779
- import { dirname as dirname5, join as join7 } from "path";
3198
+ import { dirname as dirname5, join as join10 } from "path";
2780
3199
  import { fileURLToPath as fileURLToPath2 } from "url";
2781
3200
 
2782
3201
  // ../server/dist/db.js
@@ -2786,6 +3205,12 @@ function openReadonly(file) {
2786
3205
  db.pragma("query_only = ON");
2787
3206
  return db;
2788
3207
  }
3208
+ function schemaStatus(db) {
3209
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
3210
+ const dbVersion = row ? Number(row.value) : null;
3211
+ const db_version = dbVersion != null && Number.isFinite(dbVersion) ? dbVersion : null;
3212
+ return { db_version, expected: SCHEMA_VERSION, stale: db_version != null && db_version !== SCHEMA_VERSION };
3213
+ }
2789
3214
  function lastIngested(db) {
2790
3215
  const row = db.prepare("SELECT MAX(ingested_at) AS last FROM ingest_state").get();
2791
3216
  return row?.last ?? null;
@@ -2858,21 +3283,47 @@ function listSessions(db, f) {
2858
3283
  }
2859
3284
  const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
2860
3285
  const total = db.prepare(`SELECT COUNT(*) n FROM sessions s ${whereSql}`).get(...params).n;
2861
- const rows = db.prepare(`SELECT s.id, s.ai_title, s.slug, s.source_id, s.is_sidechain, s.started_at, s.ended_at,
3286
+ const baseSelect = `SELECT s.id, s.ai_title, s.slug, s.source_id, s.is_sidechain, s.started_at, s.ended_at,
2862
3287
  s.duration_ms, s.event_count, s.turn_count, p.path AS project_path,
2863
3288
  (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
2864
3289
  FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
2865
- ${whereSql}
2866
- ORDER BY s.started_at DESC
2867
- LIMIT ? OFFSET ?`).all(...params, f.limit, f.offset);
2868
- const ids = rows.map((r) => r.id);
3290
+ ${whereSql}`;
3291
+ const NATIVE_ORDER = {
3292
+ started: "s.started_at",
3293
+ title: "COALESCE(s.ai_title, s.slug)",
3294
+ turns: "s.turn_count",
3295
+ duration: "s.duration_ms"
3296
+ };
3297
+ const sortKey = f.sort ?? "started";
3298
+ const sqlDir = f.dir === "asc" ? "ASC" : "DESC";
3299
+ let rows;
3300
+ if (sortKey === "tokens" || sortKey === "cost") {
3301
+ const all = db.prepare(baseSelect).all(...params);
3302
+ attachSessionCost(db, all);
3303
+ const sign = f.dir === "asc" ? 1 : -1;
3304
+ all.sort((a, b) => {
3305
+ const c = (sortKey === "tokens" ? a.tokens - b.tokens : a.cost - b.cost) * sign;
3306
+ return c !== 0 ? c : String(a.id).localeCompare(String(b.id));
3307
+ });
3308
+ rows = all.slice(f.offset, f.offset + f.limit);
3309
+ } else {
3310
+ const orderCol = NATIVE_ORDER[sortKey] ?? NATIVE_ORDER.started;
3311
+ rows = db.prepare(`${baseSelect} ORDER BY ${orderCol} ${sqlDir}, s.id ASC LIMIT ? OFFSET ?`).all(...params, f.limit, f.offset);
3312
+ attachSessionCost(db, rows);
3313
+ }
3314
+ return { total, sessions: rows };
3315
+ }
3316
+ function attachSessionCost(db, rows) {
2869
3317
  const costBySession = /* @__PURE__ */ new Map();
2870
- if (ids.length) {
2871
- const ph = ids.map(() => "?").join(",");
3318
+ const ids = rows.map((r) => r.id);
3319
+ const CHUNK3 = 800;
3320
+ for (let i = 0; i < ids.length; i += CHUNK3) {
3321
+ const batch = ids.slice(i, i + CHUNK3);
3322
+ const ph = batch.map(() => "?").join(",");
2872
3323
  const usage = db.prepare(`SELECT session_id, model,
2873
3324
  SUM(input_tokens) i, SUM(output_tokens) o,
2874
3325
  SUM(cache_creation_input_tokens) cw, SUM(cache_read_input_tokens) cr
2875
- FROM token_usage WHERE session_id IN (${ph}) GROUP BY session_id, model`).all(...ids);
3326
+ FROM token_usage WHERE session_id IN (${ph}) GROUP BY session_id, model`).all(...batch);
2876
3327
  for (const u of usage) {
2877
3328
  const acc = costBySession.get(u.session_id) ?? { tokens: 0, cost: 0, split: { input: 0, output: 0, cache_creation: 0, cache_read: 0 } };
2878
3329
  acc.tokens += u.i + u.o + u.cw + u.cr;
@@ -2896,7 +3347,6 @@ function listSessions(db, f) {
2896
3347
  r.cost = c ? Number(c.cost.toFixed(4)) : 0;
2897
3348
  r.title = r.ai_title || r.slug || null;
2898
3349
  }
2899
- return { total, sessions: rows };
2900
3350
  }
2901
3351
  function getSession(db, id) {
2902
3352
  const session = db.prepare(`SELECT s.*, p.path AS project_path FROM sessions s
@@ -2911,6 +3361,17 @@ function getSession(db, id) {
2911
3361
  total_duration_ms, total_tokens, input_json, result_summary,
2912
3362
  (SELECT COUNT(*) FROM sessions s WHERE s.workflow_run_id = tool_calls.workflow_run_id) AS workflow_agent_count
2913
3363
  FROM tool_calls WHERE session_id = ?`).all(id);
3364
+ if (tableExists(db, "tool_results")) {
3365
+ const getFull = db.prepare("SELECT text, bytes FROM tool_results WHERE session_id = ? AND name = ?");
3366
+ for (const t of toolRows) {
3367
+ const m = typeof t.result_summary === "string" ? t.result_summary.match(/tool-results\/([A-Za-z0-9_-]+)\.txt/) : null;
3368
+ if (!m)
3369
+ continue;
3370
+ const full = getFull.get(id, m[1]);
3371
+ if (full)
3372
+ t.full_result = { text: full.text, bytes: full.bytes };
3373
+ }
3374
+ }
2914
3375
  const toolsByEvent = /* @__PURE__ */ new Map();
2915
3376
  for (const t of toolRows) {
2916
3377
  if (!t.event_uuid)
@@ -2978,9 +3439,12 @@ function getSession(db, id) {
2978
3439
  if (p)
2979
3440
  parent = { id: p.id, title: p.ai_title || p.slug || null, turn_seq: p.turn_seq ?? null };
2980
3441
  }
3442
+ const hasMeta = tableExists(db, "session_meta");
2981
3443
  const children = db.prepare(`SELECT s.id, s.ai_title, s.slug, s.turn_count, s.started_at, s.workflow_run_id,
2982
3444
  (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
2983
- FROM sessions s WHERE s.parent_session_id = ? ORDER BY s.started_at`).all(id);
3445
+ ${hasMeta ? ", sm.agent_type, sm.agent_description, sm.spawn_depth" : ", NULL AS agent_type, NULL AS agent_description, NULL AS spawn_depth"}
3446
+ FROM sessions s ${hasMeta ? "LEFT JOIN session_meta sm ON sm.session_id = s.id" : ""}
3447
+ WHERE s.parent_session_id = ? ORDER BY s.started_at`).all(id);
2984
3448
  for (const ch of children) {
2985
3449
  ch.title = ch.ai_title || ch.slug || null;
2986
3450
  const u = db.prepare(`SELECT model, SUM(input_tokens) i, SUM(output_tokens) o,
@@ -2989,8 +3453,10 @@ function getSession(db, id) {
2989
3453
  ch.tokens = u.reduce((a, r) => a + r.i + r.o + r.cw + r.cr, 0);
2990
3454
  ch.cost = Number(u.reduce((a, r) => a + costForUsage(r.model, { input_tokens: r.i, output_tokens: r.o, cache_creation_input_tokens: r.cw, cache_read_input_tokens: r.cr }), 0).toFixed(4));
2991
3455
  }
3456
+ const hasWR = tableExists(db, "workflow_results");
2992
3457
  const workflow_runs = db.prepare(`SELECT tc.workflow_run_id AS run_id, tc.workflow_name AS name, t.seq AS turn_seq,
2993
3458
  (SELECT COUNT(*) FROM sessions s WHERE s.workflow_run_id = tc.workflow_run_id) AS agent_count
3459
+ ${hasWR ? ", (SELECT status FROM workflow_results wr WHERE wr.run_id = tc.workflow_run_id) AS status" : ", NULL AS status"}
2994
3460
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
2995
3461
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
2996
3462
  ORDER BY t.seq`).all(id);
@@ -2999,8 +3465,21 @@ function getSession(db, id) {
2999
3465
  function xmlTag(text, tag) {
3000
3466
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
3001
3467
  }
3468
+ function tableExists(db, name) {
3469
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(name);
3470
+ }
3471
+ function safeJson(s) {
3472
+ if (!s)
3473
+ return null;
3474
+ try {
3475
+ return JSON.parse(s);
3476
+ } catch {
3477
+ return null;
3478
+ }
3479
+ }
3002
3480
  function getWorkflow(db, runId) {
3003
3481
  const wf = db.prepare(`SELECT tc.id AS tool_use_id, tc.workflow_run_id AS run_id, tc.workflow_name AS name, tc.status, tc.result_summary,
3482
+ tc.input_json AS input_json,
3004
3483
  tc.session_id AS parent_session_id, t.seq AS turn_seq,
3005
3484
  ps.ai_title AS parent_ai_title, ps.slug AS parent_slug
3006
3485
  FROM tool_calls tc
@@ -3011,9 +3490,12 @@ function getWorkflow(db, runId) {
3011
3490
  LIMIT 1`).get(runId);
3012
3491
  if (!wf)
3013
3492
  return null;
3493
+ const hasMeta = tableExists(db, "session_meta");
3014
3494
  const agents = db.prepare(`SELECT s.id, s.ai_title, s.slug, s.turn_count, s.started_at, s.ended_at, s.duration_ms,
3015
3495
  (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
3016
- FROM sessions s WHERE s.workflow_run_id = ? ORDER BY s.started_at`).all(runId);
3496
+ ${hasMeta ? ", sm.agent_type, sm.agent_description, sm.spawn_depth" : ", NULL AS agent_type, NULL AS agent_description, NULL AS spawn_depth"}
3497
+ FROM sessions s ${hasMeta ? "LEFT JOIN session_meta sm ON sm.session_id = s.id" : ""}
3498
+ WHERE s.workflow_run_id = ? ORDER BY s.started_at`).all(runId);
3017
3499
  let total_tokens = 0;
3018
3500
  let total_cost = 0;
3019
3501
  let started_at = null;
@@ -3044,12 +3526,44 @@ function getWorkflow(db, runId) {
3044
3526
  completion = { status: xmlTag(t, "status"), summary: xmlTag(t, "summary"), result: xmlTag(t, "result"), failures: xmlTag(t, "failures") };
3045
3527
  }
3046
3528
  }
3529
+ const wr = tableExists(db, "workflow_results") ? db.prepare(`SELECT status, summary, default_model, result_json, phases_json, logs_json, progress_json,
3530
+ agent_count, total_tokens, total_tool_calls, duration_ms, started_at, ended_at
3531
+ FROM workflow_results WHERE run_id = ?`).get(wf.run_id) : null;
3532
+ let run = null;
3533
+ if (wr) {
3534
+ run = {
3535
+ status: wr.status ?? null,
3536
+ summary: wr.summary ?? null,
3537
+ default_model: wr.default_model ?? null,
3538
+ agent_count: wr.agent_count ?? null,
3539
+ total_tokens: wr.total_tokens ?? null,
3540
+ total_tool_calls: wr.total_tool_calls ?? null,
3541
+ duration_ms: wr.duration_ms ?? null,
3542
+ started_at: wr.started_at ?? null,
3543
+ ended_at: wr.ended_at ?? null,
3544
+ phases: safeJson(wr.phases_json),
3545
+ logs: safeJson(wr.logs_json),
3546
+ // The per-phase/per-agent event timeline (workflowProgress) — lets the page render a phase graph
3547
+ // with a per-phase descriptor (agent count, models). Absent on older/failed runs → UI falls back.
3548
+ progress: safeJson(wr.progress_json)
3549
+ };
3550
+ if (wr.result_json != null || wr.status) {
3551
+ completion = {
3552
+ status: wr.status ?? completion?.status ?? null,
3553
+ summary: wr.summary ?? completion?.summary ?? null,
3554
+ result: wr.result_json ?? completion?.result ?? null,
3555
+ failures: completion?.failures ?? null
3556
+ };
3557
+ }
3558
+ }
3047
3559
  return {
3048
3560
  run_id: wf.run_id,
3049
3561
  name: wf.name ?? null,
3050
- status: wf.status ?? null,
3562
+ status: wr?.status ?? wf.status ?? null,
3051
3563
  result_summary: wf.result_summary ?? null,
3564
+ input_json: wf.input_json ?? null,
3052
3565
  completion,
3566
+ run,
3053
3567
  parent: { id: wf.parent_session_id, title: wf.parent_ai_title || wf.parent_slug || null, turn_seq: wf.turn_seq ?? null },
3054
3568
  agents,
3055
3569
  stats: { agent_count: agents.length, total_tokens, total_cost: Number(total_cost.toFixed(4)), started_at, ended_at, duration_ms }
@@ -3058,8 +3572,11 @@ function getWorkflow(db, runId) {
3058
3572
  function listSkills(db, f = {}) {
3059
3573
  const where = ["tc.tool_name = 'Skill'", "tc.skill_name IS NOT NULL"];
3060
3574
  const params = [];
3061
- if (f.q && f.q.trim())
3062
- where.push("tc.skill_name LIKE ?"), params.push(`%${f.q.trim()}%`);
3575
+ if (f.q && f.q.trim()) {
3576
+ const like = `%${f.q.trim()}%`;
3577
+ where.push("(tc.skill_name LIKE ? OR EXISTS (SELECT 1 FROM skills sk WHERE sk.name = tc.skill_name AND sk.body LIKE ?))");
3578
+ params.push(like, like);
3579
+ }
3063
3580
  if (f.source)
3064
3581
  where.push("s.source_id = ?"), params.push(f.source);
3065
3582
  if (f.project)
@@ -3276,7 +3793,7 @@ function dashboardBreakdowns(db, f) {
3276
3793
 
3277
3794
  // ../server/dist/refresh.js
3278
3795
  import { existsSync as existsSync5, mkdirSync as mkdirSync5 } from "fs";
3279
- import { join as join6 } from "path";
3796
+ import { join as join9 } from "path";
3280
3797
  function originAllowed(origin) {
3281
3798
  if (!origin)
3282
3799
  return true;
@@ -3289,12 +3806,12 @@ function originAllowed(origin) {
3289
3806
  }
3290
3807
  function runRefresh() {
3291
3808
  const dataDir = resolveDataDir(findRepoRoot());
3292
- const lock = acquireLock(join6(dataDir, ".agent-lens.lock"));
3809
+ const lock = acquireLock(join9(dataDir, ".agent-lens.lock"));
3293
3810
  if (!lock)
3294
3811
  return null;
3295
3812
  try {
3296
3813
  const collected = collectAll();
3297
- const archiveRoot = process.env.AGENT_LENS_ARCHIVE || join6(dataDir, "archive");
3814
+ const archiveRoot = process.env.AGENT_LENS_ARCHIVE || join9(dataDir, "archive");
3298
3815
  if (!existsSync5(archiveRoot))
3299
3816
  mkdirSync5(archiveRoot, { recursive: true });
3300
3817
  runIngest([]);
@@ -3307,7 +3824,10 @@ function runRefresh() {
3307
3824
  // ../server/dist/app.js
3308
3825
  async function createApp(db, opts = {}) {
3309
3826
  const app = Fastify({ logger: false });
3310
- app.get("/api/health", async () => ({ ok: true, last_ingested: lastIngested(db) }));
3827
+ app.get("/api/health", async () => {
3828
+ const s = schemaStatus(db);
3829
+ return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
3830
+ });
3311
3831
  app.post("/api/refresh", async (req, reply) => {
3312
3832
  if (!originAllowed(req.headers.origin)) {
3313
3833
  return reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin refresh blocked" } });
@@ -3346,6 +3866,8 @@ async function createApp(db, opts = {}) {
3346
3866
  from: q.from,
3347
3867
  to: q.to,
3348
3868
  kind: q.kind === "main" || q.kind === "subagent" ? q.kind : void 0,
3869
+ sort: ["started", "title", "turns", "tokens", "cost", "duration"].find((s) => s === q.sort),
3870
+ dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
3349
3871
  limit: Math.min(Number(q.limit) || 50, 200),
3350
3872
  offset: Number(q.offset) || 0
3351
3873
  });
@@ -3421,7 +3943,7 @@ async function createApp(db, opts = {}) {
3421
3943
  async function startServer() {
3422
3944
  const here = dirname5(fileURLToPath2(import.meta.url));
3423
3945
  const repoRoot = findRepoRoot(here);
3424
- const dbPath = process.env.AGENT_LENS_DB || join7(resolveDataDir(repoRoot), "agent-lens.db");
3946
+ const dbPath = process.env.AGENT_LENS_DB || join10(resolveDataDir(repoRoot), "agent-lens.db");
3425
3947
  const host = process.env.AGENT_LENS_HOST || "127.0.0.1";
3426
3948
  const port = Number(process.env.AGENT_LENS_PORT || 4477);
3427
3949
  const webDist = resolveWebDist(here, repoRoot);
@@ -3448,11 +3970,11 @@ async function startServer() {
3448
3970
 
3449
3971
  // src/watch.ts
3450
3972
  import { existsSync as existsSync8 } from "fs";
3451
- import { join as join8 } from "path";
3973
+ import { join as join11 } from "path";
3452
3974
  import chokidar from "chokidar";
3453
3975
  async function runWatch(opts = {}) {
3454
3976
  const sources = loadSources();
3455
- const lockPath = join8(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
3977
+ const lockPath = join11(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
3456
3978
  let running = false;
3457
3979
  let pending = false;
3458
3980
  const cycle = () => {
@@ -3485,7 +4007,7 @@ async function runWatch(opts = {}) {
3485
4007
  }
3486
4008
  };
3487
4009
  cycle();
3488
- const watchPaths = sources.map((s) => join8(s.configDir, "projects")).filter((p) => existsSync8(p));
4010
+ const watchPaths = sources.map((s) => join11(s.configDir, "projects")).filter((p) => existsSync8(p));
3489
4011
  if (watchPaths.length === 0) {
3490
4012
  console.error("agent-lens watch: no source 'projects' directories found \u2014 nothing to watch");
3491
4013
  }
@@ -3513,14 +4035,14 @@ async function runWatch(opts = {}) {
3513
4035
  // src/index.ts
3514
4036
  function version() {
3515
4037
  try {
3516
- const pkg = JSON.parse(readFileSync5(join9(dirname6(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
4038
+ const pkg = JSON.parse(readFileSync8(join12(dirname6(fileURLToPath3(import.meta.url)), "../package.json"), "utf8"));
3517
4039
  return pkg.version ?? "0.0.0";
3518
4040
  } catch {
3519
4041
  return "0.0.0";
3520
4042
  }
3521
4043
  }
3522
4044
  function withLock(fn) {
3523
- const lockPath = join9(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
4045
+ const lockPath = join12(resolveDataDir(findRepoRoot()), ".agent-lens.lock");
3524
4046
  const lock = acquireLock(lockPath);
3525
4047
  if (!lock) {
3526
4048
  console.error("agent-lens: another collect/ingest run is in progress \u2014 skipping");