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.
@@ -158,7 +158,13 @@ function toDate(value) {
158
158
  return new Date(String(value));
159
159
  }
160
160
  function createDatabase(dbPath, options) {
161
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
161
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
162
+ db.pragma("busy_timeout = 5000");
163
+ if (!options?.readOnly) {
164
+ db.pragma("journal_mode = WAL");
165
+ db.pragma("synchronous = NORMAL");
166
+ }
167
+ return db;
162
168
  }
163
169
  function dbRun(db, sql, params = []) {
164
170
  db.prepare(sql).run(...params);
@@ -391,19 +397,29 @@ var ConsolidatedStore = class {
391
397
  */
392
398
  async create(input) {
393
399
  const memoryId = randomUUID2();
394
- await dbRun(
395
- this.db,
400
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
401
+ const insertMemory = this.db.prepare(
396
402
  `INSERT INTO consolidated_memories
397
403
  (memory_id, summary, topics, source_events, confidence, created_at)
398
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
399
- [
404
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
405
+ );
406
+ const insertEvent = this.db.prepare(
407
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
408
+ );
409
+ this.db.transaction(() => {
410
+ insertMemory.run(
400
411
  memoryId,
401
412
  input.summary,
402
413
  JSON.stringify(input.topics),
403
- JSON.stringify(input.sourceEvents),
414
+ JSON.stringify(sourceEvents),
404
415
  input.confidence
405
- ]
406
- );
416
+ );
417
+ for (const eventId of sourceEvents) {
418
+ if (typeof eventId === "string" && eventId.length > 0) {
419
+ insertEvent.run(memoryId, eventId);
420
+ }
421
+ }
422
+ })();
407
423
  return memoryId;
408
424
  }
409
425
  /**
@@ -509,11 +525,12 @@ var ConsolidatedStore = class {
509
525
  * Delete a consolidated memory
510
526
  */
511
527
  async delete(memoryId) {
512
- await dbRun(
513
- this.db,
514
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
515
- [memoryId]
516
- );
528
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
529
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
530
+ this.db.transaction(() => {
531
+ deleteEvents.run(memoryId);
532
+ deleteMemory.run(memoryId);
533
+ })();
517
534
  }
518
535
  /**
519
536
  * Create a long-term rule promoted from stable summaries
@@ -626,16 +643,16 @@ var ConsolidatedStore = class {
626
643
  * Check if source events are already consolidated
627
644
  */
628
645
  async isAlreadyConsolidated(eventIds) {
629
- for (const eventId of eventIds) {
630
- const result = await dbAll(
631
- this.db,
632
- `SELECT COUNT(*) as count FROM consolidated_memories
633
- WHERE source_events LIKE ?`,
634
- [`%"${eventId}"%`]
635
- );
636
- if ((result[0]?.count || 0) > 0) return true;
637
- }
638
- return false;
646
+ if (eventIds.length === 0) return false;
647
+ const placeholders = eventIds.map(() => "?").join(",");
648
+ const rows = await dbAll(
649
+ this.db,
650
+ `SELECT memory_id FROM consolidated_memory_events
651
+ WHERE event_id IN (${placeholders})
652
+ LIMIT 1`,
653
+ eventIds
654
+ );
655
+ return rows.length > 0;
639
656
  }
640
657
  /**
641
658
  * Get the last consolidation time
@@ -2171,6 +2188,19 @@ function makeDedupeKey(content, sessionId) {
2171
2188
  return `${sessionId}:${contentHash}`;
2172
2189
  }
2173
2190
 
2191
+ // src/core/citation-generator.ts
2192
+ import { createHash as createHash2 } from "crypto";
2193
+ var ID_LENGTH = 6;
2194
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2195
+ function generateCitationId(eventId) {
2196
+ const hash = createHash2("sha256").update(eventId).digest();
2197
+ let id = "";
2198
+ for (let i = 0; i < ID_LENGTH; i++) {
2199
+ id += CHARSET[hash[i] % CHARSET.length];
2200
+ }
2201
+ return id;
2202
+ }
2203
+
2174
2204
  // src/core/sqlite-event-store.ts
2175
2205
  import * as nodePath2 from "path";
2176
2206
 
@@ -2215,17 +2245,28 @@ function createSQLiteDatabase(path12, options) {
2215
2245
  }
2216
2246
  return db;
2217
2247
  }
2248
+ var statementCache = /* @__PURE__ */ new WeakMap();
2249
+ function prepareCached(db, sql) {
2250
+ let cache = statementCache.get(db);
2251
+ if (!cache) {
2252
+ cache = /* @__PURE__ */ new Map();
2253
+ statementCache.set(db, cache);
2254
+ }
2255
+ let stmt = cache.get(sql);
2256
+ if (!stmt) {
2257
+ stmt = db.prepare(sql);
2258
+ cache.set(sql, stmt);
2259
+ }
2260
+ return stmt;
2261
+ }
2218
2262
  function sqliteRun(db, sql, params = []) {
2219
- const stmt = db.prepare(sql);
2220
- return stmt.run(...params);
2263
+ return prepareCached(db, sql).run(...params);
2221
2264
  }
2222
2265
  function sqliteAll(db, sql, params = []) {
2223
- const stmt = db.prepare(sql);
2224
- return stmt.all(...params);
2266
+ return prepareCached(db, sql).all(...params);
2225
2267
  }
2226
2268
  function sqliteGet(db, sql, params = []) {
2227
- const stmt = db.prepare(sql);
2228
- return stmt.get(...params);
2269
+ return prepareCached(db, sql).get(...params);
2229
2270
  }
2230
2271
  function sqliteExec(db, sql) {
2231
2272
  db.exec(sql);
@@ -2647,6 +2688,7 @@ function normalizeQueryRewriteKind(value) {
2647
2688
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2648
2689
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2649
2690
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2691
+ var SQLITE_SCHEMA_VERSION = 1;
2650
2692
  function emptyOutboxRecoveryResult() {
2651
2693
  return {
2652
2694
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -2991,6 +3033,15 @@ var SQLiteEventStore = class {
2991
3033
  access_count INTEGER DEFAULT 0
2992
3034
  );
2993
3035
 
3036
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3037
+ -- replaces per-event source_events LIKE scans during consolidation).
3038
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3039
+ memory_id TEXT NOT NULL,
3040
+ event_id TEXT NOT NULL,
3041
+ PRIMARY KEY (memory_id, event_id)
3042
+ );
3043
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3044
+
2994
3045
  -- Continuity Log table (tracks context transitions)
2995
3046
  CREATE TABLE IF NOT EXISTS continuity_log (
2996
3047
  log_id TEXT PRIMARY KEY,
@@ -3350,11 +3401,17 @@ var SQLiteEventStore = class {
3350
3401
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3351
3402
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3352
3403
  END;
3404
+
3405
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3406
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3407
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3408
+ CREATE TABLE IF NOT EXISTS event_citations (
3409
+ event_id TEXT PRIMARY KEY,
3410
+ citation_id TEXT NOT NULL
3411
+ );
3412
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3353
3413
  `);
3354
- try {
3355
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3356
- } catch {
3357
- }
3414
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3358
3415
  try {
3359
3416
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3360
3417
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3391,55 +3448,17 @@ var SQLiteEventStore = class {
3391
3448
  }
3392
3449
  } catch {
3393
3450
  }
3394
- try {
3395
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3396
- } catch {
3397
- }
3398
- try {
3399
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3400
- } catch {
3401
- }
3402
- try {
3403
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3404
- } catch {
3405
- }
3406
- try {
3407
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3408
- } catch {
3409
- }
3451
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3452
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3453
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3454
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3410
3455
  try {
3411
3456
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3412
3457
  } catch {
3413
3458
  }
3414
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3415
- const columnNames = tableInfo.map((col) => col.name);
3416
- if (!columnNames.includes("access_count")) {
3417
- try {
3418
- sqliteExec(this.db, `
3419
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3420
- `);
3421
- } catch (err) {
3422
- console.error("Error adding access_count column:", err);
3423
- }
3424
- }
3425
- if (!columnNames.includes("last_accessed_at")) {
3426
- try {
3427
- sqliteExec(this.db, `
3428
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3429
- `);
3430
- } catch (err) {
3431
- console.error("Error adding last_accessed_at column:", err);
3432
- }
3433
- }
3434
- if (!columnNames.includes("turn_id")) {
3435
- try {
3436
- sqliteExec(this.db, `
3437
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3438
- `);
3439
- } catch (err) {
3440
- console.error("Error adding turn_id column:", err);
3441
- }
3442
- }
3459
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3460
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3461
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3443
3462
  try {
3444
3463
  sqliteExec(this.db, `
3445
3464
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3456,8 +3475,18 @@ var SQLiteEventStore = class {
3456
3475
  sqliteExec(this.db, `
3457
3476
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3458
3477
  `);
3459
- } catch (err) {
3478
+ } catch {
3479
+ }
3480
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3481
+ if ((cmeCount?.c ?? 0) === 0) {
3482
+ sqliteExec(this.db, `
3483
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3484
+ SELECT cm.memory_id, je.value
3485
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3486
+ WHERE json_valid(cm.source_events);
3487
+ `);
3460
3488
  }
3489
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3461
3490
  this.initialized = true;
3462
3491
  }
3463
3492
  /**
@@ -3589,6 +3618,67 @@ var SQLiteEventStore = class {
3589
3618
  );
3590
3619
  return rows.map(this.rowToEvent);
3591
3620
  }
3621
+ /**
3622
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3623
+ * into memory and counting in JS. Counts all events (the old recent-window
3624
+ * scan also under-counted stores larger than its cap).
3625
+ */
3626
+ async getEventTypeCounts(options) {
3627
+ await this.initialize();
3628
+ const rows = sqliteAll(
3629
+ this.db,
3630
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3631
+ []
3632
+ );
3633
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3634
+ }
3635
+ /**
3636
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3637
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3638
+ * the window needs instead of an arbitrary "most recent N" slice that both
3639
+ * over-fetches sparse windows and truncates very active ones.
3640
+ */
3641
+ async getEventsAfter(sinceIso, options) {
3642
+ await this.initialize();
3643
+ const rows = sqliteAll(
3644
+ this.db,
3645
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3646
+ [sinceIso]
3647
+ );
3648
+ return rows.map((row) => this.rowToEvent(row));
3649
+ }
3650
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3651
+ async getDistinctSessionCount(options) {
3652
+ await this.initialize();
3653
+ const row = sqliteGet(
3654
+ this.db,
3655
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3656
+ []
3657
+ );
3658
+ return row?.count ?? 0;
3659
+ }
3660
+ /**
3661
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3662
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3663
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3664
+ * lexicographically.
3665
+ */
3666
+ async getDailyEventCounts(sinceIso, options) {
3667
+ await this.initialize();
3668
+ return sqliteAll(
3669
+ this.db,
3670
+ `SELECT substr(timestamp, 1, 10) as day,
3671
+ COUNT(*) as total,
3672
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3673
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3674
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3675
+ FROM events
3676
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3677
+ GROUP BY day
3678
+ ORDER BY day ASC`,
3679
+ [sinceIso]
3680
+ );
3681
+ }
3592
3682
  /**
3593
3683
  * Get event by ID
3594
3684
  */
@@ -3602,6 +3692,71 @@ var SQLiteEventStore = class {
3602
3692
  if (!row) return null;
3603
3693
  return this.rowToEvent(row);
3604
3694
  }
3695
+ /**
3696
+ * Batch-fetch events by id in a single query, applying the same quarantine
3697
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3698
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3699
+ */
3700
+ async getEvents(ids, options) {
3701
+ await this.initialize();
3702
+ if (ids.length === 0) return [];
3703
+ const placeholders = ids.map(() => "?").join(",");
3704
+ const rows = sqliteAll(
3705
+ this.db,
3706
+ `SELECT * FROM events WHERE id IN (${placeholders}) AND ${maybeQuarantinePredicate(options)}`,
3707
+ ids
3708
+ );
3709
+ return rows.map((row) => this.rowToEvent(row));
3710
+ }
3711
+ /**
3712
+ * Resolve an event from its citation id via the reverse index, instead of
3713
+ * scanning recent events and hashing each one. The index is self-healing: a
3714
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3715
+ * freshly appended events) before retrying.
3716
+ */
3717
+ async getEventByCitationId(citationId, options) {
3718
+ await this.initialize();
3719
+ if (!citationId) return null;
3720
+ let row = sqliteGet(
3721
+ this.db,
3722
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3723
+ [citationId]
3724
+ );
3725
+ if (!row) {
3726
+ this.indexMissingCitations();
3727
+ row = sqliteGet(
3728
+ this.db,
3729
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3730
+ [citationId]
3731
+ );
3732
+ }
3733
+ if (!row) return null;
3734
+ return this.getEvent(row.event_id, options);
3735
+ }
3736
+ /**
3737
+ * Backfill citation ids for any events not yet present in event_citations.
3738
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3739
+ * work, and the scan only runs when there are genuinely new events to index.
3740
+ */
3741
+ indexMissingCitations() {
3742
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3743
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3744
+ if (indexedCount >= eventCount) return;
3745
+ const unindexed = sqliteAll(
3746
+ this.db,
3747
+ `SELECT e.id FROM events e
3748
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3749
+ WHERE ec.event_id IS NULL`,
3750
+ []
3751
+ );
3752
+ if (unindexed.length === 0) return;
3753
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3754
+ this.db.transaction(() => {
3755
+ for (const { id } of unindexed) {
3756
+ insert.run(id, generateCitationId(id));
3757
+ }
3758
+ })();
3759
+ }
3605
3760
  /**
3606
3761
  * Get events since a timestamp (for sync)
3607
3762
  */
@@ -3803,25 +3958,22 @@ var SQLiteEventStore = class {
3803
3958
  */
3804
3959
  async getPendingOutboxItems(limit = 32) {
3805
3960
  await this.initialize();
3806
- const pending = sqliteAll(
3807
- this.db,
3808
- `SELECT * FROM embedding_outbox
3809
- WHERE status = 'pending'
3810
- ORDER BY created_at
3811
- LIMIT ?`,
3812
- [limit]
3813
- );
3814
- if (pending.length === 0) return [];
3815
- const ids = pending.map((r) => r.id);
3816
- const placeholders = ids.map(() => "?").join(",");
3817
- sqliteRun(
3961
+ const claimed = sqliteAll(
3818
3962
  this.db,
3819
3963
  `UPDATE embedding_outbox
3820
3964
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3821
- WHERE id IN (${placeholders})`,
3822
- ids
3965
+ WHERE id IN (
3966
+ SELECT id FROM embedding_outbox
3967
+ WHERE status = 'pending'
3968
+ ORDER BY created_at
3969
+ LIMIT ?
3970
+ )
3971
+ RETURNING *`,
3972
+ [limit]
3823
3973
  );
3824
- return pending.map((row) => ({
3974
+ if (claimed.length === 0) return [];
3975
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
3976
+ return claimed.map((row) => ({
3825
3977
  id: row.id,
3826
3978
  eventId: row.event_id,
3827
3979
  content: row.content,
@@ -4425,7 +4577,7 @@ var SQLiteEventStore = class {
4425
4577
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4426
4578
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4427
4579
  let toolSuccessCount = 0;
4428
- let toolTotalCount = toolEvents.length;
4580
+ const toolTotalCount = toolEvents.length;
4429
4581
  for (const t of toolEvents) {
4430
4582
  try {
4431
4583
  const content = JSON.parse(t.content);
@@ -4588,10 +4740,14 @@ var SQLiteEventStore = class {
4588
4740
  * Rebuild FTS index from existing events
4589
4741
  * Call this once after upgrading to FTS5
4590
4742
  */
4591
- async rebuildFtsIndex() {
4592
- await this.initialize();
4593
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4594
- const totalEvents = countRow?.count ?? 0;
4743
+ /**
4744
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4745
+ * from the current events table. Recreating the table (instead of issuing
4746
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4747
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4748
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4749
+ */
4750
+ recreateEventsFtsTable() {
4595
4751
  sqliteExec(this.db, `
4596
4752
  DROP TRIGGER IF EXISTS events_fts_insert;
4597
4753
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4620,6 +4776,12 @@ var SQLiteEventStore = class {
4620
4776
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4621
4777
  END;
4622
4778
  `);
4779
+ }
4780
+ async rebuildFtsIndex() {
4781
+ await this.initialize();
4782
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4783
+ const totalEvents = countRow?.count ?? 0;
4784
+ this.recreateEventsFtsTable();
4623
4785
  return totalEvents;
4624
4786
  }
4625
4787
  /**
@@ -4637,6 +4799,35 @@ var SQLiteEventStore = class {
4637
4799
  return false;
4638
4800
  }
4639
4801
  }
4802
+ hasTable(tableName) {
4803
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4804
+ try {
4805
+ const rows = sqliteAll(
4806
+ this.db,
4807
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4808
+ [tableName]
4809
+ );
4810
+ return rows.length > 0;
4811
+ } catch {
4812
+ return false;
4813
+ }
4814
+ }
4815
+ /**
4816
+ * Add a column only when its table exists and the column is missing.
4817
+ *
4818
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4819
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4820
+ * failure (disk full, corruption), which is allowed to surface instead of
4821
+ * being silently swallowed as if the migration had succeeded.
4822
+ */
4823
+ addColumnIfMissing(table, column, definition) {
4824
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4825
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4826
+ }
4827
+ if (!this.hasTable(table)) return;
4828
+ if (this.hasTableColumn(table, column)) return;
4829
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4830
+ }
4640
4831
  async recordRetrievalTrace(input) {
4641
4832
  await this.initialize();
4642
4833
  const traceId = randomUUID6();
@@ -4722,6 +4913,45 @@ var SQLiteEventStore = class {
4722
4913
  FROM retrieval_traces`,
4723
4914
  []
4724
4915
  );
4916
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
4917
+ const strategyRows = sqliteAll(
4918
+ this.db,
4919
+ `SELECT
4920
+ ${strategyColumnSql} as strategy,
4921
+ COUNT(*) as total_queries,
4922
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
4923
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
4924
+ SUM(candidate_count) as total_candidate_count,
4925
+ SUM(selected_count) as total_selected_count,
4926
+ AVG(candidate_count) as avg_candidate_count,
4927
+ AVG(selected_count) as avg_selected_count,
4928
+ CASE
4929
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
4930
+ ELSE 0
4931
+ END as selection_rate
4932
+ FROM retrieval_traces
4933
+ GROUP BY ${strategyColumnSql}
4934
+ ORDER BY total_queries DESC, strategy ASC`,
4935
+ []
4936
+ );
4937
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
4938
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
4939
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
4940
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
4941
+ return {
4942
+ strategy: String(strategyRow.strategy || "unknown"),
4943
+ totalQueries: strategyTotalQueries,
4944
+ queriesWithSelection: strategyQueriesWithSelection,
4945
+ rewrittenQueries: strategyRewrittenQueries,
4946
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
4947
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
4948
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
4949
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
4950
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
4951
+ selectionRate: Number(strategyRow.selection_rate || 0),
4952
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
4953
+ };
4954
+ });
4725
4955
  const totalQueries = Number(row?.total_queries || 0);
4726
4956
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4727
4957
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4739,7 +4969,8 @@ var SQLiteEventStore = class {
4739
4969
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4740
4970
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4741
4971
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4742
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
4972
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
4973
+ strategyBreakdown
4743
4974
  };
4744
4975
  } catch (err) {
4745
4976
  if (err?.message?.includes("no such table")) {
@@ -4755,7 +4986,8 @@ var SQLiteEventStore = class {
4755
4986
  rewrittenSelectionRate: 0,
4756
4987
  rawSelectionRate: 0,
4757
4988
  avgSelectedCountForRewrittenQueries: 0,
4758
- avgSelectedCountForRawQueries: 0
4989
+ avgSelectedCountForRawQueries: 0,
4990
+ strategyBreakdown: []
4759
4991
  };
4760
4992
  }
4761
4993
  throw err;
@@ -4872,38 +5104,19 @@ var SQLiteEventStore = class {
4872
5104
  if (events.length === 0) return 0;
4873
5105
  const eventIds = events.map((e) => e.id);
4874
5106
  const placeholders = eventIds.map(() => "?").join(",");
4875
- const ftsTriggersDropped = [];
4876
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4877
- try {
5107
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5108
+ const runDelete = this.db.transaction(() => {
5109
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4878
5110
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4879
- ftsTriggersDropped.push(triggerName);
4880
- } catch {
4881
5111
  }
4882
- }
4883
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
4884
- try {
5112
+ for (const table of relatedTables) {
4885
5113
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
4886
- } catch {
4887
- }
4888
- }
4889
- const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
4890
- if (ftsTriggersDropped.length > 0) {
4891
- try {
4892
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
4893
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
4894
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4895
- END`);
4896
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
4897
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4898
- END`);
4899
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
4900
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
4901
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4902
- END`);
4903
- } catch {
4904
5114
  }
4905
- }
4906
- return result.changes || 0;
5115
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5116
+ this.recreateEventsFtsTable();
5117
+ return result.changes || 0;
5118
+ });
5119
+ return runDelete();
4907
5120
  }
4908
5121
  /**
4909
5122
  * Convert database row to MemoryEvent
@@ -7416,7 +7629,7 @@ var SessionActorRepository = class {
7416
7629
  };
7417
7630
 
7418
7631
  // src/core/operations/perspective-observation-repository.ts
7419
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
7632
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
7420
7633
  function projectHashToStorage4(projectHash) {
7421
7634
  return projectHash ?? "";
7422
7635
  }
@@ -7453,7 +7666,7 @@ function sanitizeStoredRecord(value) {
7453
7666
  return sanitizeGovernanceAuditValue(value);
7454
7667
  }
7455
7668
  function stableHash(value) {
7456
- return createHash3("sha256").update(JSON.stringify(value)).digest("hex");
7669
+ return createHash4("sha256").update(JSON.stringify(value)).digest("hex");
7457
7670
  }
7458
7671
  function evidenceHash(sourceEventIds, sourceObservationIds) {
7459
7672
  return stableHash({
@@ -8568,6 +8781,30 @@ var MemoryQueryService = class {
8568
8781
  score: 1 - (r.rank - minRank) / rankRange
8569
8782
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
8570
8783
  }
8784
+ async getEvent(id) {
8785
+ await this.initialize();
8786
+ return this.queryStore.getEvent(id);
8787
+ }
8788
+ async getEventByCitationId(citationId) {
8789
+ await this.initialize();
8790
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
8791
+ }
8792
+ async getEventsAfter(sinceIso) {
8793
+ await this.initialize();
8794
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
8795
+ }
8796
+ async getEventTypeCounts() {
8797
+ await this.initialize();
8798
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
8799
+ }
8800
+ async getDistinctSessionCount() {
8801
+ await this.initialize();
8802
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
8803
+ }
8804
+ async getDailyEventCounts(sinceIso) {
8805
+ await this.initialize();
8806
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
8807
+ }
8571
8808
  async getSessionHistory(sessionId) {
8572
8809
  await this.initialize();
8573
8810
  return this.queryStore.getSessionEvents(sessionId);
@@ -9536,11 +9773,12 @@ var Retriever = class {
9536
9773
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
9537
9774
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
9538
9775
  const filtered = [];
9776
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9539
9777
  for (const result of results) {
9540
9778
  if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
9541
9779
  if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
9542
9780
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType)) continue;
9543
- const event = await this.eventStore.getEvent(result.eventId);
9781
+ const event = eventMap.get(result.eventId);
9544
9782
  if (!event) continue;
9545
9783
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
9546
9784
  if (normalizedIncludes.length > 0) {
@@ -9649,10 +9887,30 @@ var Retriever = class {
9649
9887
  async retrieveRecent(limit = 100) {
9650
9888
  return this.eventStore.getRecentEvents(limit);
9651
9889
  }
9890
+ /**
9891
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
9892
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
9893
+ */
9894
+ async fetchEventsByIds(ids) {
9895
+ const map = /* @__PURE__ */ new Map();
9896
+ if (ids.length === 0) return map;
9897
+ const unique = [...new Set(ids)];
9898
+ if (typeof this.eventStore.getEvents === "function") {
9899
+ const events = await this.eventStore.getEvents(unique);
9900
+ for (const event of events) map.set(event.id, event);
9901
+ } else {
9902
+ const events = await Promise.all(unique.map((id) => this.eventStore.getEvent(id)));
9903
+ for (const event of events) {
9904
+ if (event) map.set(event.id, event);
9905
+ }
9906
+ }
9907
+ return map;
9908
+ }
9652
9909
  async enrichResults(results, options, query) {
9653
9910
  const memories = [];
9911
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9654
9912
  for (const result of results) {
9655
- const event = await this.eventStore.getEvent(result.eventId);
9913
+ const event = eventMap.get(result.eventId);
9656
9914
  if (!event) continue;
9657
9915
  if (this.graduation) {
9658
9916
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result.score);
@@ -12380,6 +12638,34 @@ var MemoryService = class {
12380
12638
  async rebuildFtsIndex() {
12381
12639
  return this.queryService.rebuildFtsIndex();
12382
12640
  }
12641
+ /**
12642
+ * Get a single event by id (indexed lookup).
12643
+ */
12644
+ async getEvent(id) {
12645
+ return this.queryService.getEvent(id);
12646
+ }
12647
+ /**
12648
+ * Resolve an event from its citation id via the reverse index.
12649
+ */
12650
+ async getEventByCitationId(citationId) {
12651
+ return this.queryService.getEventByCitationId(citationId);
12652
+ }
12653
+ /** All events at/after an ISO timestamp (ascending, uncapped). */
12654
+ async getEventsAfter(sinceIso) {
12655
+ return this.queryService.getEventsAfter(sinceIso);
12656
+ }
12657
+ /** Aggregate event counts by type (SQL GROUP BY). */
12658
+ async getEventTypeCounts() {
12659
+ return this.queryService.getEventTypeCounts();
12660
+ }
12661
+ /** Count distinct sessions (SQL). */
12662
+ async getDistinctSessionCount() {
12663
+ return this.queryService.getDistinctSessionCount();
12664
+ }
12665
+ /** Per-day event counts with type breakdown since an ISO timestamp (SQL). */
12666
+ async getDailyEventCounts(sinceIso) {
12667
+ return this.queryService.getDailyEventCounts(sinceIso);
12668
+ }
12383
12669
  /**
12384
12670
  * Get session history
12385
12671
  */
@@ -12816,6 +13102,75 @@ async function extractAssistantMessages(transcriptPath, options) {
12816
13102
  return extractAssistantTextMessages(entries);
12817
13103
  }
12818
13104
 
13105
+ // src/adapters/claude/hooks/hook-runtime.ts
13106
+ var DEFAULT_STDIN_TIMEOUT_MS = 1e4;
13107
+ var DEFAULT_OVERALL_TIMEOUT_MS = 3e4;
13108
+ function readNumberEnv(name, fallback, options = {}) {
13109
+ const raw = process.env[name];
13110
+ if (raw === void 0 || raw.trim() === "") return fallback;
13111
+ const parsed = options.integer ? Number.parseInt(raw, 10) : Number.parseFloat(raw);
13112
+ if (!Number.isFinite(parsed)) return fallback;
13113
+ let value = parsed;
13114
+ if (options.min !== void 0) value = Math.max(options.min, value);
13115
+ if (options.max !== void 0) value = Math.min(options.max, value);
13116
+ return value;
13117
+ }
13118
+ function readPositiveIntEnv(name, fallback) {
13119
+ return readNumberEnv(name, fallback, { integer: true, min: 1 });
13120
+ }
13121
+ function readStdin(options = {}) {
13122
+ const timeoutMs = options.timeoutMs ?? readPositiveIntEnv("CLAUDE_MEMORY_HOOK_STDIN_TIMEOUT_MS", DEFAULT_STDIN_TIMEOUT_MS);
13123
+ return new Promise((resolve2) => {
13124
+ const stdin = process.stdin;
13125
+ let data = "";
13126
+ let settled = false;
13127
+ const onData = (chunk) => {
13128
+ data += chunk;
13129
+ };
13130
+ const finish = () => {
13131
+ if (settled) return;
13132
+ settled = true;
13133
+ clearTimeout(timer);
13134
+ stdin.off("data", onData);
13135
+ stdin.off("end", finish);
13136
+ stdin.off("error", finish);
13137
+ resolve2(data);
13138
+ };
13139
+ const timer = setTimeout(finish, timeoutMs);
13140
+ timer.unref?.();
13141
+ stdin.setEncoding("utf8");
13142
+ stdin.on("data", onData);
13143
+ stdin.on("end", finish);
13144
+ stdin.on("error", finish);
13145
+ });
13146
+ }
13147
+ function emitHookOutput(json) {
13148
+ process.stdout.write(json.endsWith("\n") ? json : `${json}
13149
+ `);
13150
+ }
13151
+ async function runHook(options, run) {
13152
+ const debug = Boolean(process.env.CLAUDE_MEMORY_DEBUG);
13153
+ const timeoutMs = options.timeoutMs ?? readPositiveIntEnv("CLAUDE_MEMORY_HOOK_TIMEOUT_MS", DEFAULT_OVERALL_TIMEOUT_MS);
13154
+ let emitted = false;
13155
+ const emit = (json) => {
13156
+ if (emitted) return;
13157
+ emitted = true;
13158
+ emitHookOutput(json);
13159
+ };
13160
+ const watchdog = setTimeout(() => {
13161
+ if (debug) console.error(`[${options.name}] hook timed out after ${timeoutMs}ms; forcing exit`);
13162
+ emit(options.fallbackOutput);
13163
+ process.exit(0);
13164
+ }, timeoutMs);
13165
+ watchdog.unref?.();
13166
+ try {
13167
+ emit(await run());
13168
+ } catch (error) {
13169
+ if (debug) console.error(`[${options.name}] hook error:`, error);
13170
+ emit(options.fallbackOutput);
13171
+ }
13172
+ }
13173
+
12819
13174
  // src/adapters/claude/hooks/stop.ts
12820
13175
  var DEFAULT_PRIVACY_CONFIG = {
12821
13176
  excludePatterns: ["password", "secret", "api_key", "token", "bearer"],
@@ -12828,15 +13183,12 @@ var DEFAULT_PRIVACY_CONFIG = {
12828
13183
  }
12829
13184
  };
12830
13185
  async function main() {
12831
- const inputData = await readStdin();
12832
- const input = JSON.parse(inputData);
12833
- const memoryService = getLightweightMemoryService(input.session_id);
12834
13186
  try {
13187
+ const input = JSON.parse(await readStdin());
13188
+ const memoryService = getLightweightMemoryService(input.session_id);
12835
13189
  const turnId = readTurnState(input.session_id);
12836
13190
  const assistantMessages = await extractAssistantMessages(input.transcript_path);
12837
- const MIN_AGENT_RESPONSE_LEN = parseInt(
12838
- process.env.CLAUDE_MEMORY_AGENT_RESPONSE_MIN_LEN || "150"
12839
- );
13191
+ const MIN_AGENT_RESPONSE_LEN = readNumberEnv("CLAUDE_MEMORY_AGENT_RESPONSE_MIN_LEN", 150, { integer: true, min: 0 });
12840
13192
  const lastIdx = assistantMessages.length - 1;
12841
13193
  for (let i = 0; i < assistantMessages.length; i++) {
12842
13194
  const text = assistantMessages[i];
@@ -12870,27 +13222,15 @@ async function main() {
12870
13222
  } catch {
12871
13223
  }
12872
13224
  await memoryService.processPendingEmbeddings();
12873
- console.log(JSON.stringify({}));
13225
+ return JSON.stringify({});
12874
13226
  } catch (error) {
12875
13227
  if (process.env.CLAUDE_MEMORY_DEBUG) {
12876
13228
  console.error("Stop hook error:", error);
12877
13229
  }
12878
- console.log(JSON.stringify({}));
13230
+ return JSON.stringify({});
12879
13231
  }
12880
13232
  }
12881
- function readStdin() {
12882
- return new Promise((resolve2) => {
12883
- let data = "";
12884
- process.stdin.setEncoding("utf8");
12885
- process.stdin.on("data", (chunk) => {
12886
- data += chunk;
12887
- });
12888
- process.stdin.on("end", () => {
12889
- resolve2(data);
12890
- });
12891
- });
12892
- }
12893
13233
 
12894
13234
  // src/hooks/stop.ts
12895
- main().catch(console.error);
13235
+ void runHook({ name: "stop", fallbackOutput: "{}" }, main);
12896
13236
  //# sourceMappingURL=stop.js.map