claude-memory-layer 1.0.53 → 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.
@@ -276,7 +276,13 @@ function toDate(value) {
276
276
  return new Date(String(value));
277
277
  }
278
278
  function createDatabase(dbPath, options) {
279
- return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
279
+ const db = new BetterSqlite3(dbPath, { readonly: options?.readOnly });
280
+ db.pragma("busy_timeout = 5000");
281
+ if (!options?.readOnly) {
282
+ db.pragma("journal_mode = WAL");
283
+ db.pragma("synchronous = NORMAL");
284
+ }
285
+ return db;
280
286
  }
281
287
  function dbRun(db, sql, params = []) {
282
288
  db.prepare(sql).run(...params);
@@ -509,19 +515,29 @@ var ConsolidatedStore = class {
509
515
  */
510
516
  async create(input) {
511
517
  const memoryId = randomUUID2();
512
- await dbRun(
513
- this.db,
518
+ const sourceEvents = Array.isArray(input.sourceEvents) ? input.sourceEvents : [];
519
+ const insertMemory = this.db.prepare(
514
520
  `INSERT INTO consolidated_memories
515
521
  (memory_id, summary, topics, source_events, confidence, created_at)
516
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
517
- [
522
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
523
+ );
524
+ const insertEvent = this.db.prepare(
525
+ `INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id) VALUES (?, ?)`
526
+ );
527
+ this.db.transaction(() => {
528
+ insertMemory.run(
518
529
  memoryId,
519
530
  input.summary,
520
531
  JSON.stringify(input.topics),
521
- JSON.stringify(input.sourceEvents),
532
+ JSON.stringify(sourceEvents),
522
533
  input.confidence
523
- ]
524
- );
534
+ );
535
+ for (const eventId of sourceEvents) {
536
+ if (typeof eventId === "string" && eventId.length > 0) {
537
+ insertEvent.run(memoryId, eventId);
538
+ }
539
+ }
540
+ })();
525
541
  return memoryId;
526
542
  }
527
543
  /**
@@ -627,11 +643,12 @@ var ConsolidatedStore = class {
627
643
  * Delete a consolidated memory
628
644
  */
629
645
  async delete(memoryId) {
630
- await dbRun(
631
- this.db,
632
- `DELETE FROM consolidated_memories WHERE memory_id = ?`,
633
- [memoryId]
634
- );
646
+ const deleteEvents = this.db.prepare(`DELETE FROM consolidated_memory_events WHERE memory_id = ?`);
647
+ const deleteMemory = this.db.prepare(`DELETE FROM consolidated_memories WHERE memory_id = ?`);
648
+ this.db.transaction(() => {
649
+ deleteEvents.run(memoryId);
650
+ deleteMemory.run(memoryId);
651
+ })();
635
652
  }
636
653
  /**
637
654
  * Create a long-term rule promoted from stable summaries
@@ -744,16 +761,16 @@ var ConsolidatedStore = class {
744
761
  * Check if source events are already consolidated
745
762
  */
746
763
  async isAlreadyConsolidated(eventIds) {
747
- for (const eventId of eventIds) {
748
- const result = await dbAll(
749
- this.db,
750
- `SELECT COUNT(*) as count FROM consolidated_memories
751
- WHERE source_events LIKE ?`,
752
- [`%"${eventId}"%`]
753
- );
754
- if ((result[0]?.count || 0) > 0) return true;
755
- }
756
- return false;
764
+ if (eventIds.length === 0) return false;
765
+ const placeholders = eventIds.map(() => "?").join(",");
766
+ const rows = await dbAll(
767
+ this.db,
768
+ `SELECT memory_id FROM consolidated_memory_events
769
+ WHERE event_id IN (${placeholders})
770
+ LIMIT 1`,
771
+ eventIds
772
+ );
773
+ return rows.length > 0;
757
774
  }
758
775
  /**
759
776
  * Get the last consolidation time
@@ -2289,6 +2306,19 @@ function makeDedupeKey(content, sessionId) {
2289
2306
  return `${sessionId}:${contentHash}`;
2290
2307
  }
2291
2308
 
2309
+ // src/core/citation-generator.ts
2310
+ import { createHash as createHash2 } from "crypto";
2311
+ var ID_LENGTH = 6;
2312
+ var CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2313
+ function generateCitationId(eventId) {
2314
+ const hash = createHash2("sha256").update(eventId).digest();
2315
+ let id = "";
2316
+ for (let i = 0; i < ID_LENGTH; i++) {
2317
+ id += CHARSET[hash[i] % CHARSET.length];
2318
+ }
2319
+ return id;
2320
+ }
2321
+
2292
2322
  // src/core/sqlite-event-store.ts
2293
2323
  import * as nodePath2 from "path";
2294
2324
 
@@ -2333,17 +2363,28 @@ function createSQLiteDatabase(path12, options) {
2333
2363
  }
2334
2364
  return db;
2335
2365
  }
2366
+ var statementCache = /* @__PURE__ */ new WeakMap();
2367
+ function prepareCached(db, sql) {
2368
+ let cache = statementCache.get(db);
2369
+ if (!cache) {
2370
+ cache = /* @__PURE__ */ new Map();
2371
+ statementCache.set(db, cache);
2372
+ }
2373
+ let stmt = cache.get(sql);
2374
+ if (!stmt) {
2375
+ stmt = db.prepare(sql);
2376
+ cache.set(sql, stmt);
2377
+ }
2378
+ return stmt;
2379
+ }
2336
2380
  function sqliteRun(db, sql, params = []) {
2337
- const stmt = db.prepare(sql);
2338
- return stmt.run(...params);
2381
+ return prepareCached(db, sql).run(...params);
2339
2382
  }
2340
2383
  function sqliteAll(db, sql, params = []) {
2341
- const stmt = db.prepare(sql);
2342
- return stmt.all(...params);
2384
+ return prepareCached(db, sql).all(...params);
2343
2385
  }
2344
2386
  function sqliteGet(db, sql, params = []) {
2345
- const stmt = db.prepare(sql);
2346
- return stmt.get(...params);
2387
+ return prepareCached(db, sql).get(...params);
2347
2388
  }
2348
2389
  function sqliteExec(db, sql) {
2349
2390
  db.exec(sql);
@@ -2765,6 +2806,7 @@ function normalizeQueryRewriteKind(value) {
2765
2806
  var REWRITTEN_QUERY_REWRITE_KIND_SQL = `LOWER(TRIM(COALESCE(query_rewrite_kind, 'none'))) IN ('follow-up-context', 'intent-rewrite')`;
2766
2807
  var DEFAULT_OUTBOX_STUCK_THRESHOLD_MS = 5 * 60 * 1e3;
2767
2808
  var DEFAULT_OUTBOX_MAX_RETRIES = 3;
2809
+ var SQLITE_SCHEMA_VERSION = 1;
2768
2810
  function emptyOutboxRecoveryResult() {
2769
2811
  return {
2770
2812
  embedding: { recoveredProcessing: 0, retriedFailed: 0 },
@@ -3109,6 +3151,15 @@ var SQLiteEventStore = class {
3109
3151
  access_count INTEGER DEFAULT 0
3110
3152
  );
3111
3153
 
3154
+ -- Junction: consolidated memory -> source event id (indexed lookup that
3155
+ -- replaces per-event source_events LIKE scans during consolidation).
3156
+ CREATE TABLE IF NOT EXISTS consolidated_memory_events (
3157
+ memory_id TEXT NOT NULL,
3158
+ event_id TEXT NOT NULL,
3159
+ PRIMARY KEY (memory_id, event_id)
3160
+ );
3161
+ CREATE INDEX IF NOT EXISTS idx_cme_event ON consolidated_memory_events(event_id);
3162
+
3112
3163
  -- Continuity Log table (tracks context transitions)
3113
3164
  CREATE TABLE IF NOT EXISTS continuity_log (
3114
3165
  log_id TEXT PRIMARY KEY,
@@ -3468,11 +3519,17 @@ var SQLiteEventStore = class {
3468
3519
  DELETE FROM events_fts WHERE rowid = OLD.rowid;
3469
3520
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
3470
3521
  END;
3522
+
3523
+ -- Reverse index for citation ids (a non-reversible hash of event id), so
3524
+ -- citation lookups are O(1) instead of scanning recent events and hashing
3525
+ -- each one. Populated lazily/self-healing by getEventByCitationId.
3526
+ CREATE TABLE IF NOT EXISTS event_citations (
3527
+ event_id TEXT PRIMARY KEY,
3528
+ citation_id TEXT NOT NULL
3529
+ );
3530
+ CREATE INDEX IF NOT EXISTS idx_event_citations_citation ON event_citations(citation_id);
3471
3531
  `);
3472
- try {
3473
- sqliteExec(this.db, `ALTER TABLE memory_action_edges ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';`);
3474
- } catch {
3475
- }
3532
+ this.addColumnIfMissing("memory_action_edges", "source", `TEXT NOT NULL DEFAULT 'manual'`);
3476
3533
  try {
3477
3534
  const edgeIndexes = sqliteAll(this.db, `PRAGMA index_list(memory_action_edges)`, []);
3478
3535
  const hasSourceAwareUnique = edgeIndexes.some((index) => {
@@ -3509,55 +3566,17 @@ var SQLiteEventStore = class {
3509
3566
  }
3510
3567
  } catch {
3511
3568
  }
3512
- try {
3513
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
3514
- } catch {
3515
- }
3516
- try {
3517
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
3518
- } catch {
3519
- }
3520
- try {
3521
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN raw_query_text TEXT;`);
3522
- } catch {
3523
- }
3524
- try {
3525
- sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN query_rewrite_kind TEXT;`);
3526
- } catch {
3527
- }
3569
+ this.addColumnIfMissing("retrieval_traces", "selected_details_json", "TEXT");
3570
+ this.addColumnIfMissing("retrieval_traces", "candidate_details_json", "TEXT");
3571
+ this.addColumnIfMissing("retrieval_traces", "raw_query_text", "TEXT");
3572
+ this.addColumnIfMissing("retrieval_traces", "query_rewrite_kind", "TEXT");
3528
3573
  try {
3529
3574
  sqliteExec(this.db, `CREATE INDEX IF NOT EXISTS idx_retrieval_traces_query_rewrite_kind ON retrieval_traces(query_rewrite_kind);`);
3530
3575
  } catch {
3531
3576
  }
3532
- const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
3533
- const columnNames = tableInfo.map((col) => col.name);
3534
- if (!columnNames.includes("access_count")) {
3535
- try {
3536
- sqliteExec(this.db, `
3537
- ALTER TABLE events ADD COLUMN access_count INTEGER DEFAULT 0;
3538
- `);
3539
- } catch (err) {
3540
- console.error("Error adding access_count column:", err);
3541
- }
3542
- }
3543
- if (!columnNames.includes("last_accessed_at")) {
3544
- try {
3545
- sqliteExec(this.db, `
3546
- ALTER TABLE events ADD COLUMN last_accessed_at TEXT;
3547
- `);
3548
- } catch (err) {
3549
- console.error("Error adding last_accessed_at column:", err);
3550
- }
3551
- }
3552
- if (!columnNames.includes("turn_id")) {
3553
- try {
3554
- sqliteExec(this.db, `
3555
- ALTER TABLE events ADD COLUMN turn_id TEXT;
3556
- `);
3557
- } catch (err) {
3558
- console.error("Error adding turn_id column:", err);
3559
- }
3560
- }
3577
+ this.addColumnIfMissing("events", "access_count", "INTEGER DEFAULT 0");
3578
+ this.addColumnIfMissing("events", "last_accessed_at", "TEXT");
3579
+ this.addColumnIfMissing("events", "turn_id", "TEXT");
3561
3580
  try {
3562
3581
  sqliteExec(this.db, `
3563
3582
  CREATE INDEX IF NOT EXISTS idx_events_access_count ON events(access_count DESC);
@@ -3574,8 +3593,18 @@ var SQLiteEventStore = class {
3574
3593
  sqliteExec(this.db, `
3575
3594
  CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
3576
3595
  `);
3577
- } catch (err) {
3596
+ } catch {
3597
+ }
3598
+ const cmeCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM consolidated_memory_events`, []);
3599
+ if ((cmeCount?.c ?? 0) === 0) {
3600
+ sqliteExec(this.db, `
3601
+ INSERT OR IGNORE INTO consolidated_memory_events (memory_id, event_id)
3602
+ SELECT cm.memory_id, je.value
3603
+ FROM consolidated_memories cm, json_each(cm.source_events) je
3604
+ WHERE json_valid(cm.source_events);
3605
+ `);
3578
3606
  }
3607
+ sqliteExec(this.db, `PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
3579
3608
  this.initialized = true;
3580
3609
  }
3581
3610
  /**
@@ -3707,6 +3736,67 @@ var SQLiteEventStore = class {
3707
3736
  );
3708
3737
  return rows.map(this.rowToEvent);
3709
3738
  }
3739
+ /**
3740
+ * Aggregate event counts by type via SQL GROUP BY, instead of loading rows
3741
+ * into memory and counting in JS. Counts all events (the old recent-window
3742
+ * scan also under-counted stores larger than its cap).
3743
+ */
3744
+ async getEventTypeCounts(options) {
3745
+ await this.initialize();
3746
+ const rows = sqliteAll(
3747
+ this.db,
3748
+ `SELECT event_type, COUNT(*) as count FROM events WHERE ${maybeQuarantinePredicate(options)} GROUP BY event_type`,
3749
+ []
3750
+ );
3751
+ return rows.map((row) => ({ eventType: row.event_type, count: row.count }));
3752
+ }
3753
+ /**
3754
+ * Fetch every event at/after an ISO timestamp (ascending), with no row cap.
3755
+ * For window-scoped analytics (KPI/usefulness) this fetches exactly the events
3756
+ * the window needs instead of an arbitrary "most recent N" slice that both
3757
+ * over-fetches sparse windows and truncates very active ones.
3758
+ */
3759
+ async getEventsAfter(sinceIso, options) {
3760
+ await this.initialize();
3761
+ const rows = sqliteAll(
3762
+ this.db,
3763
+ `SELECT * FROM events WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)} ORDER BY timestamp ASC`,
3764
+ [sinceIso]
3765
+ );
3766
+ return rows.map((row) => this.rowToEvent(row));
3767
+ }
3768
+ /** Count distinct sessions via SQL instead of materializing a Set in JS. */
3769
+ async getDistinctSessionCount(options) {
3770
+ await this.initialize();
3771
+ const row = sqliteGet(
3772
+ this.db,
3773
+ `SELECT COUNT(DISTINCT session_id) as count FROM events WHERE ${maybeQuarantinePredicate(options)}`,
3774
+ []
3775
+ );
3776
+ return row?.count ?? 0;
3777
+ }
3778
+ /**
3779
+ * Per-day event counts (with type breakdown) since an ISO timestamp, computed
3780
+ * by SQL GROUP BY on the date prefix. Timestamps are stored as ISO-8601
3781
+ * strings, so substr(...,1,10) is the UTC day and `timestamp >= ?` compares
3782
+ * lexicographically.
3783
+ */
3784
+ async getDailyEventCounts(sinceIso, options) {
3785
+ await this.initialize();
3786
+ return sqliteAll(
3787
+ this.db,
3788
+ `SELECT substr(timestamp, 1, 10) as day,
3789
+ COUNT(*) as total,
3790
+ SUM(CASE WHEN event_type = 'user_prompt' THEN 1 ELSE 0 END) as prompts,
3791
+ SUM(CASE WHEN event_type = 'agent_response' THEN 1 ELSE 0 END) as responses,
3792
+ SUM(CASE WHEN event_type = 'tool_observation' THEN 1 ELSE 0 END) as tools
3793
+ FROM events
3794
+ WHERE timestamp >= ? AND ${maybeQuarantinePredicate(options)}
3795
+ GROUP BY day
3796
+ ORDER BY day ASC`,
3797
+ [sinceIso]
3798
+ );
3799
+ }
3710
3800
  /**
3711
3801
  * Get event by ID
3712
3802
  */
@@ -3720,6 +3810,71 @@ var SQLiteEventStore = class {
3720
3810
  if (!row) return null;
3721
3811
  return this.rowToEvent(row);
3722
3812
  }
3813
+ /**
3814
+ * Batch-fetch events by id in a single query, applying the same quarantine
3815
+ * predicate as getEvent. Lets hot paths replace N sequential round-trips with
3816
+ * one. Missing ids are simply absent from the result (order not guaranteed).
3817
+ */
3818
+ async getEvents(ids, options) {
3819
+ await this.initialize();
3820
+ if (ids.length === 0) return [];
3821
+ const placeholders = ids.map(() => "?").join(",");
3822
+ const rows = sqliteAll(
3823
+ this.db,
3824
+ `SELECT * FROM events WHERE id IN (${placeholders}) AND ${maybeQuarantinePredicate(options)}`,
3825
+ ids
3826
+ );
3827
+ return rows.map((row) => this.rowToEvent(row));
3828
+ }
3829
+ /**
3830
+ * Resolve an event from its citation id via the reverse index, instead of
3831
+ * scanning recent events and hashing each one. The index is self-healing: a
3832
+ * miss triggers a one-time backfill of any events not yet indexed (covering
3833
+ * freshly appended events) before retrying.
3834
+ */
3835
+ async getEventByCitationId(citationId, options) {
3836
+ await this.initialize();
3837
+ if (!citationId) return null;
3838
+ let row = sqliteGet(
3839
+ this.db,
3840
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3841
+ [citationId]
3842
+ );
3843
+ if (!row) {
3844
+ this.indexMissingCitations();
3845
+ row = sqliteGet(
3846
+ this.db,
3847
+ `SELECT event_id FROM event_citations WHERE citation_id = ? LIMIT 1`,
3848
+ [citationId]
3849
+ );
3850
+ }
3851
+ if (!row) return null;
3852
+ return this.getEvent(row.event_id, options);
3853
+ }
3854
+ /**
3855
+ * Backfill citation ids for any events not yet present in event_citations.
3856
+ * Gated on a cheap count comparison so the common (fully-indexed) path does no
3857
+ * work, and the scan only runs when there are genuinely new events to index.
3858
+ */
3859
+ indexMissingCitations() {
3860
+ const eventCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM events`, [])?.c ?? 0;
3861
+ const indexedCount = sqliteGet(this.db, `SELECT COUNT(*) as c FROM event_citations`, [])?.c ?? 0;
3862
+ if (indexedCount >= eventCount) return;
3863
+ const unindexed = sqliteAll(
3864
+ this.db,
3865
+ `SELECT e.id FROM events e
3866
+ LEFT JOIN event_citations ec ON ec.event_id = e.id
3867
+ WHERE ec.event_id IS NULL`,
3868
+ []
3869
+ );
3870
+ if (unindexed.length === 0) return;
3871
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO event_citations (event_id, citation_id) VALUES (?, ?)`);
3872
+ this.db.transaction(() => {
3873
+ for (const { id } of unindexed) {
3874
+ insert.run(id, generateCitationId(id));
3875
+ }
3876
+ })();
3877
+ }
3723
3878
  /**
3724
3879
  * Get events since a timestamp (for sync)
3725
3880
  */
@@ -3921,25 +4076,22 @@ var SQLiteEventStore = class {
3921
4076
  */
3922
4077
  async getPendingOutboxItems(limit = 32) {
3923
4078
  await this.initialize();
3924
- const pending = sqliteAll(
3925
- this.db,
3926
- `SELECT * FROM embedding_outbox
3927
- WHERE status = 'pending'
3928
- ORDER BY created_at
3929
- LIMIT ?`,
3930
- [limit]
3931
- );
3932
- if (pending.length === 0) return [];
3933
- const ids = pending.map((r) => r.id);
3934
- const placeholders = ids.map(() => "?").join(",");
3935
- sqliteRun(
4079
+ const claimed = sqliteAll(
3936
4080
  this.db,
3937
4081
  `UPDATE embedding_outbox
3938
4082
  SET status = 'processing', processed_at = datetime('now'), error_message = NULL
3939
- WHERE id IN (${placeholders})`,
3940
- ids
4083
+ WHERE id IN (
4084
+ SELECT id FROM embedding_outbox
4085
+ WHERE status = 'pending'
4086
+ ORDER BY created_at
4087
+ LIMIT ?
4088
+ )
4089
+ RETURNING *`,
4090
+ [limit]
3941
4091
  );
3942
- return pending.map((row) => ({
4092
+ if (claimed.length === 0) return [];
4093
+ claimed.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
4094
+ return claimed.map((row) => ({
3943
4095
  id: row.id,
3944
4096
  eventId: row.event_id,
3945
4097
  content: row.content,
@@ -4543,7 +4695,7 @@ var SQLiteEventStore = class {
4543
4695
  const promptEvents = sessionEvents.filter((e) => e.event_type === "user_prompt");
4544
4696
  const toolEvents = sessionEvents.filter((e) => e.event_type === "tool_observation");
4545
4697
  let toolSuccessCount = 0;
4546
- let toolTotalCount = toolEvents.length;
4698
+ const toolTotalCount = toolEvents.length;
4547
4699
  for (const t of toolEvents) {
4548
4700
  try {
4549
4701
  const content = JSON.parse(t.content);
@@ -4706,10 +4858,14 @@ var SQLiteEventStore = class {
4706
4858
  * Rebuild FTS index from existing events
4707
4859
  * Call this once after upgrading to FTS5
4708
4860
  */
4709
- async rebuildFtsIndex() {
4710
- await this.initialize();
4711
- const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4712
- const totalEvents = countRow?.count ?? 0;
4861
+ /**
4862
+ * Drop and recreate the events_fts virtual table + sync triggers, repopulating
4863
+ * from the current events table. Recreating the table (instead of issuing
4864
+ * DELETEs against it) sidesteps a quirk where older migrated FTS5 tables fail
4865
+ * with `no such column: T.event_id` on synthetic deletes. Safe to call inside
4866
+ * a transaction (pure DDL + INSERT...SELECT, no transaction control).
4867
+ */
4868
+ recreateEventsFtsTable() {
4713
4869
  sqliteExec(this.db, `
4714
4870
  DROP TRIGGER IF EXISTS events_fts_insert;
4715
4871
  DROP TRIGGER IF EXISTS events_fts_delete;
@@ -4738,6 +4894,12 @@ var SQLiteEventStore = class {
4738
4894
  INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
4739
4895
  END;
4740
4896
  `);
4897
+ }
4898
+ async rebuildFtsIndex() {
4899
+ await this.initialize();
4900
+ const countRow = sqliteGet(this.db, "SELECT COUNT(*) as count FROM events", []);
4901
+ const totalEvents = countRow?.count ?? 0;
4902
+ this.recreateEventsFtsTable();
4741
4903
  return totalEvents;
4742
4904
  }
4743
4905
  /**
@@ -4755,6 +4917,35 @@ var SQLiteEventStore = class {
4755
4917
  return false;
4756
4918
  }
4757
4919
  }
4920
+ hasTable(tableName) {
4921
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) return false;
4922
+ try {
4923
+ const rows = sqliteAll(
4924
+ this.db,
4925
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
4926
+ [tableName]
4927
+ );
4928
+ return rows.length > 0;
4929
+ } catch {
4930
+ return false;
4931
+ }
4932
+ }
4933
+ /**
4934
+ * Add a column only when its table exists and the column is missing.
4935
+ *
4936
+ * Unlike a blind `try { ALTER } catch {}`, this distinguishes "already
4937
+ * migrated" / "table not present yet" (both safely skipped) from a genuine
4938
+ * failure (disk full, corruption), which is allowed to surface instead of
4939
+ * being silently swallowed as if the migration had succeeded.
4940
+ */
4941
+ addColumnIfMissing(table, column, definition) {
4942
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
4943
+ throw new Error(`Invalid identifier in migration: ${table}.${column}`);
4944
+ }
4945
+ if (!this.hasTable(table)) return;
4946
+ if (this.hasTableColumn(table, column)) return;
4947
+ sqliteExec(this.db, `ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
4948
+ }
4758
4949
  async recordRetrievalTrace(input) {
4759
4950
  await this.initialize();
4760
4951
  const traceId = randomUUID6();
@@ -4840,6 +5031,45 @@ var SQLiteEventStore = class {
4840
5031
  FROM retrieval_traces`,
4841
5032
  []
4842
5033
  );
5034
+ const strategyColumnSql = this.hasTableColumn("retrieval_traces", "strategy") ? "COALESCE(NULLIF(TRIM(strategy), ''), 'unknown')" : "'unknown'";
5035
+ const strategyRows = sqliteAll(
5036
+ this.db,
5037
+ `SELECT
5038
+ ${strategyColumnSql} as strategy,
5039
+ COUNT(*) as total_queries,
5040
+ SUM(CASE WHEN selected_count > 0 THEN 1 ELSE 0 END) as queries_with_selection,
5041
+ SUM(CASE WHEN ${rewrittenQueryRewriteKindSql} THEN 1 ELSE 0 END) as rewritten_queries,
5042
+ SUM(candidate_count) as total_candidate_count,
5043
+ SUM(selected_count) as total_selected_count,
5044
+ AVG(candidate_count) as avg_candidate_count,
5045
+ AVG(selected_count) as avg_selected_count,
5046
+ CASE
5047
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
5048
+ ELSE 0
5049
+ END as selection_rate
5050
+ FROM retrieval_traces
5051
+ GROUP BY ${strategyColumnSql}
5052
+ ORDER BY total_queries DESC, strategy ASC`,
5053
+ []
5054
+ );
5055
+ const strategyBreakdown = strategyRows.map((strategyRow) => {
5056
+ const strategyTotalQueries = Number(strategyRow.total_queries || 0);
5057
+ const strategyQueriesWithSelection = Number(strategyRow.queries_with_selection || 0);
5058
+ const strategyRewrittenQueries = Number(strategyRow.rewritten_queries || 0);
5059
+ return {
5060
+ strategy: String(strategyRow.strategy || "unknown"),
5061
+ totalQueries: strategyTotalQueries,
5062
+ queriesWithSelection: strategyQueriesWithSelection,
5063
+ rewrittenQueries: strategyRewrittenQueries,
5064
+ rewriteRate: strategyTotalQueries > 0 ? strategyRewrittenQueries / strategyTotalQueries : 0,
5065
+ totalCandidateCount: Number(strategyRow.total_candidate_count || 0),
5066
+ totalSelectedCount: Number(strategyRow.total_selected_count || 0),
5067
+ avgCandidateCount: Number(strategyRow.avg_candidate_count || 0),
5068
+ avgSelectedCount: Number(strategyRow.avg_selected_count || 0),
5069
+ selectionRate: Number(strategyRow.selection_rate || 0),
5070
+ queryYieldRate: strategyTotalQueries > 0 ? strategyQueriesWithSelection / strategyTotalQueries : 0
5071
+ };
5072
+ });
4843
5073
  const totalQueries = Number(row?.total_queries || 0);
4844
5074
  const rewrittenQueries = Number(row?.rewritten_queries || 0);
4845
5075
  const rawQueries = Math.max(0, totalQueries - rewrittenQueries);
@@ -4857,7 +5087,8 @@ var SQLiteEventStore = class {
4857
5087
  rewrittenSelectionRate: rewrittenQueries > 0 ? rewrittenQueriesWithSelection / rewrittenQueries : 0,
4858
5088
  rawSelectionRate: rawQueries > 0 ? rawQueriesWithSelection / rawQueries : 0,
4859
5089
  avgSelectedCountForRewrittenQueries: Number(row?.avg_selected_count_for_rewritten_queries || 0),
4860
- avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0)
5090
+ avgSelectedCountForRawQueries: Number(row?.avg_selected_count_for_raw_queries || 0),
5091
+ strategyBreakdown
4861
5092
  };
4862
5093
  } catch (err) {
4863
5094
  if (err?.message?.includes("no such table")) {
@@ -4873,7 +5104,8 @@ var SQLiteEventStore = class {
4873
5104
  rewrittenSelectionRate: 0,
4874
5105
  rawSelectionRate: 0,
4875
5106
  avgSelectedCountForRewrittenQueries: 0,
4876
- avgSelectedCountForRawQueries: 0
5107
+ avgSelectedCountForRawQueries: 0,
5108
+ strategyBreakdown: []
4877
5109
  };
4878
5110
  }
4879
5111
  throw err;
@@ -4990,38 +5222,19 @@ var SQLiteEventStore = class {
4990
5222
  if (events.length === 0) return 0;
4991
5223
  const eventIds = events.map((e) => e.id);
4992
5224
  const placeholders = eventIds.map(() => "?").join(",");
4993
- const ftsTriggersDropped = [];
4994
- for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4995
- try {
5225
+ const relatedTables = ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox", "event_citations"].filter((table) => this.hasTable(table) && this.hasTableColumn(table, "event_id"));
5226
+ const runDelete = this.db.transaction(() => {
5227
+ for (const triggerName of ["events_fts_delete", "events_fts_update", "events_fts_insert"]) {
4996
5228
  sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
4997
- ftsTriggersDropped.push(triggerName);
4998
- } catch {
4999
5229
  }
5000
- }
5001
- for (const table of ["event_dedup", "memory_levels", "embedding_queue", "embedding_outbox", "vector_outbox"]) {
5002
- try {
5230
+ for (const table of relatedTables) {
5003
5231
  sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
5004
- } catch {
5005
5232
  }
5006
- }
5007
- const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5008
- if (ftsTriggersDropped.length > 0) {
5009
- try {
5010
- sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
5011
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
5012
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
5013
- END`);
5014
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
5015
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
5016
- END`);
5017
- sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
5018
- DELETE FROM events_fts WHERE rowid = OLD.rowid;
5019
- INSERT INTO events_fts(rowid, content, event_id) VALUES (NEW.rowid, NEW.content, NEW.id);
5020
- END`);
5021
- } catch {
5022
- }
5023
- }
5024
- return result.changes || 0;
5233
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
5234
+ this.recreateEventsFtsTable();
5235
+ return result.changes || 0;
5236
+ });
5237
+ return runDelete();
5025
5238
  }
5026
5239
  /**
5027
5240
  * Convert database row to MemoryEvent
@@ -7594,7 +7807,7 @@ var SessionActorRepository = class {
7594
7807
  };
7595
7808
 
7596
7809
  // src/core/operations/perspective-observation-repository.ts
7597
- import { createHash as createHash3, randomUUID as randomUUID9 } from "crypto";
7810
+ import { createHash as createHash4, randomUUID as randomUUID9 } from "crypto";
7598
7811
  function projectHashToStorage4(projectHash) {
7599
7812
  return projectHash ?? "";
7600
7813
  }
@@ -7631,7 +7844,7 @@ function sanitizeStoredRecord(value) {
7631
7844
  return sanitizeGovernanceAuditValue(value);
7632
7845
  }
7633
7846
  function stableHash(value) {
7634
- return createHash3("sha256").update(JSON.stringify(value)).digest("hex");
7847
+ return createHash4("sha256").update(JSON.stringify(value)).digest("hex");
7635
7848
  }
7636
7849
  function evidenceHash(sourceEventIds, sourceObservationIds) {
7637
7850
  return stableHash({
@@ -8746,6 +8959,30 @@ var MemoryQueryService = class {
8746
8959
  score: 1 - (r.rank - minRank) / rankRange
8747
8960
  })).filter((r) => !options?.minScore || r.score >= options.minScore);
8748
8961
  }
8962
+ async getEvent(id) {
8963
+ await this.initialize();
8964
+ return this.queryStore.getEvent(id);
8965
+ }
8966
+ async getEventByCitationId(citationId) {
8967
+ await this.initialize();
8968
+ return this.getMaintenanceStore("getEventByCitationId").getEventByCitationId(citationId);
8969
+ }
8970
+ async getEventsAfter(sinceIso) {
8971
+ await this.initialize();
8972
+ return this.getMaintenanceStore("getEventsAfter").getEventsAfter(sinceIso);
8973
+ }
8974
+ async getEventTypeCounts() {
8975
+ await this.initialize();
8976
+ return this.getMaintenanceStore("getEventTypeCounts").getEventTypeCounts();
8977
+ }
8978
+ async getDistinctSessionCount() {
8979
+ await this.initialize();
8980
+ return this.getMaintenanceStore("getDistinctSessionCount").getDistinctSessionCount();
8981
+ }
8982
+ async getDailyEventCounts(sinceIso) {
8983
+ await this.initialize();
8984
+ return this.getMaintenanceStore("getDailyEventCounts").getDailyEventCounts(sinceIso);
8985
+ }
8749
8986
  async getSessionHistory(sessionId) {
8750
8987
  await this.initialize();
8751
8988
  return this.queryStore.getSessionEvents(sessionId);
@@ -9714,11 +9951,12 @@ var Retriever = class {
9714
9951
  if (!scope && projectScopeMode === "global" && facetFilters === null) return results;
9715
9952
  const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
9716
9953
  const filtered = [];
9954
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9717
9955
  for (const result of results) {
9718
9956
  if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
9719
9957
  if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
9720
9958
  if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType)) continue;
9721
- const event = await this.eventStore.getEvent(result.eventId);
9959
+ const event = eventMap.get(result.eventId);
9722
9960
  if (!event) continue;
9723
9961
  if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
9724
9962
  if (normalizedIncludes.length > 0) {
@@ -9827,10 +10065,30 @@ var Retriever = class {
9827
10065
  async retrieveRecent(limit = 100) {
9828
10066
  return this.eventStore.getRecentEvents(limit);
9829
10067
  }
10068
+ /**
10069
+ * Batch-load events by id into a Map in a single query, de-duplicating ids.
10070
+ * Replaces per-result getEvent round-trips in the scope/enrich hot paths.
10071
+ */
10072
+ async fetchEventsByIds(ids) {
10073
+ const map = /* @__PURE__ */ new Map();
10074
+ if (ids.length === 0) return map;
10075
+ const unique = [...new Set(ids)];
10076
+ if (typeof this.eventStore.getEvents === "function") {
10077
+ const events = await this.eventStore.getEvents(unique);
10078
+ for (const event of events) map.set(event.id, event);
10079
+ } else {
10080
+ const events = await Promise.all(unique.map((id) => this.eventStore.getEvent(id)));
10081
+ for (const event of events) {
10082
+ if (event) map.set(event.id, event);
10083
+ }
10084
+ }
10085
+ return map;
10086
+ }
9830
10087
  async enrichResults(results, options, query) {
9831
10088
  const memories = [];
10089
+ const eventMap = await this.fetchEventsByIds(results.map((r) => r.eventId));
9832
10090
  for (const result of results) {
9833
- const event = await this.eventStore.getEvent(result.eventId);
10091
+ const event = eventMap.get(result.eventId);
9834
10092
  if (!event) continue;
9835
10093
  if (this.graduation) {
9836
10094
  this.graduation.recordAccess(event.id, options.sessionId || "unknown", result.score);
@@ -12558,6 +12816,34 @@ var MemoryService = class {
12558
12816
  async rebuildFtsIndex() {
12559
12817
  return this.queryService.rebuildFtsIndex();
12560
12818
  }
12819
+ /**
12820
+ * Get a single event by id (indexed lookup).
12821
+ */
12822
+ async getEvent(id) {
12823
+ return this.queryService.getEvent(id);
12824
+ }
12825
+ /**
12826
+ * Resolve an event from its citation id via the reverse index.
12827
+ */
12828
+ async getEventByCitationId(citationId) {
12829
+ return this.queryService.getEventByCitationId(citationId);
12830
+ }
12831
+ /** All events at/after an ISO timestamp (ascending, uncapped). */
12832
+ async getEventsAfter(sinceIso) {
12833
+ return this.queryService.getEventsAfter(sinceIso);
12834
+ }
12835
+ /** Aggregate event counts by type (SQL GROUP BY). */
12836
+ async getEventTypeCounts() {
12837
+ return this.queryService.getEventTypeCounts();
12838
+ }
12839
+ /** Count distinct sessions (SQL). */
12840
+ async getDistinctSessionCount() {
12841
+ return this.queryService.getDistinctSessionCount();
12842
+ }
12843
+ /** Per-day event counts with type breakdown since an ISO timestamp (SQL). */
12844
+ async getDailyEventCounts(sinceIso) {
12845
+ return this.queryService.getDailyEventCounts(sinceIso);
12846
+ }
12561
12847
  /**
12562
12848
  * Get session history
12563
12849
  */
@@ -12927,6 +13213,75 @@ function clearTurnState(sessionId) {
12927
13213
  }
12928
13214
  }
12929
13215
 
13216
+ // src/adapters/claude/hooks/hook-runtime.ts
13217
+ var DEFAULT_STDIN_TIMEOUT_MS = 1e4;
13218
+ var DEFAULT_OVERALL_TIMEOUT_MS = 3e4;
13219
+ function readNumberEnv(name, fallback, options = {}) {
13220
+ const raw = process.env[name];
13221
+ if (raw === void 0 || raw.trim() === "") return fallback;
13222
+ const parsed = options.integer ? Number.parseInt(raw, 10) : Number.parseFloat(raw);
13223
+ if (!Number.isFinite(parsed)) return fallback;
13224
+ let value = parsed;
13225
+ if (options.min !== void 0) value = Math.max(options.min, value);
13226
+ if (options.max !== void 0) value = Math.min(options.max, value);
13227
+ return value;
13228
+ }
13229
+ function readPositiveIntEnv(name, fallback) {
13230
+ return readNumberEnv(name, fallback, { integer: true, min: 1 });
13231
+ }
13232
+ function readStdin(options = {}) {
13233
+ const timeoutMs = options.timeoutMs ?? readPositiveIntEnv("CLAUDE_MEMORY_HOOK_STDIN_TIMEOUT_MS", DEFAULT_STDIN_TIMEOUT_MS);
13234
+ return new Promise((resolve2) => {
13235
+ const stdin = process.stdin;
13236
+ let data = "";
13237
+ let settled = false;
13238
+ const onData = (chunk) => {
13239
+ data += chunk;
13240
+ };
13241
+ const finish = () => {
13242
+ if (settled) return;
13243
+ settled = true;
13244
+ clearTimeout(timer);
13245
+ stdin.off("data", onData);
13246
+ stdin.off("end", finish);
13247
+ stdin.off("error", finish);
13248
+ resolve2(data);
13249
+ };
13250
+ const timer = setTimeout(finish, timeoutMs);
13251
+ timer.unref?.();
13252
+ stdin.setEncoding("utf8");
13253
+ stdin.on("data", onData);
13254
+ stdin.on("end", finish);
13255
+ stdin.on("error", finish);
13256
+ });
13257
+ }
13258
+ function emitHookOutput(json) {
13259
+ process.stdout.write(json.endsWith("\n") ? json : `${json}
13260
+ `);
13261
+ }
13262
+ async function runHook(options, run) {
13263
+ const debug = Boolean(process.env.CLAUDE_MEMORY_DEBUG);
13264
+ const timeoutMs = options.timeoutMs ?? readPositiveIntEnv("CLAUDE_MEMORY_HOOK_TIMEOUT_MS", DEFAULT_OVERALL_TIMEOUT_MS);
13265
+ let emitted = false;
13266
+ const emit = (json) => {
13267
+ if (emitted) return;
13268
+ emitted = true;
13269
+ emitHookOutput(json);
13270
+ };
13271
+ const watchdog = setTimeout(() => {
13272
+ if (debug) console.error(`[${options.name}] hook timed out after ${timeoutMs}ms; forcing exit`);
13273
+ emit(options.fallbackOutput);
13274
+ process.exit(0);
13275
+ }, timeoutMs);
13276
+ watchdog.unref?.();
13277
+ try {
13278
+ emit(await run());
13279
+ } catch (error) {
13280
+ if (debug) console.error(`[${options.name}] hook error:`, error);
13281
+ emit(options.fallbackOutput);
13282
+ }
13283
+ }
13284
+
12930
13285
  // src/adapters/claude/hooks/post-tool-use.ts
12931
13286
  var DEFAULT_CONFIG6 = {
12932
13287
  enabled: true,
@@ -12946,7 +13301,7 @@ var DEFAULT_CONFIG6 = {
12946
13301
  "Skill",
12947
13302
  "EnterPlanMode"
12948
13303
  ],
12949
- minOutputLength: parseInt(process.env.CLAUDE_MEMORY_TOOL_MIN_OUTPUT_LEN || "100"),
13304
+ minOutputLength: readNumberEnv("CLAUDE_MEMORY_TOOL_MIN_OUTPUT_LEN", 100, { integer: true, min: 0 }),
12950
13305
  maxOutputLength: 1e4,
12951
13306
  maxOutputLines: 100,
12952
13307
  storeOnlyOnSuccess: false
@@ -13021,8 +13376,12 @@ function isToolSuccess(response) {
13021
13376
  return true;
13022
13377
  }
13023
13378
  async function main() {
13024
- const inputData = await readStdin();
13025
- const input = JSON.parse(inputData);
13379
+ let input;
13380
+ try {
13381
+ input = JSON.parse(await readStdin());
13382
+ } catch {
13383
+ return JSON.stringify({});
13384
+ }
13026
13385
  const config = { ...DEFAULT_CONFIG6 };
13027
13386
  const privacyConfig = DEFAULT_PRIVACY_CONFIG;
13028
13387
  const envBlocklist = process.env.CLAUDE_MEMORY_TOOL_BLOCKLIST;
@@ -13030,18 +13389,15 @@ async function main() {
13030
13389
  config.excludedTools = envBlocklist.split(",").map((s) => s.trim()).filter(Boolean);
13031
13390
  }
13032
13391
  if (!config.enabled) {
13033
- console.log(JSON.stringify({}));
13034
- return;
13392
+ return JSON.stringify({});
13035
13393
  }
13036
13394
  if (config.excludedTools?.includes(input.tool_name)) {
13037
- console.log(JSON.stringify({}));
13038
- return;
13395
+ return JSON.stringify({});
13039
13396
  }
13040
13397
  const toolOutput = extractToolOutput(input.tool_response);
13041
13398
  const success = isToolSuccess(input.tool_response);
13042
13399
  if (!success && config.storeOnlyOnSuccess) {
13043
- console.log(JSON.stringify({}));
13044
- return;
13400
+ return JSON.stringify({});
13045
13401
  }
13046
13402
  if (!hasSignificantOutput(
13047
13403
  input.tool_name,
@@ -13049,8 +13405,7 @@ async function main() {
13049
13405
  input.tool_response,
13050
13406
  config.minOutputLength ?? 100
13051
13407
  )) {
13052
- console.log(JSON.stringify({}));
13053
- return;
13408
+ return JSON.stringify({});
13054
13409
  }
13055
13410
  try {
13056
13411
  const memoryService = getLightweightMemoryService(input.session_id);
@@ -13082,27 +13437,15 @@ async function main() {
13082
13437
  }
13083
13438
  };
13084
13439
  await memoryService.storeToolObservation(input.session_id, payload);
13085
- console.log(JSON.stringify({}));
13440
+ return JSON.stringify({});
13086
13441
  } catch (error) {
13087
13442
  if (process.env.CLAUDE_MEMORY_DEBUG) {
13088
13443
  console.error("PostToolUse hook error:", error);
13089
13444
  }
13090
- console.log(JSON.stringify({}));
13445
+ return JSON.stringify({});
13091
13446
  }
13092
13447
  }
13093
- function readStdin() {
13094
- return new Promise((resolve2) => {
13095
- let data = "";
13096
- process.stdin.setEncoding("utf8");
13097
- process.stdin.on("data", (chunk) => {
13098
- data += chunk;
13099
- });
13100
- process.stdin.on("end", () => {
13101
- resolve2(data);
13102
- });
13103
- });
13104
- }
13105
13448
 
13106
13449
  // src/hooks/post-tool-use.ts
13107
- main().catch(console.error);
13450
+ void runHook({ name: "post-tool-use", fallbackOutput: "{}" }, main);
13108
13451
  //# sourceMappingURL=post-tool-use.js.map