claude-memory-layer 1.0.52 → 1.0.54

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.
@@ -157,7 +157,13 @@ function toDate(value) {
157
157
  return new Date(String(value));
158
158
  }
159
159
  function createDatabase(dbPath, options) {
160
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
160
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
161
+ db.pragma("busy_timeout = 5000");
162
+ if (!options?.readOnly) {
163
+ db.pragma("journal_mode = WAL");
164
+ db.pragma("synchronous = NORMAL");
165
+ }
166
+ return db;
161
167
  }
162
168
  function dbRun(db, sql, params = []) {
163
169
  db.prepare(sql).run(...params);
@@ -390,19 +396,29 @@ var ConsolidatedStore = class {
390
396
  */
391
397
  async create(input) {
392
398
  const memoryId = randomUUID2();
393
- await dbRun(
394
- this.db,
399
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
400
+ const insertMemory = this.db.prepare(
395
401
  `INSERT INTO consolidated_memories
396
402
  (memory_id, summary, topics, source_events, confidence, created_at)
397
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
398
- [
403
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
404
+ );
405
+ const insertEvent = this.db.prepare(
406
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
407
+ );
408
+ this.db.transaction(() => {
409
+ insertMemory.run(
399
410
  memoryId,
400
411
  input.summary,
401
412
  JSON.stringify(input.topics),
402
- JSON.stringify(input.sourceEvents),
413
+ JSON.stringify(sourceEvents),
403
414
  input.confidence
404
- ]
405
- );
415
+ );
416
+ for (const eventId of sourceEvents) {
417
+ if (typeof eventId === "string" && eventId.length > 0) {
418
+ insertEvent.run(memoryId, eventId);
419
+ }
420
+ }
421
+ })();
406
422
  return memoryId;
407
423
  }
408
424
  /**
@@ -508,11 +524,12 @@ var ConsolidatedStore = class {
508
524
  * Delete a consolidated memory
509
525
  */
510
526
  async delete(memoryId) {
511
- await dbRun(
512
- this.db,
513
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
514
- [memoryId]
515
- );
527
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
528
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
529
+ this.db.transaction(() => {
530
+ deleteEvents.run(memoryId);
531
+ deleteMemory.run(memoryId);
532
+ })();
516
533
  }
517
534
  /**
518
535
  * Create a long-term rule promoted from stable summaries
@@ -625,16 +642,16 @@ var ConsolidatedStore = class {
625
642
  * Check if source events are already consolidated
626
643
  */
627
644
  async isAlreadyConsolidated(eventIds) {
628
- for (const eventId of eventIds) {
629
- const result = await dbAll(
630
- this.db,
631
- `SELECT COUNT(*) as count FROM consolidated_memories
632
- WHERE source_events LIKE ?`,
633
- [`%"${eventId}"%`]
634
- );
635
- if ((result[0]?.count || 0) > 0) return true;
636
- }
637
- return false;
645
+ if (eventIds.length === 0) return false;
646
+ const placeholders = eventIds.map(() => "?").join(",");
647
+ const rows = await dbAll(
648
+ this.db,
649
+ `SELECT memory_id FROM consolidated_memory_events
650
+ WHERE event_id IN (${placeholders})
651
+ LIMIT 1`,
652
+ eventIds
653
+ );
654
+ return rows.length > 0;
638
655
  }
639
656
  /**
640
657
  * Get the last consolidation time
@@ -2170,6 +2187,19 @@ function makeDedupeKey(content, sessionId) {
2170
2187
  return `${sessionId}:${contentHash}`;
2171
2188
  }
2172
2189
 
2190
+ // src/core/citation-generator.ts
2191
+ import { createHash as createHash2 } from "crypto";
2192
+ var ID_LENGTH = 6;
2193
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2194
+ function generateCitationId(eventId) {
2195
+ const hash = createHash2("sha256").update(eventId).digest();
2196
+ let id = "";
2197
+ for (let i = 0; i < ID_LENGTH; i++) {
2198
+ id += CHARSET[hash[i] % CHARSET.length];
2199
+ }
2200
+ return id;
2201
+ }
2202
+
2173
2203
  // src/core/sqlite-event-store.ts
2174
2204
  import * as nodePath2 from "path";
2175
2205
 
@@ -2214,17 +2244,28 @@ function createSQLiteDatabase(path11, options) {
2214
2244
  }
2215
2245
  return db;
2216
2246
  }
2247
+ var statementCache = /* @__PURE__ */ new WeakMap();
2248
+ function prepareCached(db, sql) {
2249
+ let cache = statementCache.get(db);
2250
+ if (!cache) {
2251
+ cache = /* @__PURE__ */ new Map();
2252
+ statementCache.set(db, cache);
2253
+ }
2254
+ let stmt = cache.get(sql);
2255
+ if (!stmt) {
2256
+ stmt = db.prepare(sql);
2257
+ cache.set(sql, stmt);
2258
+ }
2259
+ return stmt;
2260
+ }
2217
2261
  function sqliteRun(db, sql, params = []) {
2218
- const stmt = db.prepare(sql);
2219
- return stmt.run(...params);
2262
+ return prepareCached(db, sql).run(...params);
2220
2263
  }
2221
2264
  function sqliteAll(db, sql, params = []) {
2222
- const stmt = db.prepare(sql);
2223
- return stmt.all(...params);
2265
+ return prepareCached(db, sql).all(...params);
2224
2266
  }
2225
2267
  function sqliteGet(db, sql, params = []) {
2226
- const stmt = db.prepare(sql);
2227
- return stmt.get(...params);
2268
+ return prepareCached(db, sql).get(...params);
2228
2269
  }
2229
2270
  function sqliteExec(db, sql) {
2230
2271
  db.exec(sql);
@@ -2646,6 +2687,7 @@ function normalizeQueryRewriteKind(value) {
2646
2687
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2647
2688
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2648
2689
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2690
+ var SQLITE_SCHEMA_VERSION = 1;
2649
2691
  function emptyOutboxRecoveryResult() {
2650
2692
  return {
2651
2693
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -2990,6 +3032,15 @@ var SQLiteEventStore = class {
2990
3032
  access_count INTEGER DEFAULT 0
2991
3033
  );
2992
3034
 
3035
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3036
+ -- replaces per-event source_events LIKE scans during consolidation).
3037
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3038
+ memory_id TEXT NOT NULL,
3039
+ event_id TEXT NOT NULL,
3040
+ PRIMARY KEY (memory_id, event_id)
3041
+ );
3042
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3043
+
2993
3044
  -- Continuity Log table (tracks context transitions)
2994
3045
  CREATE TABLE IF NOT EXISTS continuity_log (
2995
3046
  log_id TEXT PRIMARY KEY,
@@ -3349,11 +3400,17 @@ var SQLiteEventStore = class {
3349
3400
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3350
3401
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3351
3402
  END;
3403
+
3404
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3405
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3406
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3407
+ CREATE TABLE IF NOT EXISTS event_citations (
3408
+ event_id TEXT PRIMARY KEY,
3409
+ citation_id TEXT NOT NULL
3410
+ );
3411
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3352
3412
  `);
3353
- try {
3354
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3355
- } catch {
3356
- }
3413
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3357
3414
  try {
3358
3415
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3359
3416
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3390,55 +3447,17 @@ var SQLiteEventStore = class {
3390
3447
  }
3391
3448
  } catch {
3392
3449
  }
3393
- try {
3394
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3395
- } catch {
3396
- }
3397
- try {
3398
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3399
- } catch {
3400
- }
3401
- try {
3402
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3403
- } catch {
3404
- }
3405
- try {
3406
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3407
- } catch {
3408
- }
3450
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3451
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3452
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3453
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3409
3454
  try {
3410
3455
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3411
3456
  } catch {
3412
3457
  }
3413
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3414
- const columnNames = tableInfo.map((col) => col.name);
3415
- if (!columnNames.includes("access_count")) {
3416
- try {
3417
- sqliteExec(this.db, `
3418
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3419
- `);
3420
- } catch (err) {
3421
- console.error("Error adding access_count column:", err);
3422
- }
3423
- }
3424
- if (!columnNames.includes("last_accessed_at")) {
3425
- try {
3426
- sqliteExec(this.db, `
3427
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3428
- `);
3429
- } catch (err) {
3430
- console.error("Error adding last_accessed_at column:", err);
3431
- }
3432
- }
3433
- if (!columnNames.includes("turn_id")) {
3434
- try {
3435
- sqliteExec(this.db, `
3436
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3437
- `);
3438
- } catch (err) {
3439
- console.error("Error adding turn_id column:", err);
3440
- }
3441
- }
3458
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3459
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3460
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3442
3461
  try {
3443
3462
  sqliteExec(this.db, `
3444
3463
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3455,8 +3474,18 @@ var SQLiteEventStore = class {
3455
3474
  sqliteExec(this.db, `
3456
3475
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3457
3476
  `);
3458
- } catch (err) {
3477
+ } catch {
3478
+ }
3479
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3480
+ if ((cmeCount?.c ?? 0) === 0) {
3481
+ sqliteExec(this.db, `
3482
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3483
+ SELECT cm.memory_id, je.value
3484
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3485
+ WHERE json_valid(cm.source_events);
3486
+ `);
3459
3487
  }
3488
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3460
3489
  this.initialized = true;
3461
3490
  }
3462
3491
  /**
@@ -3588,6 +3617,67 @@ var SQLiteEventStore = class {
3588
3617
  );
3589
3618
  return rows.map(this.rowToEvent);
3590
3619
  }
3620
+ /**
3621
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3622
+ * into memory and counting in JS. Counts all events (the old recent-window
3623
+ * scan also under-counted stores larger than its cap).
3624
+ */
3625
+ async getEventTypeCounts(options) {
3626
+ await this.initialize();
3627
+ const rows = sqliteAll(
3628
+ this.db,
3629
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3630
+ []
3631
+ );
3632
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3633
+ }
3634
+ /**
3635
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3636
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3637
+ * the window needs instead of an arbitrary "most recent N" slice that both
3638
+ * over-fetches sparse windows and truncates very active ones.
3639
+ */
3640
+ async getEventsAfter(sinceIso, options) {
3641
+ await this.initialize();
3642
+ const rows = sqliteAll(
3643
+ this.db,
3644
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3645
+ [sinceIso]
3646
+ );
3647
+ return rows.map((row) => this.rowToEvent(row));
3648
+ }
3649
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3650
+ async getDistinctSessionCount(options) {
3651
+ await this.initialize();
3652
+ const row = sqliteGet(
3653
+ this.db,
3654
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3655
+ []
3656
+ );
3657
+ return row?.count ?? 0;
3658
+ }
3659
+ /**
3660
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3661
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3662
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3663
+ * lexicographically.
3664
+ */
3665
+ async getDailyEventCounts(sinceIso, options) {
3666
+ await this.initialize();
3667
+ return sqliteAll(
3668
+ this.db,
3669
+ `SELECT substr(timestamp, 1, 10) as day,
3670
+ COUNT(*) as total,
3671
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3672
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3673
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3674
+ FROM events
3675
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3676
+ GROUP BY day
3677
+ ORDER BY day ASC`,
3678
+ [sinceIso]
3679
+ );
3680
+ }
3591
3681
  /**
3592
3682
  * Get event by ID
3593
3683
  */
@@ -3601,6 +3691,71 @@ var SQLiteEventStore = class {
3601
3691
  if (!row) return null;
3602
3692
  return this.rowToEvent(row);
3603
3693
  }
3694
+ /**
3695
+ * Batch-fetch events by id in a single query, applying the same quarantine
3696
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3697
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3698
+ */
3699
+ async getEvents(ids, options) {
3700
+ await this.initialize();
3701
+ if (ids.length === 0) return [];
3702
+ const placeholders = ids.map(() => "?").join(",");
3703
+ const rows = sqliteAll(
3704
+ this.db,
3705
+ `SELECT * FROM events WHERE id IN (${placeholders}) AND ${maybeQuarantinePredicate(options)}`,
3706
+ ids
3707
+ );
3708
+ return rows.map((row) => this.rowToEvent(row));
3709
+ }
3710
+ /**
3711
+ * Resolve an event from its citation id via the reverse index, instead of
3712
+ * scanning recent events and hashing each one. The index is self-healing: a
3713
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3714
+ * freshly appended events) before retrying.
3715
+ */
3716
+ async getEventByCitationId(citationId, options) {
3717
+ await this.initialize();
3718
+ if (!citationId) return null;
3719
+ let row = sqliteGet(
3720
+ this.db,
3721
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3722
+ [citationId]
3723
+ );
3724
+ if (!row) {
3725
+ this.indexMissingCitations();
3726
+ row = sqliteGet(
3727
+ this.db,
3728
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3729
+ [citationId]
3730
+ );
3731
+ }
3732
+ if (!row) return null;
3733
+ return this.getEvent(row.event_id, options);
3734
+ }
3735
+ /**
3736
+ * Backfill citation ids for any events not yet present in event_citations.
3737
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3738
+ * work, and the scan only runs when there are genuinely new events to index.
3739
+ */
3740
+ indexMissingCitations() {
3741
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3742
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3743
+ if (indexedCount >= eventCount) return;
3744
+ const unindexed = sqliteAll(
3745
+ this.db,
3746
+ `SELECT e.id FROM events e
3747
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3748
+ WHERE ec.event_id IS NULL`,
3749
+ []
3750
+ );
3751
+ if (unindexed.length === 0) return;
3752
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3753
+ this.db.transaction(() => {
3754
+ for (const { id } of unindexed) {
3755
+ insert.run(id, generateCitationId(id));
3756
+ }
3757
+ })();
3758
+ }
3604
3759
  /**
3605
3760
  * Get events since a timestamp (for sync)
3606
3761
  */
@@ -3802,25 +3957,22 @@ var SQLiteEventStore = class {
3802
3957
  */
3803
3958
  async getPendingOutboxItems(limit = 32) {
3804
3959
  await this.initialize();
3805
- const pending = sqliteAll(
3806
- this.db,
3807
- `SELECT * FROM embedding_outbox
3808
- WHERE status = 'pending'
3809
- ORDER BY created_at
3810
- LIMIT ?`,
3811
- [limit]
3812
- );
3813
- if (pending.length === 0) return [];
3814
- const ids = pending.map((r) => r.id);
3815
- const placeholders = ids.map(() => "?").join(",");
3816
- sqliteRun(
3960
+ const claimed = sqliteAll(
3817
3961
  this.db,
3818
3962
  `UPDATE embedding_outbox
3819
3963
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3820
- WHERE id IN (${placeholders})`,
3821
- ids
3964
+ WHERE id IN (
3965
+ SELECT id FROM embedding_outbox
3966
+ WHERE status = 'pending'
3967
+ ORDER BY created_at
3968
+ LIMIT ?
3969
+ )
3970
+ RETURNING *`,
3971
+ [limit]
3822
3972
  );
3823
- return pending.map((row) => ({
3973
+ if (claimed.length === 0) return [];
3974
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
3975
+ return claimed.map((row) => ({
3824
3976
  id: row.id,
3825
3977
  eventId: row.event_id,
3826
3978
  content: row.content,
@@ -4424,7 +4576,7 @@ var SQLiteEventStore = class {
4424
4576
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4425
4577
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4426
4578
  let toolSuccessCount = 0;
4427
- let toolTotalCount = toolEvents.length;
4579
+ const toolTotalCount = toolEvents.length;
4428
4580
  for (const t of toolEvents) {
4429
4581
  try {
4430
4582
  const content = JSON.parse(t.content);
@@ -4587,10 +4739,14 @@ var SQLiteEventStore = class {
4587
4739
  * Rebuild FTS index from existing events
4588
4740
  * Call this once after upgrading to FTS5
4589
4741
  */
4590
- async rebuildFtsIndex() {
4591
- await this.initialize();
4592
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4593
- const totalEvents = countRow?.count ?? 0;
4742
+ /**
4743
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4744
+ * from the current events table. Recreating the table (instead of issuing
4745
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4746
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4747
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4748
+ */
4749
+ recreateEventsFtsTable() {
4594
4750
  sqliteExec(this.db, `
4595
4751
  DROP TRIGGER IF EXISTS events_fts_insert;
4596
4752
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4619,6 +4775,12 @@ var SQLiteEventStore = class {
4619
4775
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4620
4776
  END;
4621
4777
  `);
4778
+ }
4779
+ async rebuildFtsIndex() {
4780
+ await this.initialize();
4781
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4782
+ const totalEvents = countRow?.count ?? 0;
4783
+ this.recreateEventsFtsTable();
4622
4784
  return totalEvents;
4623
4785
  }
4624
4786
  /**
@@ -4636,6 +4798,35 @@ var SQLiteEventStore = class {
4636
4798
  return false;
4637
4799
  }
4638
4800
  }
4801
+ hasTable(tableName) {
4802
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4803
+ try {
4804
+ const rows = sqliteAll(
4805
+ this.db,
4806
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4807
+ [tableName]
4808
+ );
4809
+ return rows.length > 0;
4810
+ } catch {
4811
+ return false;
4812
+ }
4813
+ }
4814
+ /**
4815
+ * Add a column only when its table exists and the column is missing.
4816
+ *
4817
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4818
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4819
+ * failure (disk full, corruption), which is allowed to surface instead of
4820
+ * being silently swallowed as if the migration had succeeded.
4821
+ */
4822
+ addColumnIfMissing(table, column, definition) {
4823
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4824
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4825
+ }
4826
+ if (!this.hasTable(table)) return;
4827
+ if (this.hasTableColumn(table, column)) return;
4828
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4829
+ }
4639
4830
  async recordRetrievalTrace(input) {
4640
4831
  await this.initialize();
4641
4832
  const traceId = randomUUID6();
@@ -4721,6 +4912,45 @@ var SQLiteEventStore = class {
4721
4912
  FROM retrieval_traces`,
4722
4913
  []
4723
4914
  );
4915
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
4916
+ const strategyRows = sqliteAll(
4917
+ this.db,
4918
+ `SELECT
4919
+ ${strategyColumnSql} as strategy,
4920
+ COUNT(*) as total_queries,
4921
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
4922
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
4923
+ SUM(candidate_count) as total_candidate_count,
4924
+ SUM(selected_count) as total_selected_count,
4925
+ AVG(candidate_count) as avg_candidate_count,
4926
+ AVG(selected_count) as avg_selected_count,
4927
+ CASE
4928
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
4929
+ ELSE 0
4930
+ END as selection_rate
4931
+ FROM retrieval_traces
4932
+ GROUP BY ${strategyColumnSql}
4933
+ ORDER BY total_queries DESC, strategy ASC`,
4934
+ []
4935
+ );
4936
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
4937
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
4938
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
4939
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
4940
+ return {
4941
+ strategy: String(strategyRow.strategy || "unknown"),
4942
+ totalQueries: strategyTotalQueries,
4943
+ queriesWithSelection: strategyQueriesWithSelection,
4944
+ rewrittenQueries: strategyRewrittenQueries,
4945
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
4946
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
4947
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
4948
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
4949
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
4950
+ selectionRate: Number(strategyRow.selection_rate || 0),
4951
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
4952
+ };
4953
+ });
4724
4954
  const totalQueries = Number(row?.total_queries || 0);
4725
4955
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4726
4956
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4738,7 +4968,8 @@ var SQLiteEventStore = class {
4738
4968
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4739
4969
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4740
4970
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4741
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
4971
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
4972
+ strategyBreakdown
4742
4973
  };
4743
4974
  } catch (err) {
4744
4975
  if (err?.message?.includes("no such table")) {
@@ -4754,7 +4985,8 @@ var SQLiteEventStore = class {
4754
4985
  rewrittenSelectionRate: 0,
4755
4986
  rawSelectionRate: 0,
4756
4987
  avgSelectedCountForRewrittenQueries: 0,
4757
- avgSelectedCountForRawQueries: 0
4988
+ avgSelectedCountForRawQueries: 0,
4989
+ strategyBreakdown: []
4758
4990
  };
4759
4991
  }
4760
4992
  throw err;
@@ -4871,38 +5103,19 @@ var SQLiteEventStore = class {
4871
5103
  if (events.length === 0) return 0;
4872
5104
  const eventIds = events.map((e) => e.id);
4873
5105
  const placeholders = eventIds.map(() => "?").join(",");
4874
- const ftsTriggersDropped = [];
4875
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4876
- try {
5106
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5107
+ const runDelete = this.db.transaction(() => {
5108
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4877
5109
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4878
- ftsTriggersDropped.push(triggerName);
4879
- } catch {
4880
5110
  }
4881
- }
4882
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
4883
- try {
5111
+ for (const table of relatedTables) {
4884
5112
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
4885
- } catch {
4886
5113
  }
4887
- }
4888
- const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
4889
- if (ftsTriggersDropped.length > 0) {
4890
- try {
4891
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
4892
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
4893
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4894
- END`);
4895
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
4896
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4897
- END`);
4898
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
4899
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4900
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4901
- END`);
4902
- } catch {
4903
- }
4904
- }
4905
- return result.changes || 0;
5114
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5115
+ this.recreateEventsFtsTable();
5116
+ return result.changes || 0;
5117
+ });
5118
+ return runDelete();
4906
5119
  }
4907
5120
  /**
4908
5121
  * Convert database row to MemoryEvent
@@ -7235,7 +7448,7 @@ var SessionActorRepository = class {
7235
7448
  };
7236
7449
 
7237
7450
  // src/core/operations/perspective-observation-repository.ts
7238
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
7451
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
7239
7452
  function projectHashToStorage4(projectHash) {
7240
7453
  return projectHash ?? "";
7241
7454
  }
@@ -7272,7 +7485,7 @@ function sanitizeStoredRecord(value) {
7272
7485
  return sanitizeGovernanceAuditValue(value);
7273
7486
  }
7274
7487
  function stableHash(value) {
7275
- return createHash3("sha256").update(JSON.stringify(value)).digest("hex");
7488
+ return createHash4("sha256").update(JSON.stringify(value)).digest("hex");
7276
7489
  }
7277
7490
  function evidenceHash(sourceEventIds, sourceObservationIds) {
7278
7491
  return stableHash({
@@ -8387,6 +8600,30 @@ var MemoryQueryService = class {
8387
8600
  score: 1 - (r.rank - minRank) / rankRange
8388
8601
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
8389
8602
  }
8603
+ async getEvent(id) {
8604
+ await this.initialize();
8605
+ return this.queryStore.getEvent(id);
8606
+ }
8607
+ async getEventByCitationId(citationId) {
8608
+ await this.initialize();
8609
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
8610
+ }
8611
+ async getEventsAfter(sinceIso) {
8612
+ await this.initialize();
8613
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
8614
+ }
8615
+ async getEventTypeCounts() {
8616
+ await this.initialize();
8617
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
8618
+ }
8619
+ async getDistinctSessionCount() {
8620
+ await this.initialize();
8621
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
8622
+ }
8623
+ async getDailyEventCounts(sinceIso) {
8624
+ await this.initialize();
8625
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
8626
+ }
8390
8627
  async getSessionHistory(sessionId) {
8391
8628
  await this.initialize();
8392
8629
  return this.queryStore.getSessionEvents(sessionId);
@@ -9355,11 +9592,12 @@ var Retriever = class {
9355
9592
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
9356
9593
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
9357
9594
  const filtered = [];
9595
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9358
9596
  for (const result of results) {
9359
9597
  if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
9360
9598
  if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
9361
9599
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType)) continue;
9362
- const event = await this.eventStore.getEvent(result.eventId);
9600
+ const event = eventMap.get(result.eventId);
9363
9601
  if (!event) continue;
9364
9602
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
9365
9603
  if (normalizedIncludes.length > 0) {
@@ -9468,10 +9706,30 @@ var Retriever = class {
9468
9706
  async retrieveRecent(limit = 100) {
9469
9707
  return this.eventStore.getRecentEvents(limit);
9470
9708
  }
9709
+ /**
9710
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
9711
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
9712
+ */
9713
+ async fetchEventsByIds(ids) {
9714
+ const map = /* @__PURE__ */ new Map();
9715
+ if (ids.length === 0) return map;
9716
+ const unique = [...new Set(ids)];
9717
+ if (typeof this.eventStore.getEvents === "function") {
9718
+ const events = await this.eventStore.getEvents(unique);
9719
+ for (const event of events) map.set(event.id, event);
9720
+ } else {
9721
+ const events = await Promise.all(unique.map((id) => this.eventStore.getEvent(id)));
9722
+ for (const event of events) {
9723
+ if (event) map.set(event.id, event);
9724
+ }
9725
+ }
9726
+ return map;
9727
+ }
9471
9728
  async enrichResults(results, options, query) {
9472
9729
  const memories = [];
9730
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9473
9731
  for (const result of results) {
9474
- const event = await this.eventStore.getEvent(result.eventId);
9732
+ const event = eventMap.get(result.eventId);
9475
9733
  if (!event) continue;
9476
9734
  if (this.graduation) {
9477
9735
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result.score);
@@ -12224,6 +12482,34 @@ var MemoryService = class {
12224
12482
  async rebuildFtsIndex() {
12225
12483
  return this.queryService.rebuildFtsIndex();
12226
12484
  }
12485
+ /**
12486
+ * Get a single event by id (indexed lookup).
12487
+ */
12488
+ async getEvent(id) {
12489
+ return this.queryService.getEvent(id);
12490
+ }
12491
+ /**
12492
+ * Resolve an event from its citation id via the reverse index.
12493
+ */
12494
+ async getEventByCitationId(citationId) {
12495
+ return this.queryService.getEventByCitationId(citationId);
12496
+ }
12497
+ /** All events at/after an ISO timestamp (ascending, uncapped). */
12498
+ async getEventsAfter(sinceIso) {
12499
+ return this.queryService.getEventsAfter(sinceIso);
12500
+ }
12501
+ /** Aggregate event counts by type (SQL GROUP BY). */
12502
+ async getEventTypeCounts() {
12503
+ return this.queryService.getEventTypeCounts();
12504
+ }
12505
+ /** Count distinct sessions (SQL). */
12506
+ async getDistinctSessionCount() {
12507
+ return this.queryService.getDistinctSessionCount();
12508
+ }
12509
+ /** Per-day event counts with type breakdown since an ISO timestamp (SQL). */
12510
+ async getDailyEventCounts(sinceIso) {
12511
+ return this.queryService.getDailyEventCounts(sinceIso);
12512
+ }
12227
12513
  /**
12228
12514
  * Get session history
12229
12515
  */