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.
@@ -164,7 +164,13 @@ function toDate(value) {
164
164
  return new Date(String(value));
165
165
  }
166
166
  function createDatabase(dbPath, options) {
167
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
167
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
168
+ db.pragma("busy_timeout = 5000");
169
+ if (!options?.readOnly) {
170
+ db.pragma("journal_mode = WAL");
171
+ db.pragma("synchronous = NORMAL");
172
+ }
173
+ return db;
168
174
  }
169
175
  function dbRun(db, sql, params = []) {
170
176
  db.prepare(sql).run(...params);
@@ -397,19 +403,29 @@ var ConsolidatedStore = class {
397
403
  */
398
404
  async create(input) {
399
405
  const memoryId = randomUUID2();
400
- await dbRun(
401
- this.db,
406
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
407
+ const insertMemory = this.db.prepare(
402
408
  `INSERT INTO consolidated_memories
403
409
  (memory_id, summary, topics, source_events, confidence, created_at)
404
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
405
- [
410
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
411
+ );
412
+ const insertEvent = this.db.prepare(
413
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
414
+ );
415
+ this.db.transaction(() => {
416
+ insertMemory.run(
406
417
  memoryId,
407
418
  input.summary,
408
419
  JSON.stringify(input.topics),
409
- JSON.stringify(input.sourceEvents),
420
+ JSON.stringify(sourceEvents),
410
421
  input.confidence
411
- ]
412
- );
422
+ );
423
+ for (const eventId of sourceEvents) {
424
+ if (typeof eventId === "string" && eventId.length > 0) {
425
+ insertEvent.run(memoryId, eventId);
426
+ }
427
+ }
428
+ })();
413
429
  return memoryId;
414
430
  }
415
431
  /**
@@ -515,11 +531,12 @@ var ConsolidatedStore = class {
515
531
  * Delete a consolidated memory
516
532
  */
517
533
  async delete(memoryId) {
518
- await dbRun(
519
- this.db,
520
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
521
- [memoryId]
522
- );
534
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
535
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
536
+ this.db.transaction(() => {
537
+ deleteEvents.run(memoryId);
538
+ deleteMemory.run(memoryId);
539
+ })();
523
540
  }
524
541
  /**
525
542
  * Create a long-term rule promoted from stable summaries
@@ -632,16 +649,16 @@ var ConsolidatedStore = class {
632
649
  * Check if source events are already consolidated
633
650
  */
634
651
  async isAlreadyConsolidated(eventIds) {
635
- for (const eventId of eventIds) {
636
- const result = await dbAll(
637
- this.db,
638
- `SELECT COUNT(*) as count FROM consolidated_memories
639
- WHERE source_events LIKE ?`,
640
- [`%"${eventId}"%`]
641
- );
642
- if ((result[0]?.count || 0) > 0) return true;
643
- }
644
- return false;
652
+ if (eventIds.length === 0) return false;
653
+ const placeholders = eventIds.map(() => "?").join(",");
654
+ const rows = await dbAll(
655
+ this.db,
656
+ `SELECT memory_id FROM consolidated_memory_events
657
+ WHERE event_id IN (${placeholders})
658
+ LIMIT 1`,
659
+ eventIds
660
+ );
661
+ return rows.length > 0;
645
662
  }
646
663
  /**
647
664
  * Get the last consolidation time
@@ -2177,6 +2194,19 @@ function makeDedupeKey(content, sessionId) {
2177
2194
  return `${sessionId}:${contentHash}`;
2178
2195
  }
2179
2196
 
2197
+ // src/core/citation-generator.ts
2198
+ import { createHash as createHash2 } from "crypto";
2199
+ var ID_LENGTH = 6;
2200
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2201
+ function generateCitationId(eventId) {
2202
+ const hash = createHash2("sha256").update(eventId).digest();
2203
+ let id = "";
2204
+ for (let i = 0; i < ID_LENGTH; i++) {
2205
+ id += CHARSET[hash[i] % CHARSET.length];
2206
+ }
2207
+ return id;
2208
+ }
2209
+
2180
2210
  // src/core/sqlite-event-store.ts
2181
2211
  import * as nodePath2 from "path";
2182
2212
 
@@ -2221,17 +2251,28 @@ function createSQLiteDatabase(path12, options) {
2221
2251
  }
2222
2252
  return db;
2223
2253
  }
2254
+ var statementCache = /* @__PURE__ */ new WeakMap();
2255
+ function prepareCached(db, sql) {
2256
+ let cache = statementCache.get(db);
2257
+ if (!cache) {
2258
+ cache = /* @__PURE__ */ new Map();
2259
+ statementCache.set(db, cache);
2260
+ }
2261
+ let stmt = cache.get(sql);
2262
+ if (!stmt) {
2263
+ stmt = db.prepare(sql);
2264
+ cache.set(sql, stmt);
2265
+ }
2266
+ return stmt;
2267
+ }
2224
2268
  function sqliteRun(db, sql, params = []) {
2225
- const stmt = db.prepare(sql);
2226
- return stmt.run(...params);
2269
+ return prepareCached(db, sql).run(...params);
2227
2270
  }
2228
2271
  function sqliteAll(db, sql, params = []) {
2229
- const stmt = db.prepare(sql);
2230
- return stmt.all(...params);
2272
+ return prepareCached(db, sql).all(...params);
2231
2273
  }
2232
2274
  function sqliteGet(db, sql, params = []) {
2233
- const stmt = db.prepare(sql);
2234
- return stmt.get(...params);
2275
+ return prepareCached(db, sql).get(...params);
2235
2276
  }
2236
2277
  function sqliteExec(db, sql) {
2237
2278
  db.exec(sql);
@@ -2653,6 +2694,7 @@ function normalizeQueryRewriteKind(value) {
2653
2694
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2654
2695
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2655
2696
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2697
+ var SQLITE_SCHEMA_VERSION = 1;
2656
2698
  function emptyOutboxRecoveryResult() {
2657
2699
  return {
2658
2700
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -2997,6 +3039,15 @@ var SQLiteEventStore = class {
2997
3039
  access_count INTEGER DEFAULT 0
2998
3040
  );
2999
3041
 
3042
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3043
+ -- replaces per-event source_events LIKE scans during consolidation).
3044
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3045
+ memory_id TEXT NOT NULL,
3046
+ event_id TEXT NOT NULL,
3047
+ PRIMARY KEY (memory_id, event_id)
3048
+ );
3049
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3050
+
3000
3051
  -- Continuity Log table (tracks context transitions)
3001
3052
  CREATE TABLE IF NOT EXISTS continuity_log (
3002
3053
  log_id TEXT PRIMARY KEY,
@@ -3356,11 +3407,17 @@ var SQLiteEventStore = class {
3356
3407
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3357
3408
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3358
3409
  END;
3410
+
3411
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3412
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3413
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3414
+ CREATE TABLE IF NOT EXISTS event_citations (
3415
+ event_id TEXT PRIMARY KEY,
3416
+ citation_id TEXT NOT NULL
3417
+ );
3418
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3359
3419
  `);
3360
- try {
3361
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3362
- } catch {
3363
- }
3420
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3364
3421
  try {
3365
3422
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3366
3423
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3397,55 +3454,17 @@ var SQLiteEventStore = class {
3397
3454
  }
3398
3455
  } catch {
3399
3456
  }
3400
- try {
3401
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3402
- } catch {
3403
- }
3404
- try {
3405
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3406
- } catch {
3407
- }
3408
- try {
3409
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3410
- } catch {
3411
- }
3412
- try {
3413
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3414
- } catch {
3415
- }
3457
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3458
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3459
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3460
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3416
3461
  try {
3417
3462
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3418
3463
  } catch {
3419
3464
  }
3420
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3421
- const columnNames = tableInfo.map((col) => col.name);
3422
- if (!columnNames.includes("access_count")) {
3423
- try {
3424
- sqliteExec(this.db, `
3425
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3426
- `);
3427
- } catch (err) {
3428
- console.error("Error adding access_count column:", err);
3429
- }
3430
- }
3431
- if (!columnNames.includes("last_accessed_at")) {
3432
- try {
3433
- sqliteExec(this.db, `
3434
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3435
- `);
3436
- } catch (err) {
3437
- console.error("Error adding last_accessed_at column:", err);
3438
- }
3439
- }
3440
- if (!columnNames.includes("turn_id")) {
3441
- try {
3442
- sqliteExec(this.db, `
3443
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3444
- `);
3445
- } catch (err) {
3446
- console.error("Error adding turn_id column:", err);
3447
- }
3448
- }
3465
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3466
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3467
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3449
3468
  try {
3450
3469
  sqliteExec(this.db, `
3451
3470
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3462,8 +3481,18 @@ var SQLiteEventStore = class {
3462
3481
  sqliteExec(this.db, `
3463
3482
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3464
3483
  `);
3465
- } catch (err) {
3484
+ } catch {
3466
3485
  }
3486
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3487
+ if ((cmeCount?.c ?? 0) === 0) {
3488
+ sqliteExec(this.db, `
3489
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3490
+ SELECT cm.memory_id, je.value
3491
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3492
+ WHERE json_valid(cm.source_events);
3493
+ `);
3494
+ }
3495
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3467
3496
  this.initialized = true;
3468
3497
  }
3469
3498
  /**
@@ -3595,6 +3624,67 @@ var SQLiteEventStore = class {
3595
3624
  );
3596
3625
  return rows.map(this.rowToEvent);
3597
3626
  }
3627
+ /**
3628
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3629
+ * into memory and counting in JS. Counts all events (the old recent-window
3630
+ * scan also under-counted stores larger than its cap).
3631
+ */
3632
+ async getEventTypeCounts(options) {
3633
+ await this.initialize();
3634
+ const rows = sqliteAll(
3635
+ this.db,
3636
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3637
+ []
3638
+ );
3639
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3640
+ }
3641
+ /**
3642
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3643
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3644
+ * the window needs instead of an arbitrary "most recent N" slice that both
3645
+ * over-fetches sparse windows and truncates very active ones.
3646
+ */
3647
+ async getEventsAfter(sinceIso, options) {
3648
+ await this.initialize();
3649
+ const rows = sqliteAll(
3650
+ this.db,
3651
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3652
+ [sinceIso]
3653
+ );
3654
+ return rows.map((row) => this.rowToEvent(row));
3655
+ }
3656
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3657
+ async getDistinctSessionCount(options) {
3658
+ await this.initialize();
3659
+ const row = sqliteGet(
3660
+ this.db,
3661
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3662
+ []
3663
+ );
3664
+ return row?.count ?? 0;
3665
+ }
3666
+ /**
3667
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3668
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3669
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3670
+ * lexicographically.
3671
+ */
3672
+ async getDailyEventCounts(sinceIso, options) {
3673
+ await this.initialize();
3674
+ return sqliteAll(
3675
+ this.db,
3676
+ `SELECT substr(timestamp, 1, 10) as day,
3677
+ COUNT(*) as total,
3678
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3679
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3680
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3681
+ FROM events
3682
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3683
+ GROUP BY day
3684
+ ORDER BY day ASC`,
3685
+ [sinceIso]
3686
+ );
3687
+ }
3598
3688
  /**
3599
3689
  * Get event by ID
3600
3690
  */
@@ -3608,6 +3698,71 @@ var SQLiteEventStore = class {
3608
3698
  if (!row) return null;
3609
3699
  return this.rowToEvent(row);
3610
3700
  }
3701
+ /**
3702
+ * Batch-fetch events by id in a single query, applying the same quarantine
3703
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3704
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3705
+ */
3706
+ async getEvents(ids, options) {
3707
+ await this.initialize();
3708
+ if (ids.length === 0) return [];
3709
+ const placeholders = ids.map(() => "?").join(",");
3710
+ const rows = sqliteAll(
3711
+ this.db,
3712
+ `SELECT * FROM events WHERE id IN (${placeholders}) AND ${maybeQuarantinePredicate(options)}`,
3713
+ ids
3714
+ );
3715
+ return rows.map((row) => this.rowToEvent(row));
3716
+ }
3717
+ /**
3718
+ * Resolve an event from its citation id via the reverse index, instead of
3719
+ * scanning recent events and hashing each one. The index is self-healing: a
3720
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3721
+ * freshly appended events) before retrying.
3722
+ */
3723
+ async getEventByCitationId(citationId, options) {
3724
+ await this.initialize();
3725
+ if (!citationId) return null;
3726
+ let row = sqliteGet(
3727
+ this.db,
3728
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3729
+ [citationId]
3730
+ );
3731
+ if (!row) {
3732
+ this.indexMissingCitations();
3733
+ row = sqliteGet(
3734
+ this.db,
3735
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3736
+ [citationId]
3737
+ );
3738
+ }
3739
+ if (!row) return null;
3740
+ return this.getEvent(row.event_id, options);
3741
+ }
3742
+ /**
3743
+ * Backfill citation ids for any events not yet present in event_citations.
3744
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3745
+ * work, and the scan only runs when there are genuinely new events to index.
3746
+ */
3747
+ indexMissingCitations() {
3748
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3749
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3750
+ if (indexedCount >= eventCount) return;
3751
+ const unindexed = sqliteAll(
3752
+ this.db,
3753
+ `SELECT e.id FROM events e
3754
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3755
+ WHERE ec.event_id IS NULL`,
3756
+ []
3757
+ );
3758
+ if (unindexed.length === 0) return;
3759
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3760
+ this.db.transaction(() => {
3761
+ for (const { id } of unindexed) {
3762
+ insert.run(id, generateCitationId(id));
3763
+ }
3764
+ })();
3765
+ }
3611
3766
  /**
3612
3767
  * Get events since a timestamp (for sync)
3613
3768
  */
@@ -3809,25 +3964,22 @@ var SQLiteEventStore = class {
3809
3964
  */
3810
3965
  async getPendingOutboxItems(limit = 32) {
3811
3966
  await this.initialize();
3812
- const pending = sqliteAll(
3813
- this.db,
3814
- `SELECT * FROM embedding_outbox
3815
- WHERE status = 'pending'
3816
- ORDER BY created_at
3817
- LIMIT ?`,
3818
- [limit]
3819
- );
3820
- if (pending.length === 0) return [];
3821
- const ids = pending.map((r) => r.id);
3822
- const placeholders = ids.map(() => "?").join(",");
3823
- sqliteRun(
3967
+ const claimed = sqliteAll(
3824
3968
  this.db,
3825
3969
  `UPDATE embedding_outbox
3826
3970
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3827
- WHERE id IN (${placeholders})`,
3828
- ids
3971
+ WHERE id IN (
3972
+ SELECT id FROM embedding_outbox
3973
+ WHERE status = 'pending'
3974
+ ORDER BY created_at
3975
+ LIMIT ?
3976
+ )
3977
+ RETURNING *`,
3978
+ [limit]
3829
3979
  );
3830
- return pending.map((row) => ({
3980
+ if (claimed.length === 0) return [];
3981
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
3982
+ return claimed.map((row) => ({
3831
3983
  id: row.id,
3832
3984
  eventId: row.event_id,
3833
3985
  content: row.content,
@@ -4431,7 +4583,7 @@ var SQLiteEventStore = class {
4431
4583
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4432
4584
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4433
4585
  let toolSuccessCount = 0;
4434
- let toolTotalCount = toolEvents.length;
4586
+ const toolTotalCount = toolEvents.length;
4435
4587
  for (const t of toolEvents) {
4436
4588
  try {
4437
4589
  const content = JSON.parse(t.content);
@@ -4594,10 +4746,14 @@ var SQLiteEventStore = class {
4594
4746
  * Rebuild FTS index from existing events
4595
4747
  * Call this once after upgrading to FTS5
4596
4748
  */
4597
- async rebuildFtsIndex() {
4598
- await this.initialize();
4599
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4600
- const totalEvents = countRow?.count ?? 0;
4749
+ /**
4750
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4751
+ * from the current events table. Recreating the table (instead of issuing
4752
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4753
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4754
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4755
+ */
4756
+ recreateEventsFtsTable() {
4601
4757
  sqliteExec(this.db, `
4602
4758
  DROP TRIGGER IF EXISTS events_fts_insert;
4603
4759
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4626,6 +4782,12 @@ var SQLiteEventStore = class {
4626
4782
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4627
4783
  END;
4628
4784
  `);
4785
+ }
4786
+ async rebuildFtsIndex() {
4787
+ await this.initialize();
4788
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4789
+ const totalEvents = countRow?.count ?? 0;
4790
+ this.recreateEventsFtsTable();
4629
4791
  return totalEvents;
4630
4792
  }
4631
4793
  /**
@@ -4643,6 +4805,35 @@ var SQLiteEventStore = class {
4643
4805
  return false;
4644
4806
  }
4645
4807
  }
4808
+ hasTable(tableName) {
4809
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4810
+ try {
4811
+ const rows = sqliteAll(
4812
+ this.db,
4813
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4814
+ [tableName]
4815
+ );
4816
+ return rows.length > 0;
4817
+ } catch {
4818
+ return false;
4819
+ }
4820
+ }
4821
+ /**
4822
+ * Add a column only when its table exists and the column is missing.
4823
+ *
4824
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4825
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4826
+ * failure (disk full, corruption), which is allowed to surface instead of
4827
+ * being silently swallowed as if the migration had succeeded.
4828
+ */
4829
+ addColumnIfMissing(table, column, definition) {
4830
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4831
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4832
+ }
4833
+ if (!this.hasTable(table)) return;
4834
+ if (this.hasTableColumn(table, column)) return;
4835
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4836
+ }
4646
4837
  async recordRetrievalTrace(input) {
4647
4838
  await this.initialize();
4648
4839
  const traceId = randomUUID6();
@@ -4728,6 +4919,45 @@ var SQLiteEventStore = class {
4728
4919
  FROM retrieval_traces`,
4729
4920
  []
4730
4921
  );
4922
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
4923
+ const strategyRows = sqliteAll(
4924
+ this.db,
4925
+ `SELECT
4926
+ ${strategyColumnSql} as strategy,
4927
+ COUNT(*) as total_queries,
4928
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
4929
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
4930
+ SUM(candidate_count) as total_candidate_count,
4931
+ SUM(selected_count) as total_selected_count,
4932
+ AVG(candidate_count) as avg_candidate_count,
4933
+ AVG(selected_count) as avg_selected_count,
4934
+ CASE
4935
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
4936
+ ELSE 0
4937
+ END as selection_rate
4938
+ FROM retrieval_traces
4939
+ GROUP BY ${strategyColumnSql}
4940
+ ORDER BY total_queries DESC, strategy ASC`,
4941
+ []
4942
+ );
4943
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
4944
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
4945
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
4946
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
4947
+ return {
4948
+ strategy: String(strategyRow.strategy || "unknown"),
4949
+ totalQueries: strategyTotalQueries,
4950
+ queriesWithSelection: strategyQueriesWithSelection,
4951
+ rewrittenQueries: strategyRewrittenQueries,
4952
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
4953
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
4954
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
4955
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
4956
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
4957
+ selectionRate: Number(strategyRow.selection_rate || 0),
4958
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
4959
+ };
4960
+ });
4731
4961
  const totalQueries = Number(row?.total_queries || 0);
4732
4962
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4733
4963
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4745,7 +4975,8 @@ var SQLiteEventStore = class {
4745
4975
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4746
4976
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4747
4977
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4748
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
4978
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
4979
+ strategyBreakdown
4749
4980
  };
4750
4981
  } catch (err) {
4751
4982
  if (err?.message?.includes("no such table")) {
@@ -4761,7 +4992,8 @@ var SQLiteEventStore = class {
4761
4992
  rewrittenSelectionRate: 0,
4762
4993
  rawSelectionRate: 0,
4763
4994
  avgSelectedCountForRewrittenQueries: 0,
4764
- avgSelectedCountForRawQueries: 0
4995
+ avgSelectedCountForRawQueries: 0,
4996
+ strategyBreakdown: []
4765
4997
  };
4766
4998
  }
4767
4999
  throw err;
@@ -4878,38 +5110,19 @@ var SQLiteEventStore = class {
4878
5110
  if (events.length === 0) return 0;
4879
5111
  const eventIds = events.map((e) => e.id);
4880
5112
  const placeholders = eventIds.map(() => "?").join(",");
4881
- const ftsTriggersDropped = [];
4882
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4883
- try {
5113
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5114
+ const runDelete = this.db.transaction(() => {
5115
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4884
5116
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4885
- ftsTriggersDropped.push(triggerName);
4886
- } catch {
4887
5117
  }
4888
- }
4889
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
4890
- try {
5118
+ for (const table of relatedTables) {
4891
5119
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
4892
- } catch {
4893
- }
4894
- }
4895
- const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
4896
- if (ftsTriggersDropped.length > 0) {
4897
- try {
4898
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
4899
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
4900
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4901
- END`);
4902
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
4903
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4904
- END`);
4905
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
4906
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4907
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4908
- END`);
4909
- } catch {
4910
5120
  }
4911
- }
4912
- return result.changes || 0;
5121
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5122
+ this.recreateEventsFtsTable();
5123
+ return result.changes || 0;
5124
+ });
5125
+ return runDelete();
4913
5126
  }
4914
5127
  /**
4915
5128
  * Convert database row to MemoryEvent
@@ -7242,7 +7455,7 @@ var SessionActorRepository = class {
7242
7455
  };
7243
7456
 
7244
7457
  // src/core/operations/perspective-observation-repository.ts
7245
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
7458
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
7246
7459
  function projectHashToStorage4(projectHash) {
7247
7460
  return projectHash ?? "";
7248
7461
  }
@@ -7279,7 +7492,7 @@ function sanitizeStoredRecord(value) {
7279
7492
  return sanitizeGovernanceAuditValue(value);
7280
7493
  }
7281
7494
  function stableHash(value) {
7282
- return createHash3("sha256").update(JSON.stringify(value)).digest("hex");
7495
+ return createHash4("sha256").update(JSON.stringify(value)).digest("hex");
7283
7496
  }
7284
7497
  function evidenceHash(sourceEventIds, sourceObservationIds) {
7285
7498
  return stableHash({
@@ -8394,6 +8607,30 @@ var MemoryQueryService = class {
8394
8607
  score: 1 - (r.rank - minRank) / rankRange
8395
8608
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
8396
8609
  }
8610
+ async getEvent(id) {
8611
+ await this.initialize();
8612
+ return this.queryStore.getEvent(id);
8613
+ }
8614
+ async getEventByCitationId(citationId) {
8615
+ await this.initialize();
8616
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
8617
+ }
8618
+ async getEventsAfter(sinceIso) {
8619
+ await this.initialize();
8620
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
8621
+ }
8622
+ async getEventTypeCounts() {
8623
+ await this.initialize();
8624
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
8625
+ }
8626
+ async getDistinctSessionCount() {
8627
+ await this.initialize();
8628
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
8629
+ }
8630
+ async getDailyEventCounts(sinceIso) {
8631
+ await this.initialize();
8632
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
8633
+ }
8397
8634
  async getSessionHistory(sessionId) {
8398
8635
  await this.initialize();
8399
8636
  return this.queryStore.getSessionEvents(sessionId);
@@ -9362,11 +9599,12 @@ var Retriever = class {
9362
9599
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
9363
9600
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
9364
9601
  const filtered = [];
9602
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9365
9603
  for (const result of results) {
9366
9604
  if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
9367
9605
  if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
9368
9606
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType)) continue;
9369
- const event = await this.eventStore.getEvent(result.eventId);
9607
+ const event = eventMap.get(result.eventId);
9370
9608
  if (!event) continue;
9371
9609
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
9372
9610
  if (normalizedIncludes.length > 0) {
@@ -9475,10 +9713,30 @@ var Retriever = class {
9475
9713
  async retrieveRecent(limit = 100) {
9476
9714
  return this.eventStore.getRecentEvents(limit);
9477
9715
  }
9716
+ /**
9717
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
9718
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
9719
+ */
9720
+ async fetchEventsByIds(ids) {
9721
+ const map = /* @__PURE__ */ new Map();
9722
+ if (ids.length === 0) return map;
9723
+ const unique = [...new Set(ids)];
9724
+ if (typeof this.eventStore.getEvents === "function") {
9725
+ const events = await this.eventStore.getEvents(unique);
9726
+ for (const event of events) map.set(event.id, event);
9727
+ } else {
9728
+ const events = await Promise.all(unique.map((id) => this.eventStore.getEvent(id)));
9729
+ for (const event of events) {
9730
+ if (event) map.set(event.id, event);
9731
+ }
9732
+ }
9733
+ return map;
9734
+ }
9478
9735
  async enrichResults(results, options, query) {
9479
9736
  const memories = [];
9737
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9480
9738
  for (const result of results) {
9481
- const event = await this.eventStore.getEvent(result.eventId);
9739
+ const event = eventMap.get(result.eventId);
9482
9740
  if (!event) continue;
9483
9741
  if (this.graduation) {
9484
9742
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result.score);
@@ -12206,6 +12464,34 @@ var MemoryService = class {
12206
12464
  async rebuildFtsIndex() {
12207
12465
  return this.queryService.rebuildFtsIndex();
12208
12466
  }
12467
+ /**
12468
+ * Get a single event by id (indexed lookup).
12469
+ */
12470
+ async getEvent(id) {
12471
+ return this.queryService.getEvent(id);
12472
+ }
12473
+ /**
12474
+ * Resolve an event from its citation id via the reverse index.
12475
+ */
12476
+ async getEventByCitationId(citationId) {
12477
+ return this.queryService.getEventByCitationId(citationId);
12478
+ }
12479
+ /** All events at/after an ISO timestamp (ascending, uncapped). */
12480
+ async getEventsAfter(sinceIso) {
12481
+ return this.queryService.getEventsAfter(sinceIso);
12482
+ }
12483
+ /** Aggregate event counts by type (SQL GROUP BY). */
12484
+ async getEventTypeCounts() {
12485
+ return this.queryService.getEventTypeCounts();
12486
+ }
12487
+ /** Count distinct sessions (SQL). */
12488
+ async getDistinctSessionCount() {
12489
+ return this.queryService.getDistinctSessionCount();
12490
+ }
12491
+ /** Per-day event counts with type breakdown since an ISO timestamp (SQL). */
12492
+ async getDailyEventCounts(sinceIso) {
12493
+ return this.queryService.getDailyEventCounts(sinceIso);
12494
+ }
12209
12495
  /**
12210
12496
  * Get session history
12211
12497
  */
@@ -12529,6 +12815,18 @@ var getLightweightMemoryService = defaultRegistry.getLightweightMemoryService;
12529
12815
  var getLightweightMemoryServiceForProject = defaultRegistry.getLightweightMemoryServiceForProject;
12530
12816
  var createMemoryService = defaultRegistry.createMemoryService;
12531
12817
 
12818
+ // src/adapters/claude/hooks/hook-runtime.ts
12819
+ function readNumberEnv(name, fallback, options = {}) {
12820
+ const raw = process.env[name];
12821
+ if (raw === void 0 || raw.trim() === "") return fallback;
12822
+ const parsed = options.integer ? Number.parseInt(raw, 10) : Number.parseFloat(raw);
12823
+ if (!Number.isFinite(parsed)) return fallback;
12824
+ let value = parsed;
12825
+ if (options.min !== void 0) value = Math.max(options.min, value);
12826
+ if (options.max !== void 0) value = Math.min(options.max, value);
12827
+ return value;
12828
+ }
12829
+
12532
12830
  // src/adapters/claude/hooks/semantic-daemon.ts
12533
12831
  var SOCKET_PATH = process.env.CLAUDE_MEMORY_SEMANTIC_SOCKET || path11.join(
12534
12832
  os6.homedir(),
@@ -12536,7 +12834,7 @@ var SOCKET_PATH = process.env.CLAUDE_MEMORY_SEMANTIC_SOCKET || path11.join(
12536
12834
  "memory",
12537
12835
  "semantic-daemon.sock"
12538
12836
  );
12539
- var IDLE_TIMEOUT_MS = parseInt(process.env.CLAUDE_MEMORY_SEMANTIC_DAEMON_IDLE_MS || "600000");
12837
+ var IDLE_TIMEOUT_MS = readNumberEnv("CLAUDE_MEMORY_SEMANTIC_DAEMON_IDLE_MS", 6e5, { integer: true, min: 1e3 });
12540
12838
  var serviceCache = /* @__PURE__ */ new Map();
12541
12839
  var server = null;
12542
12840
  var idleTimer = null;