claude-memory-layer 1.0.53 → 1.0.55

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.
@@ -1412,6 +1412,30 @@ var MemoryQueryService = class {
1412
1412
  score: 1 - (r.rank - minRank) / rankRange
1413
1413
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
1414
1414
  }
1415
+ async getEvent(id) {
1416
+ await this.initialize();
1417
+ return this.queryStore.getEvent(id);
1418
+ }
1419
+ async getEventByCitationId(citationId) {
1420
+ await this.initialize();
1421
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
1422
+ }
1423
+ async getEventsAfter(sinceIso) {
1424
+ await this.initialize();
1425
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
1426
+ }
1427
+ async getEventTypeCounts() {
1428
+ await this.initialize();
1429
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
1430
+ }
1431
+ async getDistinctSessionCount() {
1432
+ await this.initialize();
1433
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
1434
+ }
1435
+ async getDailyEventCounts(sinceIso) {
1436
+ await this.initialize();
1437
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
1438
+ }
1415
1439
  async getSessionHistory(sessionId) {
1416
1440
  await this.initialize();
1417
1441
  return this.queryStore.getSessionEvents(sessionId);
@@ -2226,6 +2250,19 @@ function parseEntityCanonicalKey(canonicalKey) {
2226
2250
  return null;
2227
2251
  }
2228
2252
 
2253
+ // src/core/citation-generator.ts
2254
+ import { createHash as createHash3 } from "crypto";
2255
+ var ID_LENGTH = 6;
2256
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2257
+ function generateCitationId(eventId) {
2258
+ const hash = createHash3("sha256").update(eventId).digest();
2259
+ let id = "";
2260
+ for (let i = 0; i < ID_LENGTH; i++) {
2261
+ id += CHARSET[hash[i] % CHARSET.length];
2262
+ }
2263
+ return id;
2264
+ }
2265
+
2229
2266
  // src/core/sqlite-event-store.ts
2230
2267
  import * as nodePath2 from "path";
2231
2268
 
@@ -2248,17 +2285,28 @@ function createSQLiteDatabase(path8, options) {
2248
2285
  }
2249
2286
  return db;
2250
2287
  }
2288
+ var statementCache = /* @__PURE__ */ new WeakMap();
2289
+ function prepareCached(db, sql) {
2290
+ let cache = statementCache.get(db);
2291
+ if (!cache) {
2292
+ cache = /* @__PURE__ */ new Map();
2293
+ statementCache.set(db, cache);
2294
+ }
2295
+ let stmt = cache.get(sql);
2296
+ if (!stmt) {
2297
+ stmt = db.prepare(sql);
2298
+ cache.set(sql, stmt);
2299
+ }
2300
+ return stmt;
2301
+ }
2251
2302
  function sqliteRun(db, sql, params = []) {
2252
- const stmt = db.prepare(sql);
2253
- return stmt.run(...params);
2303
+ return prepareCached(db, sql).run(...params);
2254
2304
  }
2255
2305
  function sqliteAll(db, sql, params = []) {
2256
- const stmt = db.prepare(sql);
2257
- return stmt.all(...params);
2306
+ return prepareCached(db, sql).all(...params);
2258
2307
  }
2259
2308
  function sqliteGet(db, sql, params = []) {
2260
- const stmt = db.prepare(sql);
2261
- return stmt.get(...params);
2309
+ return prepareCached(db, sql).get(...params);
2262
2310
  }
2263
2311
  function sqliteExec(db, sql) {
2264
2312
  db.exec(sql);
@@ -2352,7 +2400,13 @@ function toDate(value) {
2352
2400
  return new Date(String(value));
2353
2401
  }
2354
2402
  function createDatabase(dbPath, options) {
2355
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
2403
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
2404
+ db.pragma("busy_timeout = 5000");
2405
+ if (!options?.readOnly) {
2406
+ db.pragma("journal_mode = WAL");
2407
+ db.pragma("synchronous = NORMAL");
2408
+ }
2409
+ return db;
2356
2410
  }
2357
2411
  function dbRun(db, sql, params = []) {
2358
2412
  db.prepare(sql).run(...params);
@@ -2706,6 +2760,7 @@ function normalizeQueryRewriteKind(value) {
2706
2760
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2707
2761
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2708
2762
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2763
+ var SQLITE_SCHEMA_VERSION = 1;
2709
2764
  function emptyOutboxRecoveryResult() {
2710
2765
  return {
2711
2766
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -3050,6 +3105,15 @@ var SQLiteEventStore = class {
3050
3105
  access_count INTEGER DEFAULT 0
3051
3106
  );
3052
3107
 
3108
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3109
+ -- replaces per-event source_events LIKE scans during consolidation).
3110
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3111
+ memory_id TEXT NOT NULL,
3112
+ event_id TEXT NOT NULL,
3113
+ PRIMARY KEY (memory_id, event_id)
3114
+ );
3115
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3116
+
3053
3117
  -- Continuity Log table (tracks context transitions)
3054
3118
  CREATE TABLE IF NOT EXISTS continuity_log (
3055
3119
  log_id TEXT PRIMARY KEY,
@@ -3409,11 +3473,17 @@ var SQLiteEventStore = class {
3409
3473
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3410
3474
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3411
3475
  END;
3476
+
3477
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3478
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3479
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3480
+ CREATE TABLE IF NOT EXISTS event_citations (
3481
+ event_id TEXT PRIMARY KEY,
3482
+ citation_id TEXT NOT NULL
3483
+ );
3484
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3412
3485
  `);
3413
- try {
3414
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3415
- } catch {
3416
- }
3486
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3417
3487
  try {
3418
3488
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3419
3489
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3450,55 +3520,17 @@ var SQLiteEventStore = class {
3450
3520
  }
3451
3521
  } catch {
3452
3522
  }
3453
- try {
3454
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3455
- } catch {
3456
- }
3457
- try {
3458
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3459
- } catch {
3460
- }
3461
- try {
3462
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3463
- } catch {
3464
- }
3465
- try {
3466
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3467
- } catch {
3468
- }
3523
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3524
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3525
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3526
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3469
3527
  try {
3470
3528
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3471
3529
  } catch {
3472
3530
  }
3473
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3474
- const columnNames = tableInfo.map((col) => col.name);
3475
- if (!columnNames.includes("access_count")) {
3476
- try {
3477
- sqliteExec(this.db, `
3478
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3479
- `);
3480
- } catch (err) {
3481
- console.error("Error adding access_count column:", err);
3482
- }
3483
- }
3484
- if (!columnNames.includes("last_accessed_at")) {
3485
- try {
3486
- sqliteExec(this.db, `
3487
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3488
- `);
3489
- } catch (err) {
3490
- console.error("Error adding last_accessed_at column:", err);
3491
- }
3492
- }
3493
- if (!columnNames.includes("turn_id")) {
3494
- try {
3495
- sqliteExec(this.db, `
3496
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3497
- `);
3498
- } catch (err) {
3499
- console.error("Error adding turn_id column:", err);
3500
- }
3501
- }
3531
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3532
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3533
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3502
3534
  try {
3503
3535
  sqliteExec(this.db, `
3504
3536
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3515,8 +3547,18 @@ var SQLiteEventStore = class {
3515
3547
  sqliteExec(this.db, `
3516
3548
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3517
3549
  `);
3518
- } catch (err) {
3550
+ } catch {
3519
3551
  }
3552
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3553
+ if ((cmeCount?.c ?? 0) === 0) {
3554
+ sqliteExec(this.db, `
3555
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3556
+ SELECT cm.memory_id, je.value
3557
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3558
+ WHERE json_valid(cm.source_events);
3559
+ `);
3560
+ }
3561
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3520
3562
  this.initialized = true;
3521
3563
  }
3522
3564
  /**
@@ -3648,6 +3690,67 @@ var SQLiteEventStore = class {
3648
3690
  );
3649
3691
  return rows.map(this.rowToEvent);
3650
3692
  }
3693
+ /**
3694
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3695
+ * into memory and counting in JS. Counts all events (the old recent-window
3696
+ * scan also under-counted stores larger than its cap).
3697
+ */
3698
+ async getEventTypeCounts(options) {
3699
+ await this.initialize();
3700
+ const rows = sqliteAll(
3701
+ this.db,
3702
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3703
+ []
3704
+ );
3705
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3706
+ }
3707
+ /**
3708
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3709
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3710
+ * the window needs instead of an arbitrary "most recent N" slice that both
3711
+ * over-fetches sparse windows and truncates very active ones.
3712
+ */
3713
+ async getEventsAfter(sinceIso, options) {
3714
+ await this.initialize();
3715
+ const rows = sqliteAll(
3716
+ this.db,
3717
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3718
+ [sinceIso]
3719
+ );
3720
+ return rows.map((row) => this.rowToEvent(row));
3721
+ }
3722
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3723
+ async getDistinctSessionCount(options) {
3724
+ await this.initialize();
3725
+ const row = sqliteGet(
3726
+ this.db,
3727
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3728
+ []
3729
+ );
3730
+ return row?.count ?? 0;
3731
+ }
3732
+ /**
3733
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3734
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3735
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3736
+ * lexicographically.
3737
+ */
3738
+ async getDailyEventCounts(sinceIso, options) {
3739
+ await this.initialize();
3740
+ return sqliteAll(
3741
+ this.db,
3742
+ `SELECT substr(timestamp, 1, 10) as day,
3743
+ COUNT(*) as total,
3744
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3745
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3746
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3747
+ FROM events
3748
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3749
+ GROUP BY day
3750
+ ORDER BY day ASC`,
3751
+ [sinceIso]
3752
+ );
3753
+ }
3651
3754
  /**
3652
3755
  * Get event by ID
3653
3756
  */
@@ -3661,6 +3764,71 @@ var SQLiteEventStore = class {
3661
3764
  if (!row) return null;
3662
3765
  return this.rowToEvent(row);
3663
3766
  }
3767
+ /**
3768
+ * Batch-fetch events by id in a single query, applying the same quarantine
3769
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3770
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3771
+ */
3772
+ async getEvents(ids, options) {
3773
+ await this.initialize();
3774
+ if (ids.length === 0) return [];
3775
+ const placeholders2 = ids.map(() => "?").join(",");
3776
+ const rows = sqliteAll(
3777
+ this.db,
3778
+ `SELECT * FROM events WHERE id IN (${placeholders2}) AND ${maybeQuarantinePredicate(options)}`,
3779
+ ids
3780
+ );
3781
+ return rows.map((row) => this.rowToEvent(row));
3782
+ }
3783
+ /**
3784
+ * Resolve an event from its citation id via the reverse index, instead of
3785
+ * scanning recent events and hashing each one. The index is self-healing: a
3786
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3787
+ * freshly appended events) before retrying.
3788
+ */
3789
+ async getEventByCitationId(citationId, options) {
3790
+ await this.initialize();
3791
+ if (!citationId) return null;
3792
+ let row = sqliteGet(
3793
+ this.db,
3794
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3795
+ [citationId]
3796
+ );
3797
+ if (!row) {
3798
+ this.indexMissingCitations();
3799
+ row = sqliteGet(
3800
+ this.db,
3801
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3802
+ [citationId]
3803
+ );
3804
+ }
3805
+ if (!row) return null;
3806
+ return this.getEvent(row.event_id, options);
3807
+ }
3808
+ /**
3809
+ * Backfill citation ids for any events not yet present in event_citations.
3810
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3811
+ * work, and the scan only runs when there are genuinely new events to index.
3812
+ */
3813
+ indexMissingCitations() {
3814
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3815
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3816
+ if (indexedCount >= eventCount) return;
3817
+ const unindexed = sqliteAll(
3818
+ this.db,
3819
+ `SELECT e.id FROM events e
3820
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3821
+ WHERE ec.event_id IS NULL`,
3822
+ []
3823
+ );
3824
+ if (unindexed.length === 0) return;
3825
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3826
+ this.db.transaction(() => {
3827
+ for (const { id } of unindexed) {
3828
+ insert.run(id, generateCitationId(id));
3829
+ }
3830
+ })();
3831
+ }
3664
3832
  /**
3665
3833
  * Get events since a timestamp (for sync)
3666
3834
  */
@@ -3862,25 +4030,22 @@ var SQLiteEventStore = class {
3862
4030
  */
3863
4031
  async getPendingOutboxItems(limit = 32) {
3864
4032
  await this.initialize();
3865
- const pending = sqliteAll(
3866
- this.db,
3867
- `SELECT * FROM embedding_outbox
3868
- WHERE status = 'pending'
3869
- ORDER BY created_at
3870
- LIMIT ?`,
3871
- [limit]
3872
- );
3873
- if (pending.length === 0) return [];
3874
- const ids = pending.map((r) => r.id);
3875
- const placeholders2 = ids.map(() => "?").join(",");
3876
- sqliteRun(
4033
+ const claimed = sqliteAll(
3877
4034
  this.db,
3878
4035
  `UPDATE embedding_outbox
3879
4036
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3880
- WHERE id IN (${placeholders2})`,
3881
- ids
4037
+ WHERE id IN (
4038
+ SELECT id FROM embedding_outbox
4039
+ WHERE status = 'pending'
4040
+ ORDER BY created_at
4041
+ LIMIT ?
4042
+ )
4043
+ RETURNING *`,
4044
+ [limit]
3882
4045
  );
3883
- return pending.map((row) => ({
4046
+ if (claimed.length === 0) return [];
4047
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
4048
+ return claimed.map((row) => ({
3884
4049
  id: row.id,
3885
4050
  eventId: row.event_id,
3886
4051
  content: row.content,
@@ -4484,7 +4649,7 @@ var SQLiteEventStore = class {
4484
4649
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4485
4650
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4486
4651
  let toolSuccessCount = 0;
4487
- let toolTotalCount = toolEvents.length;
4652
+ const toolTotalCount = toolEvents.length;
4488
4653
  for (const t of toolEvents) {
4489
4654
  try {
4490
4655
  const content = JSON.parse(t.content);
@@ -4647,10 +4812,14 @@ var SQLiteEventStore = class {
4647
4812
  * Rebuild FTS index from existing events
4648
4813
  * Call this once after upgrading to FTS5
4649
4814
  */
4650
- async rebuildFtsIndex() {
4651
- await this.initialize();
4652
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4653
- const totalEvents = countRow?.count ?? 0;
4815
+ /**
4816
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4817
+ * from the current events table. Recreating the table (instead of issuing
4818
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4819
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4820
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4821
+ */
4822
+ recreateEventsFtsTable() {
4654
4823
  sqliteExec(this.db, `
4655
4824
  DROP TRIGGER IF EXISTS events_fts_insert;
4656
4825
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4679,6 +4848,12 @@ var SQLiteEventStore = class {
4679
4848
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4680
4849
  END;
4681
4850
  `);
4851
+ }
4852
+ async rebuildFtsIndex() {
4853
+ await this.initialize();
4854
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4855
+ const totalEvents = countRow?.count ?? 0;
4856
+ this.recreateEventsFtsTable();
4682
4857
  return totalEvents;
4683
4858
  }
4684
4859
  /**
@@ -4696,6 +4871,35 @@ var SQLiteEventStore = class {
4696
4871
  return false;
4697
4872
  }
4698
4873
  }
4874
+ hasTable(tableName) {
4875
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4876
+ try {
4877
+ const rows = sqliteAll(
4878
+ this.db,
4879
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4880
+ [tableName]
4881
+ );
4882
+ return rows.length > 0;
4883
+ } catch {
4884
+ return false;
4885
+ }
4886
+ }
4887
+ /**
4888
+ * Add a column only when its table exists and the column is missing.
4889
+ *
4890
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4891
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4892
+ * failure (disk full, corruption), which is allowed to surface instead of
4893
+ * being silently swallowed as if the migration had succeeded.
4894
+ */
4895
+ addColumnIfMissing(table, column, definition) {
4896
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4897
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4898
+ }
4899
+ if (!this.hasTable(table)) return;
4900
+ if (this.hasTableColumn(table, column)) return;
4901
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4902
+ }
4699
4903
  async recordRetrievalTrace(input) {
4700
4904
  await this.initialize();
4701
4905
  const traceId = randomUUID2();
@@ -4781,6 +4985,45 @@ var SQLiteEventStore = class {
4781
4985
  FROM retrieval_traces`,
4782
4986
  []
4783
4987
  );
4988
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
4989
+ const strategyRows = sqliteAll(
4990
+ this.db,
4991
+ `SELECT
4992
+ ${strategyColumnSql} as strategy,
4993
+ COUNT(*) as total_queries,
4994
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
4995
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
4996
+ SUM(candidate_count) as total_candidate_count,
4997
+ SUM(selected_count) as total_selected_count,
4998
+ AVG(candidate_count) as avg_candidate_count,
4999
+ AVG(selected_count) as avg_selected_count,
5000
+ CASE
5001
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
5002
+ ELSE 0
5003
+ END as selection_rate
5004
+ FROM retrieval_traces
5005
+ GROUP BY ${strategyColumnSql}
5006
+ ORDER BY total_queries DESC, strategy ASC`,
5007
+ []
5008
+ );
5009
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
5010
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
5011
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
5012
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
5013
+ return {
5014
+ strategy: String(strategyRow.strategy || "unknown"),
5015
+ totalQueries: strategyTotalQueries,
5016
+ queriesWithSelection: strategyQueriesWithSelection,
5017
+ rewrittenQueries: strategyRewrittenQueries,
5018
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
5019
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
5020
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
5021
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
5022
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
5023
+ selectionRate: Number(strategyRow.selection_rate || 0),
5024
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
5025
+ };
5026
+ });
4784
5027
  const totalQueries = Number(row?.total_queries || 0);
4785
5028
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4786
5029
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4798,7 +5041,8 @@ var SQLiteEventStore = class {
4798
5041
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4799
5042
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4800
5043
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4801
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
5044
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
5045
+ strategyBreakdown
4802
5046
  };
4803
5047
  } catch (err) {
4804
5048
  if (err?.message?.includes("no such table")) {
@@ -4814,7 +5058,8 @@ var SQLiteEventStore = class {
4814
5058
  rewrittenSelectionRate: 0,
4815
5059
  rawSelectionRate: 0,
4816
5060
  avgSelectedCountForRewrittenQueries: 0,
4817
- avgSelectedCountForRawQueries: 0
5061
+ avgSelectedCountForRawQueries: 0,
5062
+ strategyBreakdown: []
4818
5063
  };
4819
5064
  }
4820
5065
  throw err;
@@ -4931,38 +5176,19 @@ var SQLiteEventStore = class {
4931
5176
  if (events.length === 0) return 0;
4932
5177
  const eventIds = events.map((e) => e.id);
4933
5178
  const placeholders2 = eventIds.map(() => "?").join(",");
4934
- const ftsTriggersDropped = [];
4935
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4936
- try {
5179
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5180
+ const runDelete = this.db.transaction(() => {
5181
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4937
5182
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4938
- ftsTriggersDropped.push(triggerName);
4939
- } catch {
4940
5183
  }
4941
- }
4942
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
4943
- try {
5184
+ for (const table of relatedTables) {
4944
5185
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders2})`, eventIds);
4945
- } catch {
4946
- }
4947
- }
4948
- const result2 = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
4949
- if (ftsTriggersDropped.length > 0) {
4950
- try {
4951
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
4952
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
4953
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4954
- END`);
4955
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
4956
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4957
- END`);
4958
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
4959
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4960
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4961
- END`);
4962
- } catch {
4963
5186
  }
4964
- }
4965
- return result2.changes || 0;
5187
+ const result2 = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5188
+ this.recreateEventsFtsTable();
5189
+ return result2.changes || 0;
5190
+ });
5191
+ return runDelete();
4966
5192
  }
4967
5193
  /**
4968
5194
  * Convert database row to MemoryEvent
@@ -6179,7 +6405,7 @@ var FrontierService = class {
6179
6405
  };
6180
6406
 
6181
6407
  // src/core/operations/action-projector.ts
6182
- import { createHash as createHash3 } from "crypto";
6408
+ import { createHash as createHash4 } from "crypto";
6183
6409
  import { z as z10 } from "zod";
6184
6410
  var NonEmptyStringSchema3 = z10.string().transform((value) => value.trim()).pipe(z10.string().min(1));
6185
6411
  var ProjectTaskActionsInputSchema = z10.object({
@@ -6374,7 +6600,7 @@ var TaskActionProjector = class {
6374
6600
  }
6375
6601
  };
6376
6602
  function actionIdForTaskEntity(entityId) {
6377
- const bytes = createHash3("sha256").update(`memory-action:task:${entityId}`).digest().subarray(0, 16);
6603
+ const bytes = createHash4("sha256").update(`memory-action:task:${entityId}`).digest().subarray(0, 16);
6378
6604
  bytes[6] = bytes[6] & 15 | 64;
6379
6605
  bytes[8] = bytes[8] & 63 | 128;
6380
6606
  const hex = bytes.toString("hex");
@@ -8254,7 +8480,7 @@ var LessonRepository = class {
8254
8480
  };
8255
8481
 
8256
8482
  // src/core/operations/lesson-candidate-service.ts
8257
- import { createHash as createHash4 } from "crypto";
8483
+ import { createHash as createHash5 } from "crypto";
8258
8484
  import { z as z14 } from "zod";
8259
8485
  var NonEmptyStringSchema4 = z14.string().transform((value) => value.trim()).pipe(z14.string().min(1));
8260
8486
  var LessonCandidateInputSchema = z14.object({
@@ -8446,7 +8672,7 @@ function confidenceForGroup(group, tools, fileCategories, taskPatterns) {
8446
8672
  return Math.min(0.95, Math.round((0.58 + sessionBonus + toolBonus + evidenceBonus + taskBonus) * 100) / 100);
8447
8673
  }
8448
8674
  function candidateIdFor(projectHash, signature) {
8449
- return `lesson-candidate:${createHash4("sha256").update(`${projectHash}
8675
+ return `lesson-candidate:${createHash5("sha256").update(`${projectHash}
8450
8676
  ${signature}`).digest("hex").slice(0, 16)}`;
8451
8677
  }
8452
8678
  function sanitizeArray(values) {
@@ -9236,7 +9462,7 @@ var ActorCardRepository = class {
9236
9462
  };
9237
9463
 
9238
9464
  // src/core/operations/perspective-observation-repository.ts
9239
- import { createHash as createHash5, randomUUID as randomUUID12 } from "crypto";
9465
+ import { createHash as createHash6, randomUUID as randomUUID12 } from "crypto";
9240
9466
  function projectHashToStorage6(projectHash) {
9241
9467
  return projectHash ?? "";
9242
9468
  }
@@ -9273,7 +9499,7 @@ function sanitizeStoredRecord(value) {
9273
9499
  return sanitizeGovernanceAuditValue(value);
9274
9500
  }
9275
9501
  function stableHash(value) {
9276
- return createHash5("sha256").update(JSON.stringify(value)).digest("hex");
9502
+ return createHash6("sha256").update(JSON.stringify(value)).digest("hex");
9277
9503
  }
9278
9504
  function evidenceHash(sourceEventIds, sourceObservationIds) {
9279
9505
  return stableHash({
@@ -9781,19 +10007,6 @@ function createPerspectiveDeriver(options) {
9781
10007
  return new PerspectiveDeriver(options);
9782
10008
  }
9783
10009
 
9784
- // src/core/citation-generator.ts
9785
- import { createHash as createHash6 } from "crypto";
9786
- var ID_LENGTH = 6;
9787
- var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
9788
- function generateCitationId(eventId) {
9789
- const hash = createHash6("sha256").update(eventId).digest();
9790
- let id = "";
9791
- for (let i = 0; i < ID_LENGTH; i++) {
9792
- id += CHARSET[hash[i] % CHARSET.length];
9793
- }
9794
- return id;
9795
- }
9796
-
9797
10010
  // src/core/operations/perspective-query-agent.ts
9798
10011
  var DEFAULT_TOOL_CAPS = {
9799
10012
  minimal: 2,
@@ -11596,11 +11809,12 @@ var Retriever = class {
11596
11809
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
11597
11810
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
11598
11811
  const filtered = [];
11812
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
11599
11813
  for (const result2 of results) {
11600
11814
  if (scope?.sessionId && result2.sessionId !== scope.sessionId) continue;
11601
11815
  if (scope?.sessionIdPrefix && !result2.sessionId.startsWith(scope.sessionIdPrefix)) continue;
11602
11816
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result2.eventType)) continue;
11603
- const event = await this.eventStore.getEvent(result2.eventId);
11817
+ const event = eventMap.get(result2.eventId);
11604
11818
  if (!event) continue;
11605
11819
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
11606
11820
  if (normalizedIncludes.length > 0) {
@@ -11709,10 +11923,30 @@ var Retriever = class {
11709
11923
  async retrieveRecent(limit = 100) {
11710
11924
  return this.eventStore.getRecentEvents(limit);
11711
11925
  }
11926
+ /**
11927
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
11928
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
11929
+ */
11930
+ async fetchEventsByIds(ids) {
11931
+ const map = /* @__PURE__ */ new Map();
11932
+ if (ids.length === 0) return map;
11933
+ const unique2 = [...new Set(ids)];
11934
+ if (typeof this.eventStore.getEvents === "function") {
11935
+ const events = await this.eventStore.getEvents(unique2);
11936
+ for (const event of events) map.set(event.id, event);
11937
+ } else {
11938
+ const events = await Promise.all(unique2.map((id) => this.eventStore.getEvent(id)));
11939
+ for (const event of events) {
11940
+ if (event) map.set(event.id, event);
11941
+ }
11942
+ }
11943
+ return map;
11944
+ }
11712
11945
  async enrichResults(results, options, query) {
11713
11946
  const memories = [];
11947
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
11714
11948
  for (const result2 of results) {
11715
- const event = await this.eventStore.getEvent(result2.eventId);
11949
+ const event = eventMap.get(result2.eventId);
11716
11950
  if (!event) continue;
11717
11951
  if (this.graduation) {
11718
11952
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result2.score);
@@ -13626,19 +13860,29 @@ var ConsolidatedStore = class {
13626
13860
  */
13627
13861
  async create(input) {
13628
13862
  const memoryId = randomUUID16();
13629
- await dbRun(
13630
- this.db,
13863
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
13864
+ const insertMemory = this.db.prepare(
13631
13865
  `INSERT INTO consolidated_memories
13632
13866
  (memory_id, summary, topics, source_events, confidence, created_at)
13633
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
13634
- [
13867
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
13868
+ );
13869
+ const insertEvent = this.db.prepare(
13870
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
13871
+ );
13872
+ this.db.transaction(() => {
13873
+ insertMemory.run(
13635
13874
  memoryId,
13636
13875
  input.summary,
13637
13876
  JSON.stringify(input.topics),
13638
- JSON.stringify(input.sourceEvents),
13877
+ JSON.stringify(sourceEvents),
13639
13878
  input.confidence
13640
- ]
13641
- );
13879
+ );
13880
+ for (const eventId of sourceEvents) {
13881
+ if (typeof eventId === "string" && eventId.length > 0) {
13882
+ insertEvent.run(memoryId, eventId);
13883
+ }
13884
+ }
13885
+ })();
13642
13886
  return memoryId;
13643
13887
  }
13644
13888
  /**
@@ -13744,11 +13988,12 @@ var ConsolidatedStore = class {
13744
13988
  * Delete a consolidated memory
13745
13989
  */
13746
13990
  async delete(memoryId) {
13747
- await dbRun(
13748
- this.db,
13749
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
13750
- [memoryId]
13751
- );
13991
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
13992
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
13993
+ this.db.transaction(() => {
13994
+ deleteEvents.run(memoryId);
13995
+ deleteMemory.run(memoryId);
13996
+ })();
13752
13997
  }
13753
13998
  /**
13754
13999
  * Create a long-term rule promoted from stable summaries
@@ -13861,16 +14106,16 @@ var ConsolidatedStore = class {
13861
14106
  * Check if source events are already consolidated
13862
14107
  */
13863
14108
  async isAlreadyConsolidated(eventIds) {
13864
- for (const eventId of eventIds) {
13865
- const result2 = await dbAll(
13866
- this.db,
13867
- `SELECT COUNT(*) as count FROM consolidated_memories
13868
- WHERE source_events LIKE ?`,
13869
- [`%"${eventId}"%`]
13870
- );
13871
- if ((result2[0]?.count || 0) > 0) return true;
13872
- }
13873
- return false;
14109
+ if (eventIds.length === 0) return false;
14110
+ const placeholders2 = eventIds.map(() => "?").join(",");
14111
+ const rows = await dbAll(
14112
+ this.db,
14113
+ `SELECT memory_id FROM consolidated_memory_events
14114
+ WHERE event_id IN (${placeholders2})
14115
+ LIMIT 1`,
14116
+ eventIds
14117
+ );
14118
+ return rows.length > 0;
13874
14119
  }
13875
14120
  /**
13876
14121
  * Get the last consolidation time
@@ -15779,6 +16024,23 @@ var EventStore = class {
15779
16024
  access_count INTEGER DEFAULT 0
15780
16025
  )
15781
16026
  `);
16027
+ await dbRun(this.db, `
16028
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
16029
+ memory_id VARCHAR NOT NULL,
16030
+ event_id VARCHAR NOT NULL,
16031
+ PRIMARY KEY (memory_id, event_id)
16032
+ )
16033
+ `);
16034
+ await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id)`);
16035
+ const cmeCount = await dbAll(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`);
16036
+ if ((cmeCount[0]?.c ?? 0) === 0) {
16037
+ await dbRun(this.db, `
16038
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
16039
+ SELECT cm.memory_id, je.value
16040
+ FROM consolidated_memories cm, json_each(cm.source_events) je
16041
+ WHERE json_valid(cm.source_events)
16042
+ `);
16043
+ }
15782
16044
  await dbRun(this.db, `
15783
16045
  CREATE TABLE IF NOT EXISTS continuity_log (
15784
16046
  log_id VARCHAR PRIMARY KEY,
@@ -15917,6 +16179,18 @@ var EventStore = class {
15917
16179
  if (rows.length === 0) return null;
15918
16180
  return this.rowToEvent(rows[0]);
15919
16181
  }
16182
+ /** Batch-fetch events by id in a single query (missing ids are omitted). */
16183
+ async getEvents(ids) {
16184
+ await this.initialize();
16185
+ if (ids.length === 0) return [];
16186
+ const placeholders2 = ids.map(() => "?").join(",");
16187
+ const rows = await dbAll(
16188
+ this.db,
16189
+ `SELECT * FROM events WHERE id IN (${placeholders2})`,
16190
+ ids
16191
+ );
16192
+ return rows.map((row) => this.rowToEvent(row));
16193
+ }
15920
16194
  /**
15921
16195
  * Create or update session
15922
16196
  */
@@ -16004,23 +16278,22 @@ var EventStore = class {
16004
16278
  */
16005
16279
  async getPendingOutboxItems(limit = 32) {
16006
16280
  await this.initialize();
16007
- const pending = await dbAll(
16281
+ const claimed = await dbAll(
16008
16282
  this.db,
16009
- `SELECT * FROM embedding_outbox
16010
- WHERE status = 'pending'
16011
- ORDER BY created_at
16012
- LIMIT ?`,
16283
+ `UPDATE embedding_outbox
16284
+ SET status = 'processing'
16285
+ WHERE id IN (
16286
+ SELECT id FROM embedding_outbox
16287
+ WHERE status = 'pending'
16288
+ ORDER BY created_at
16289
+ LIMIT ?
16290
+ )
16291
+ RETURNING *`,
16013
16292
  [limit]
16014
16293
  );
16015
- if (pending.length === 0) return [];
16016
- const ids = pending.map((r) => r.id);
16017
- const placeholders2 = ids.map(() => "?").join(",");
16018
- await dbRun(
16019
- this.db,
16020
- `UPDATE embedding_outbox SET status = 'processing' WHERE id IN (${placeholders2})`,
16021
- ids
16022
- );
16023
- return pending.map((row) => ({
16294
+ if (claimed.length === 0) return [];
16295
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
16296
+ return claimed.map((row) => ({
16024
16297
  id: row.id,
16025
16298
  eventId: row.event_id,
16026
16299
  content: row.content,