@swestash/agent-lens 0.17.0 → 0.17.2

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.
@@ -592,6 +592,11 @@ var cac = (name = "") => new CAC(name);
592
592
 
593
593
  // ../core/dist/schema.js
594
594
  var SCHEMA_VERSION = 15;
595
+ var ANALYZE_SQL = "PRAGMA analysis_limit = 1000; ANALYZE;";
596
+ var BULK_DEFERRED = {
597
+ indexes: ["idx_events_type", "idx_events_ts", "idx_events_turn_reply", "idx_events_uuid_cover"],
598
+ triggers: ["events_ai", "events_ad", "events_au"]
599
+ };
595
600
  var SCHEMA_SQL = (
596
601
  /* sql */
597
602
  `
@@ -694,9 +699,22 @@ CREATE TABLE IF NOT EXISTS events (
694
699
  source_file TEXT -- archive path the canonical copy came from
695
700
  );
696
701
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
697
- CREATE INDEX IF NOT EXISTS idx_events_turn ON events(turn_id);
698
702
  CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
699
703
  CREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp);
704
+ -- Covering indexes for the dashboard joins. An events row averages ~3.4 KB (raw_json + text), so
705
+ -- about one row fits per page, and a join that reads the row instead of an index pays one random
706
+ -- page read per event: on the real corpus /dashboard/time took 31s from a cold cache. These hold
707
+ -- exactly the columns those joins read. The PK lookups (token_usage, tool_calls, findings,
708
+ -- file_changes \u2192 events by uuid) want timestamp and model, and model latency wants the first
709
+ -- assistant timestamp per turn. Both are index-only scans now, and the guard in
710
+ -- packages/server/test/query-plans.test.ts keeps them that way.
711
+ -- No SCHEMA_VERSION bump: indexes need no rebuild, and CREATE IF NOT EXISTS adds them on the next
712
+ -- incremental ingest. A bump would make that ingest refuse and demand --full.
713
+ -- An index's columns cannot be widened in place, so idx_events_turn is replaced under a new name.
714
+ -- idx_events_turn_reply serves every lookup the old one did, because turn_id is still its prefix.
715
+ DROP INDEX IF EXISTS idx_events_turn;
716
+ CREATE INDEX IF NOT EXISTS idx_events_turn_reply ON events(turn_id, role, timestamp);
717
+ CREATE INDEX IF NOT EXISTS idx_events_uuid_cover ON events(uuid, timestamp, model);
700
718
 
701
719
  -- Tool invocations (incl. subagents / Task calls). -------------------------
702
720
  CREATE TABLE IF NOT EXISTS tool_calls (
@@ -721,13 +739,32 @@ CREATE TABLE IF NOT EXISTS tool_calls (
721
739
  input_json TEXT, -- tool input, verbatim
722
740
  result_summary TEXT -- short result projection (no secrets dumped wholesale)
723
741
  );
724
- CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
725
- CREATE INDEX IF NOT EXISTS idx_tool_calls_tool ON tool_calls(tool_name);
726
- CREATE INDEX IF NOT EXISTS idx_tool_calls_skill ON tool_calls(skill_name);
727
- CREATE INDEX IF NOT EXISTS idx_tool_calls_skill_id ON tool_calls(skill_id);
742
+ -- Covering indexes for the dashboard aggregates. A tool_calls row averages ~2.3 KB (input_json), and
743
+ -- the aggregates read only a handful of narrow columns. Each index below carries all of them, so
744
+ -- whether the planner drives from sessions (a selective source/date filter) or scans tool_calls
745
+ -- (no filter), it never reads the row. Before this, the errored-calls series scanned the whole
746
+ -- table, 7.5s cold on the real corpus. packages/server/test/query-plans.test.ts holds the line.
747
+ -- They replace the single-column session/tool/skill/skill_id/error_type indexes under new names, because
748
+ -- CREATE IF NOT EXISTS cannot widen an index that already exists. Each new index begins with the old
749
+ -- index's column, so every existing lookup still uses it. Same no-bump reasoning as the events
750
+ -- indexes above.
751
+ DROP INDEX IF EXISTS idx_tool_calls_session;
752
+ DROP INDEX IF EXISTS idx_tool_calls_tool;
753
+ DROP INDEX IF EXISTS idx_tool_calls_skill;
754
+ DROP INDEX IF EXISTS idx_tool_calls_skill_id;
755
+ DROP INDEX IF EXISTS idx_tool_calls_error_type;
756
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_by_session
757
+ ON tool_calls(session_id, tool_name, status, error_type, skill_name, skill_id, agent_type, event_uuid);
758
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_by_tool ON tool_calls(tool_name, session_id, status, error_type, event_uuid);
759
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_by_skill ON tool_calls(skill_name, skill_id, session_id, event_uuid);
760
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_by_skill_id ON tool_calls(skill_id, skill_name, session_id, event_uuid);
761
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_by_error ON tool_calls(error_type, status, session_id, event_uuid);
728
762
  CREATE INDEX IF NOT EXISTS idx_tool_calls_spawned ON tool_calls(spawned_session_id);
729
763
  CREATE INDEX IF NOT EXISTS idx_tool_calls_workflow_run ON tool_calls(workflow_run_id);
730
- CREATE INDEX IF NOT EXISTS idx_tool_calls_error_type ON tool_calls(error_type);
764
+ -- Every column that REFERENCES turns(id) needs an index: deleting a turn makes SQLite look up its
765
+ -- referencing rows, and without one that is a full scan of this table per deleted turn. The
766
+ -- incremental rebuild deletes the dirty sessions' turns, so five changed files cost 12.9 GB of reads.
767
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_turn ON tool_calls(turn_id);
731
768
 
732
769
  -- Skill *versions* (content-addressed). A skill firing injects the full SKILL.md body into the
733
770
  -- transcript as an isMeta user event (begins "Base directory for this skill: \u2026", then the body,
@@ -767,6 +804,8 @@ CREATE TABLE IF NOT EXISTS token_usage (
767
804
  service_tier TEXT
768
805
  );
769
806
  CREATE INDEX IF NOT EXISTS idx_token_usage_session ON token_usage(session_id);
807
+ -- Foreign-key lookup for deleting turns; see idx_tool_calls_turn.
808
+ CREATE INDEX IF NOT EXISTS idx_token_usage_turn ON token_usage(turn_id);
770
809
  -- Partial so rare usage rows lacking a message_id (e.g. <synthetic>) don't collide on NULL. --
771
810
  CREATE UNIQUE INDEX IF NOT EXISTS idx_token_usage_msg
772
811
  ON token_usage(session_id, message_id) WHERE message_id IS NOT NULL;
@@ -2282,9 +2321,11 @@ import { dirname as dirname6, join as join9 } from "node:path";
2282
2321
 
2283
2322
  // ../ingest/dist/db.js
2284
2323
  import { DatabaseSync } from "node:sqlite";
2324
+ var WAL_SIZE_LIMIT = 64 * 1024 * 1024;
2285
2325
  function openRaw(file) {
2286
2326
  const db = new DatabaseSync(file);
2287
2327
  db.exec("PRAGMA journal_mode = WAL");
2328
+ db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT}`);
2288
2329
  db.exec("PRAGMA foreign_keys = ON");
2289
2330
  return db;
2290
2331
  }
@@ -2304,7 +2345,7 @@ function openDb(file) {
2304
2345
  applySchema(db);
2305
2346
  return db;
2306
2347
  }
2307
- function resetSchema(db) {
2348
+ function resetSchema(db, { bulk = false } = {}) {
2308
2349
  db.exec("PRAGMA foreign_keys = OFF");
2309
2350
  try {
2310
2351
  db.exec(`
@@ -2331,10 +2372,20 @@ function resetSchema(db) {
2331
2372
  DROP TABLE IF EXISTS meta;
2332
2373
  `);
2333
2374
  applySchema(db);
2375
+ if (bulk) {
2376
+ for (const name of BULK_DEFERRED.indexes)
2377
+ db.exec(`DROP INDEX IF EXISTS ${name}`);
2378
+ for (const name of BULK_DEFERRED.triggers)
2379
+ db.exec(`DROP TRIGGER IF EXISTS ${name}`);
2380
+ }
2334
2381
  } finally {
2335
2382
  db.exec("PRAGMA foreign_keys = ON");
2336
2383
  }
2337
2384
  }
2385
+ function finishBulkLoad(db) {
2386
+ db.exec(SCHEMA_SQL);
2387
+ db.exec("INSERT INTO events_fts(events_fts) VALUES('rebuild')");
2388
+ }
2338
2389
 
2339
2390
  // ../ingest/dist/adapters/claude-code.js
2340
2391
  import { readdirSync as readdirSync2 } from "node:fs";
@@ -3643,6 +3694,13 @@ function nearestGitRoot(path3, home) {
3643
3694
  }
3644
3695
  return null;
3645
3696
  }
3697
+ function sessionCwd(first, last) {
3698
+ const f = strip(first);
3699
+ const l = strip(last);
3700
+ if (f === l || existsSync4(l))
3701
+ return last;
3702
+ return l.startsWith(f === "/" ? "/" : f + "/") ? first : last;
3703
+ }
3646
3704
  function canonicalizeProjects(db, opts = {}) {
3647
3705
  const home = strip(opts.homeDir ?? homedir3());
3648
3706
  const rows = db.prepare("SELECT id, agent_id, path FROM projects").all();
@@ -3729,6 +3787,77 @@ function xmlTag(text, tag) {
3729
3787
  return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
3730
3788
  }
3731
3789
 
3790
+ // ../ingest/dist/skillslots.js
3791
+ var ARG_TRAILERS = ["\nARGUMENTS:", "\n## User Request\n"];
3792
+ var EMPTY_OUTPUT = "(Bash completed with no output)";
3793
+ var DYNAMIC_PLACEHOLDER = "(dynamic context: command output omitted)";
3794
+ var isMarker = (t) => t === EMPTY_OUTPUT || t === DYNAMIC_PLACEHOLDER;
3795
+ function uniqueLine(lines, text) {
3796
+ let at = -1;
3797
+ for (let i = 0; i < lines.length; i++) {
3798
+ if (lines[i].trim() !== text)
3799
+ continue;
3800
+ if (at >= 0)
3801
+ return -1;
3802
+ at = i;
3803
+ }
3804
+ return at;
3805
+ }
3806
+ function learnSlots(body) {
3807
+ const lines = body.split("\n");
3808
+ const trimmed = lines.map((l) => l.trim());
3809
+ const slots = [];
3810
+ for (let i = 0; i < lines.length; i++) {
3811
+ if (!isMarker(trimmed[i]))
3812
+ continue;
3813
+ let b = i - 1;
3814
+ while (b >= 0 && trimmed[b] === "")
3815
+ b--;
3816
+ let a = i + 1;
3817
+ while (a < lines.length && (trimmed[a] === "" || isMarker(trimmed[a])))
3818
+ a++;
3819
+ i = a - 1;
3820
+ if (b < 0 || a >= lines.length)
3821
+ continue;
3822
+ const slot = { before: trimmed[b], after: trimmed[a] };
3823
+ if (uniqueLine(lines, slot.before) < 0 || uniqueLine(lines, slot.after) < 0)
3824
+ continue;
3825
+ if (!slots.some((s) => s.before === slot.before && s.after === slot.after))
3826
+ slots.push(slot);
3827
+ }
3828
+ return slots;
3829
+ }
3830
+ function mergeSlots(into, body) {
3831
+ for (const s of learnSlots(body))
3832
+ if (!into.some((x) => x.before === s.before && x.after === s.after))
3833
+ into.push(s);
3834
+ return into;
3835
+ }
3836
+ function maskDynamic(body, slots) {
3837
+ let lines = body.split("\n");
3838
+ for (const { before, after } of slots) {
3839
+ const b = uniqueLine(lines, before);
3840
+ const a = uniqueLine(lines, after);
3841
+ if (b < 0 || a <= b)
3842
+ continue;
3843
+ lines = [...lines.slice(0, b + 1), "", DYNAMIC_PLACEHOLDER, "", ...lines.slice(a)];
3844
+ }
3845
+ return lines.join("\n");
3846
+ }
3847
+ var MIN_MASKED_ARGS = 8;
3848
+ function maskKnownValues(body, known) {
3849
+ const subs = [];
3850
+ const args = known.args?.trim();
3851
+ if (args && args.length >= MIN_MASKED_ARGS)
3852
+ subs.push([args, "$ARGUMENTS"]);
3853
+ if (known.baseDir)
3854
+ subs.push([known.baseDir, "${CLAUDE_SKILL_DIR}"]);
3855
+ if (known.sessionId)
3856
+ subs.push([known.sessionId, "${CLAUDE_SESSION_ID}"]);
3857
+ subs.sort((x, y) => y[0].length - x[0].length);
3858
+ return subs.reduce((out, [value, placeholder]) => out.split(value).join(placeholder), body);
3859
+ }
3860
+
3732
3861
  // ../ingest/dist/pipeline.js
3733
3862
  function newStats() {
3734
3863
  return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
@@ -3750,9 +3879,15 @@ function parseSkillInjection(text) {
3750
3879
  const baseDir = firstLine || null;
3751
3880
  let body = firstNl >= 0 ? text.slice(firstNl + 1) : "";
3752
3881
  let args = null;
3753
- const argIdx = body.lastIndexOf("\nARGUMENTS:");
3882
+ let argIdx = -1;
3883
+ let trailer = "";
3884
+ for (const t of ARG_TRAILERS) {
3885
+ const i = body.lastIndexOf(t);
3886
+ if (i > argIdx)
3887
+ [argIdx, trailer] = [i, t];
3888
+ }
3754
3889
  if (argIdx >= 0) {
3755
- args = body.slice(argIdx + "\nARGUMENTS:".length).trim() || null;
3890
+ args = body.slice(argIdx + trailer.length).trim() || null;
3756
3891
  body = body.slice(0, argIdx);
3757
3892
  }
3758
3893
  return { baseDir, nameKey: baseDir ? skillNameKey(baseDir) : "", body: body.trim(), args };
@@ -3822,6 +3957,10 @@ function durationMs(start, end) {
3822
3957
  return null;
3823
3958
  return Math.max(0, b - a);
3824
3959
  }
3960
+ function isTurnStart(e) {
3961
+ const body = e.text?.trim() ?? "";
3962
+ return e.type === "user" && e.is_meta === 0 && body.length > 0 && !isCommandResultCarrier(body);
3963
+ }
3825
3964
  function buildTurns(sessionId, rows) {
3826
3965
  rows.sort((x, y) => (x.timestamp ?? "").localeCompare(y.timestamp ?? "") || (x.seq ?? 0) - (y.seq ?? 0) || x.uuid.localeCompare(y.uuid));
3827
3966
  const turns = [];
@@ -3829,9 +3968,7 @@ function buildTurns(sessionId, rows) {
3829
3968
  let cur = null;
3830
3969
  let seq = 0;
3831
3970
  for (const e of rows) {
3832
- const body = e.text?.trim() ?? "";
3833
- const isPrompt = e.type === "user" && e.is_meta === 0 && body.length > 0 && !isCommandResultCarrier(body);
3834
- if (isPrompt) {
3971
+ if (isTurnStart(e)) {
3835
3972
  cur = {
3836
3973
  id: `${sessionId}:${seq}`,
3837
3974
  session_id: sessionId,
@@ -3888,7 +4025,7 @@ function expandDirtyScope(db, dirty) {
3888
4025
  }
3889
4026
  }
3890
4027
  function rebuildTurns(db, scope, sessionIds) {
3891
- const selEvents = db.prepare("SELECT uuid, seq, type, is_sidechain, is_meta, timestamp, model, text FROM events WHERE session_id = ?");
4028
+ const selEvents = db.prepare("SELECT uuid, seq, type, is_sidechain, is_meta, timestamp, model, text, turn_id FROM events WHERE session_id = ?");
3892
4029
  const insTurn = db.prepare(`INSERT INTO turns (id, session_id, seq, user_event_uuid, prompt_preview, model, started_at, ended_at, duration_ms)
3893
4030
  VALUES (@id, @session_id, @seq, @user_event_uuid, @prompt_preview, @model, @started_at, @ended_at, @duration_ms)`);
3894
4031
  const updEventTurn = db.prepare("UPDATE events SET turn_id = ? WHERE uuid = ?");
@@ -3898,8 +4035,11 @@ function rebuildTurns(db, scope, sessionIds) {
3898
4035
  const { turns, eventTurn } = buildTurns(id, rows);
3899
4036
  for (const t of turns)
3900
4037
  runNamed(insTurn, t);
3901
- for (const [uuid, turnId] of eventTurn)
3902
- updEventTurn.run(turnId, uuid);
4038
+ for (const r of rows) {
4039
+ const turnId = eventTurn.get(r.uuid) ?? null;
4040
+ if (turnId !== r.turn_id)
4041
+ updEventTurn.run(turnId, r.uuid);
4042
+ }
3903
4043
  }
3904
4044
  });
3905
4045
  tx();
@@ -3916,6 +4056,15 @@ function linkSkillVersions(db, sessionIds) {
3916
4056
  first_seen = MIN(first_seen, excluded.first_seen),
3917
4057
  base_dir = excluded.base_dir`);
3918
4058
  const linkCall = db.prepare("UPDATE tool_calls SET skill_id = ? WHERE id = ?");
4059
+ const selLinkedVersions = db.prepare("SELECT id, base_dir, body, first_seen, last_seen FROM skills WHERE name = ? AND id IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
4060
+ const upsertRemasked = db.prepare(`INSERT INTO skills (id, name, base_dir, body, summary, body_bytes, first_seen, last_seen)
4061
+ VALUES (@id, @name, @base_dir, @body, @summary, @body_bytes, @first_seen, @last_seen)
4062
+ ON CONFLICT(id) DO UPDATE SET
4063
+ last_seen = MAX(last_seen, excluded.last_seen),
4064
+ first_seen = MIN(first_seen, excluded.first_seen)`);
4065
+ const relinkVersion = db.prepare("UPDATE tool_calls SET skill_id = ? WHERE skill_id = ?");
4066
+ const delVersion = db.prepare("DELETE FROM skills WHERE id = ?");
4067
+ const matches = [];
3919
4068
  const linkTx = transaction(db, () => {
3920
4069
  for (const { id: sid } of sessionIds) {
3921
4070
  const calls = selSkillCalls.all(sid);
@@ -3965,18 +4114,53 @@ function linkSkillVersions(db, sessionIds) {
3965
4114
  idx = 0;
3966
4115
  const [match] = queue.splice(idx, 1);
3967
4116
  const c = calls.find((x) => x.id === match.id);
3968
- const name = c.skill_name;
3969
- const vid = skillVersionId(name, inj.body);
3970
- upsertSkill.run({
4117
+ const body = maskKnownValues(inj.body, { args: match.args ?? inj.args, baseDir: inj.baseDir, sessionId: sid });
4118
+ matches.push({ callId: match.id, name: c.skill_name, baseDir: inj.baseDir, body, ts: e.timestamp });
4119
+ }
4120
+ }
4121
+ const slotsByName = /* @__PURE__ */ new Map();
4122
+ const addSlots = (name, body) => slotsByName.set(name, mergeSlots(slotsByName.get(name) ?? [], body));
4123
+ for (const m of matches)
4124
+ addSlots(m.name, m.body);
4125
+ for (const name of slotsByName.keys()) {
4126
+ for (const v of selLinkedVersions.all(name))
4127
+ addSlots(name, v.body);
4128
+ }
4129
+ for (const m of matches) {
4130
+ const body = maskDynamic(m.body, slotsByName.get(m.name));
4131
+ const vid = skillVersionId(m.name, body);
4132
+ upsertSkill.run({
4133
+ id: vid,
4134
+ name: m.name,
4135
+ base_dir: m.baseDir,
4136
+ body,
4137
+ summary: skillSummary(body),
4138
+ body_bytes: Buffer.byteLength(body, "utf8"),
4139
+ ts: m.ts
4140
+ });
4141
+ linkCall.run(vid, m.callId);
4142
+ }
4143
+ for (const [name, slots] of slotsByName) {
4144
+ if (!slots.length)
4145
+ continue;
4146
+ const versions = selLinkedVersions.all(name);
4147
+ for (const v of versions) {
4148
+ const body = maskDynamic(v.body, slots);
4149
+ if (body === v.body)
4150
+ continue;
4151
+ const vid = skillVersionId(name, body);
4152
+ upsertRemasked.run({
3971
4153
  id: vid,
3972
4154
  name,
3973
- base_dir: inj.baseDir,
3974
- body: inj.body,
3975
- summary: skillSummary(inj.body),
3976
- body_bytes: Buffer.byteLength(inj.body, "utf8"),
3977
- ts: e.timestamp
4155
+ base_dir: v.base_dir,
4156
+ body,
4157
+ summary: skillSummary(body),
4158
+ body_bytes: Buffer.byteLength(body, "utf8"),
4159
+ first_seen: v.first_seen,
4160
+ last_seen: v.last_seen
3978
4161
  });
3979
- linkCall.run(vid, match.id);
4162
+ relinkVersion.run(vid, v.id);
4163
+ delVersion.run(v.id);
3980
4164
  }
3981
4165
  }
3982
4166
  });
@@ -4035,26 +4219,28 @@ function rebuildDerived(db, dirty) {
4035
4219
  andId: incremental ? " AND id IN (SELECT id FROM _dirty)" : "",
4036
4220
  andSelfId: incremental ? " AND sessions.id IN (SELECT id FROM _dirty)" : ""
4037
4221
  };
4038
- if (dirty != null)
4039
- expandDirtyScope(db, dirty);
4040
- db.exec(`UPDATE events SET turn_id = NULL${scope.bySession}`);
4041
- db.exec(`UPDATE token_usage SET turn_id = NULL${scope.bySession}`);
4042
- db.exec(`UPDATE tool_calls SET turn_id = NULL${scope.bySession}`);
4043
- db.exec(`UPDATE tool_calls SET skill_id = NULL${scope.bySession}`);
4044
- db.exec(`UPDATE sessions SET parent_turn_id = NULL${scope.byId}`);
4045
- db.exec(`DELETE FROM turns${scope.bySession}`);
4046
- const sessionIds = db.prepare(`SELECT id FROM sessions${scope.byId}`).all();
4047
- rebuildTurns(db, scope, sessionIds);
4048
- linkSkillVersions(db, sessionIds);
4049
- linkParents(db, scope);
4050
- recomputeSessionAggregates(db, scope);
4051
- pruneOrphans(db, scope, incremental);
4052
- if (incremental) {
4053
- const expanded = new Set(db.prepare("SELECT id FROM _dirty").all().map((r) => r.id));
4054
- db.exec("DROP TABLE IF EXISTS _dirty");
4055
- return expanded;
4056
- }
4057
- return null;
4222
+ return transaction(db, () => {
4223
+ if (dirty != null)
4224
+ expandDirtyScope(db, dirty);
4225
+ db.exec("PRAGMA defer_foreign_keys = ON");
4226
+ db.exec(`UPDATE token_usage SET turn_id = NULL${scope.bySession}`);
4227
+ db.exec(`UPDATE tool_calls SET turn_id = NULL${scope.bySession}`);
4228
+ db.exec(`UPDATE tool_calls SET skill_id = NULL${scope.bySession}`);
4229
+ db.exec(`UPDATE sessions SET parent_turn_id = NULL${scope.byId}`);
4230
+ db.exec(`DELETE FROM turns${scope.bySession}`);
4231
+ const sessionIds = db.prepare(`SELECT id FROM sessions${scope.byId}`).all();
4232
+ rebuildTurns(db, scope, sessionIds);
4233
+ linkSkillVersions(db, sessionIds);
4234
+ linkParents(db, scope);
4235
+ recomputeSessionAggregates(db, scope);
4236
+ pruneOrphans(db, scope, incremental);
4237
+ if (incremental) {
4238
+ const expanded = new Set(db.prepare("SELECT id FROM _dirty").all().map((r) => r.id));
4239
+ db.exec("DROP TABLE IF EXISTS _dirty");
4240
+ return expanded;
4241
+ }
4242
+ return null;
4243
+ })();
4058
4244
  }
4059
4245
  function prepareStatements(db) {
4060
4246
  return {
@@ -4111,9 +4297,11 @@ function prepareStatements(db) {
4111
4297
  workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
4112
4298
  };
4113
4299
  }
4114
- function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
4300
+ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats, opts = {}) {
4115
4301
  const sessionMeta = {};
4116
4302
  let eventsInFile = 0;
4303
+ let turnSeq = -1;
4304
+ let firstCwd = null;
4117
4305
  const tx = transaction(db, () => {
4118
4306
  stmts.insSessionStub.run(file.sessionId, adapter.agentId, file.sourceId);
4119
4307
  let seq = 0;
@@ -4128,10 +4316,19 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
4128
4316
  continue;
4129
4317
  }
4130
4318
  const parsed = adapter.parseLine(raw, file, seq++);
4131
- if (parsed.meta)
4319
+ if (parsed.meta) {
4320
+ if (parsed.meta.cwd && firstCwd === null)
4321
+ firstCwd = parsed.meta.cwd;
4132
4322
  Object.assign(sessionMeta, parsed.meta);
4323
+ }
4133
4324
  if (parsed.event) {
4134
- const info = stmts.insEvent.run({ ...parsed.event, raw_json: packRaw(parsed.event.raw_json) });
4325
+ let turnId = parsed.event.turn_id;
4326
+ if (opts.provisionalTurns) {
4327
+ if (isTurnStart(parsed.event))
4328
+ turnSeq++;
4329
+ turnId = turnSeq < 0 ? null : `${file.sessionId}:${turnSeq}`;
4330
+ }
4331
+ const info = stmts.insEvent.run({ ...parsed.event, turn_id: turnId, raw_json: packRaw(parsed.event.raw_json) });
4135
4332
  if (info.changes > 0) {
4136
4333
  stats.newEvents++;
4137
4334
  eventsInFile++;
@@ -4160,11 +4357,12 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
4160
4357
  }
4161
4358
  let projectId2 = null;
4162
4359
  if (sessionMeta.cwd) {
4163
- projectId2 = createHash5("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
4360
+ const cwd = sessionCwd(firstCwd ?? sessionMeta.cwd, sessionMeta.cwd);
4361
+ projectId2 = createHash5("sha1").update(`${adapter.agentId}\0${cwd}`).digest("hex").slice(0, 16);
4164
4362
  stmts.upsertProject.run({
4165
4363
  id: projectId2,
4166
4364
  agent_id: adapter.agentId,
4167
- path: sessionMeta.cwd,
4365
+ path: cwd,
4168
4366
  encoded_dir: file.encodedDir || null,
4169
4367
  now: now2
4170
4368
  });
@@ -4503,6 +4701,13 @@ function shouldReingest(path3, size, mtimeMs, prev) {
4503
4701
  return { action: "skip-hash", hash: hash2 };
4504
4702
  return { action: "ingest", hash: hash2, buf };
4505
4703
  }
4704
+ function assertForeignKeys(db) {
4705
+ const bad = db.prepare("PRAGMA foreign_key_check").all();
4706
+ if (!bad.length)
4707
+ return;
4708
+ const edges = [...new Set(bad.map((b) => `${b.table} -> ${b.parent}`))].join(", ");
4709
+ throw new Error(`agent-lens-ingest: --full produced ${bad.length} foreign-key violations (${edges}); rolled back`);
4710
+ }
4506
4711
  function printIngestReport(db, dbPath, r) {
4507
4712
  const count = (sql) => db.prepare(sql).get().n;
4508
4713
  const sessions = count("SELECT COUNT(*) n FROM sessions");
@@ -4549,9 +4754,7 @@ function runIngest(argv = process.argv.slice(2)) {
4549
4754
  }
4550
4755
  mkdirSync4(dirname6(dbPath), { recursive: true });
4551
4756
  const db = args.full ? openRaw(dbPath) : openDb(dbPath);
4552
- if (args.full) {
4553
- resetSchema(db);
4554
- } else {
4757
+ if (!args.full) {
4555
4758
  const dbVer = readSchemaVersion(db);
4556
4759
  if (dbVer != null && dbVer !== SCHEMA_VERSION) {
4557
4760
  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.`);
@@ -4565,62 +4768,81 @@ function runIngest(argv = process.argv.slice(2)) {
4565
4768
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
4566
4769
  const excludePaths = loadExcludes();
4567
4770
  const excludedDirs = parseExcludes(excludePaths.join(","));
4568
- const pruned = pruneExcluded(db, excludePaths);
4569
- const stmts = prepareStatements(db);
4570
- const stats = newStats();
4571
- const wfStats = newWorkflowStats();
4572
- const metaStats = newMetaStats();
4573
- const trStats = newToolResultStats();
4574
- const dirty = /* @__PURE__ */ new Set();
4575
- for (const source of sources) {
4576
- const adapter = adapterById.get(source.agent);
4577
- if (!adapter) {
4578
- console.warn(`agent-lens-ingest: no adapter for agent '${source.agent}' (source '${source.label}') \u2014 skipping`);
4579
- continue;
4580
- }
4581
- stmts.insAgent.run(adapter.agentId, adapter.agentName);
4582
- stmts.insSource.run({ id: source.label, label: source.label, agent_id: adapter.agentId, config_dir: source.configDir });
4583
- let files = adapter.discover(join9(archiveRoot, source.label), source.label);
4584
- if (excludedDirs.length)
4585
- files = files.filter((f) => !isExcludedArchivePath(f.path, excludedDirs));
4586
- files.sort((a, b) => Number(a.isVersion) - Number(b.isVersion));
4587
- for (const file of files) {
4588
- stats.files++;
4589
- const st = statSync3(file.path);
4590
- const mtimeMs = Math.trunc(st.mtimeMs);
4591
- const prev = args.full ? void 0 : stmts.getState.get(file.path);
4592
- const decision = shouldReingest(file.path, st.size, mtimeMs, prev);
4593
- if (decision.action === "skip-stat") {
4594
- stats.skipped++;
4595
- continue;
4596
- }
4597
- if (decision.action === "skip-hash") {
4598
- stmts.setState.run({
4599
- file_path: file.path,
4600
- size: st.size,
4601
- mtime_ms: mtimeMs,
4602
- sha256: decision.hash,
4603
- events_ingested: prev.events_ingested,
4604
- ingested_at: now2
4605
- });
4606
- stats.skipped++;
4607
- continue;
4771
+ const pruned = args.full ? 0 : pruneExcluded(db, excludePaths);
4772
+ if (args.full)
4773
+ db.exec("PRAGMA foreign_keys = OFF");
4774
+ let report;
4775
+ try {
4776
+ report = transaction(db, () => {
4777
+ if (args.full)
4778
+ resetSchema(db, { bulk: true });
4779
+ const stmts = prepareStatements(db);
4780
+ const stats = newStats();
4781
+ const wfStats = newWorkflowStats();
4782
+ const metaStats = newMetaStats();
4783
+ const trStats = newToolResultStats();
4784
+ const dirty = /* @__PURE__ */ new Set();
4785
+ for (const source of sources) {
4786
+ const adapter = adapterById.get(source.agent);
4787
+ if (!adapter) {
4788
+ console.warn(`agent-lens-ingest: no adapter for agent '${source.agent}' (source '${source.label}') \u2014 skipping`);
4789
+ continue;
4790
+ }
4791
+ stmts.insAgent.run(adapter.agentId, adapter.agentName);
4792
+ stmts.insSource.run({ id: source.label, label: source.label, agent_id: adapter.agentId, config_dir: source.configDir });
4793
+ let files = adapter.discover(join9(archiveRoot, source.label), source.label);
4794
+ if (excludedDirs.length)
4795
+ files = files.filter((f) => !isExcludedArchivePath(f.path, excludedDirs));
4796
+ files.sort((a, b) => Number(a.isVersion) - Number(b.isVersion));
4797
+ for (const file of files) {
4798
+ stats.files++;
4799
+ const st = statSync3(file.path);
4800
+ const mtimeMs = Math.trunc(st.mtimeMs);
4801
+ const prev = args.full ? void 0 : stmts.getState.get(file.path);
4802
+ const decision = shouldReingest(file.path, st.size, mtimeMs, prev);
4803
+ if (decision.action === "skip-stat") {
4804
+ stats.skipped++;
4805
+ continue;
4806
+ }
4807
+ if (decision.action === "skip-hash") {
4808
+ stmts.setState.run({
4809
+ file_path: file.path,
4810
+ size: st.size,
4811
+ mtime_ms: mtimeMs,
4812
+ sha256: decision.hash,
4813
+ events_ingested: prev.events_ingested,
4814
+ ingested_at: now2
4815
+ });
4816
+ stats.skipped++;
4817
+ continue;
4818
+ }
4819
+ const lines = decision.buf ? decision.buf.toString("utf8").split("\n") : streamLines(file.path);
4820
+ ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash: decision.hash }, now2, stats, { provisionalTurns: args.full });
4821
+ dirty.add(file.sessionId);
4822
+ }
4823
+ ingestWorkflowResults(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
4824
+ ingestSubagentMeta(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
4825
+ ingestToolResults(db, join9(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
4608
4826
  }
4609
- const lines = decision.buf ? decision.buf.toString("utf8").split("\n") : streamLines(file.path);
4610
- ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash: decision.hash }, now2, stats);
4611
- dirty.add(file.sessionId);
4612
- }
4613
- ingestWorkflowResults(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
4614
- ingestSubagentMeta(db, join9(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
4615
- ingestToolResults(db, join9(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
4827
+ const expanded = rebuildDerived(db, args.full ? null : dirty);
4828
+ if (args.full)
4829
+ finishBulkLoad(db);
4830
+ canonicalizeProjects(db);
4831
+ const classified = classify(db, args.full ? null : expanded);
4832
+ const detected = detect(db, args.full ? null : expanded);
4833
+ classifyErrors(db, args.full ? null : expanded);
4834
+ const fileChanges = deriveFileChanges(db, args.full ? null : expanded);
4835
+ db.exec(ANALYZE_SQL);
4836
+ if (args.full)
4837
+ assertForeignKeys(db);
4838
+ return { stats, classified, detected, fileChanges, wfStats, metaStats, trStats };
4839
+ })();
4840
+ } finally {
4841
+ if (args.full)
4842
+ db.exec("PRAGMA foreign_keys = ON");
4616
4843
  }
4617
- const expanded = rebuildDerived(db, args.full ? null : dirty);
4618
- canonicalizeProjects(db);
4619
- const classified = classify(db, args.full ? null : expanded);
4620
- const detected = detect(db, args.full ? null : expanded);
4621
- classifyErrors(db, args.full ? null : expanded);
4622
- const fileChanges = deriveFileChanges(db, args.full ? null : expanded);
4623
- printIngestReport(db, dbPath, { stats, pruned, classified, detected, fileChanges, wfStats, metaStats, trStats });
4844
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
4845
+ printIngestReport(db, dbPath, { ...report, pruned });
4624
4846
  }
4625
4847
  function runMetrics(argv = process.argv.slice(2)) {
4626
4848
  let dbArg = "";
@@ -4739,9 +4961,11 @@ function costOf(rows) {
4739
4961
  }
4740
4962
 
4741
4963
  // ../server/dist/db.js
4964
+ var READ_CACHE_KIB = 64 * 1024;
4742
4965
  function openReadonly(file) {
4743
4966
  const db = new DatabaseSync2(file, { readOnly: true });
4744
4967
  db.exec("PRAGMA query_only = ON");
4968
+ db.exec(`PRAGMA cache_size = -${READ_CACHE_KIB}`);
4745
4969
  return db;
4746
4970
  }
4747
4971
  function schemaStatus(db) {
@@ -6161,6 +6385,31 @@ function setPref(db, key, value) {
6161
6385
  ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, value, (/* @__PURE__ */ new Date()).toISOString());
6162
6386
  }
6163
6387
 
6388
+ // ../server/dist/response-cache.js
6389
+ function dataVersionCache(db, max = 64) {
6390
+ const version2 = db.prepare("PRAGMA data_version");
6391
+ const entries = /* @__PURE__ */ new Map();
6392
+ let seen;
6393
+ return (key, compute) => {
6394
+ const v = version2.get().data_version;
6395
+ if (v !== seen) {
6396
+ entries.clear();
6397
+ seen = v;
6398
+ }
6399
+ if (entries.has(key)) {
6400
+ const hit = entries.get(key);
6401
+ entries.delete(key);
6402
+ entries.set(key, hit);
6403
+ return hit;
6404
+ }
6405
+ const value = compute();
6406
+ entries.set(key, value);
6407
+ if (entries.size > max)
6408
+ entries.delete(entries.keys().next().value);
6409
+ return value;
6410
+ };
6411
+ }
6412
+
6164
6413
  // ../server/dist/app.js
6165
6414
  function notFound(reply, message = "not found") {
6166
6415
  return reply.code(404).send({ error: { code: "NOT_FOUND", message } });
@@ -6239,19 +6488,20 @@ async function createApp(db, opts = {}) {
6239
6488
  const q = req.query;
6240
6489
  return { source: q.source, from: q.from, to: q.to, models: q.models?.split(",").filter(Boolean) };
6241
6490
  };
6242
- app.get("/api/dashboard/overview", async (req) => dashboardOverview(db, dashFilters(req)));
6491
+ const cached2 = dataVersionCache(db);
6492
+ app.get("/api/dashboard/overview", async (req) => cached2(req.url, () => dashboardOverview(db, dashFilters(req))));
6243
6493
  app.get("/api/dashboard/timeseries", async (req) => {
6244
6494
  const q = req.query;
6245
- return dashboardTimeseries(db, dashFilters(req), q.bucket);
6495
+ return cached2(req.url, () => dashboardTimeseries(db, dashFilters(req), q.bucket));
6246
6496
  });
6247
- app.get("/api/dashboard/breakdowns", async (req) => dashboardBreakdowns(db, dashFilters(req)));
6497
+ app.get("/api/dashboard/breakdowns", async (req) => cached2(req.url, () => dashboardBreakdowns(db, dashFilters(req))));
6248
6498
  app.get("/api/dashboard/time", async (req) => {
6249
6499
  const q = req.query;
6250
- return dashboardTime(db, dashFilters(req), q.bucket);
6500
+ return cached2(req.url, () => dashboardTime(db, dashFilters(req), q.bucket));
6251
6501
  });
6252
6502
  app.get("/api/dashboard/audit", async (req) => {
6253
6503
  const q = req.query;
6254
- return dashboardAudit(db, dashFilters(req), q.bucket);
6504
+ return cached2(req.url, () => dashboardAudit(db, dashFilters(req), q.bucket));
6255
6505
  });
6256
6506
  const PREF_KEY = /^[a-z0-9._-]{1,64}$/i;
6257
6507
  app.get("/api/prefs/:key", async (req, reply) => {