claude-memory-layer 1.0.10 → 1.0.12

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.
Files changed (142) hide show
  1. package/AGENTS.md +60 -0
  2. package/README.md +166 -2
  3. package/bootstrap-kb/decisions/decisions.md +244 -0
  4. package/bootstrap-kb/glossary/glossary.md +46 -0
  5. package/bootstrap-kb/modules/.claude-plugin.md +22 -0
  6. package/bootstrap-kb/modules/agents.md.md +15 -0
  7. package/bootstrap-kb/modules/claude.md.md +15 -0
  8. package/bootstrap-kb/modules/context.md.md +15 -0
  9. package/bootstrap-kb/modules/docs.md +18 -0
  10. package/bootstrap-kb/modules/handoff.md.md +15 -0
  11. package/bootstrap-kb/modules/package-lock.json.md +15 -0
  12. package/bootstrap-kb/modules/package.json.md +15 -0
  13. package/bootstrap-kb/modules/plan.md.md +15 -0
  14. package/bootstrap-kb/modules/readme.md.md +15 -0
  15. package/bootstrap-kb/modules/scripts.md +26 -0
  16. package/bootstrap-kb/modules/spec.md.md +15 -0
  17. package/bootstrap-kb/modules/specs.md +20 -0
  18. package/bootstrap-kb/modules/src.md +51 -0
  19. package/bootstrap-kb/modules/tests.md +42 -0
  20. package/bootstrap-kb/modules/tsconfig.json.md +15 -0
  21. package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
  22. package/bootstrap-kb/overview/overview.md +40 -0
  23. package/bootstrap-kb/sources/manifest.json +950 -0
  24. package/bootstrap-kb/sources/manifest.md +227 -0
  25. package/bootstrap-kb/timeline/timeline.md +57 -0
  26. package/d.sh +3 -0
  27. package/deploy.sh +3 -0
  28. package/dist/cli/index.js +3577 -389
  29. package/dist/cli/index.js.map +4 -4
  30. package/dist/core/index.js +1383 -138
  31. package/dist/core/index.js.map +4 -4
  32. package/dist/hooks/post-tool-use.js +1917 -214
  33. package/dist/hooks/post-tool-use.js.map +4 -4
  34. package/dist/hooks/session-end.js +1813 -231
  35. package/dist/hooks/session-end.js.map +4 -4
  36. package/dist/hooks/session-start.js +1802 -205
  37. package/dist/hooks/session-start.js.map +4 -4
  38. package/dist/hooks/stop.js +1909 -248
  39. package/dist/hooks/stop.js.map +4 -4
  40. package/dist/hooks/user-prompt-submit.js +1861 -206
  41. package/dist/hooks/user-prompt-submit.js.map +4 -4
  42. package/dist/server/api/index.js +2341 -217
  43. package/dist/server/api/index.js.map +4 -4
  44. package/dist/server/index.js +2350 -226
  45. package/dist/server/index.js.map +4 -4
  46. package/dist/services/memory-service.js +1805 -206
  47. package/dist/services/memory-service.js.map +4 -4
  48. package/dist/ui/app.js +1447 -55
  49. package/dist/ui/index.html +318 -147
  50. package/dist/ui/style.css +892 -0
  51. package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
  52. package/docs/MEMU_ADOPTION.md +40 -0
  53. package/docs/OPERATIONS.md +18 -0
  54. package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
  55. package/memory/_index.md +405 -0
  56. package/memory/default/uncategorized/2026-02-25.md +4839 -0
  57. package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
  58. package/memory/specs/citations-system/2026-02-25.md +1121 -0
  59. package/memory/specs/endless-mode/2026-02-25.md +1392 -0
  60. package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
  61. package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
  62. package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
  63. package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
  64. package/memory/specs/private-tags/2026-02-25.md +1057 -0
  65. package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
  66. package/memory/specs/task-entity-system/2026-02-25.md +924 -0
  67. package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
  68. package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
  69. package/package.json +9 -2
  70. package/scripts/build.ts +6 -0
  71. package/scripts/fix-sync-gap.js +32 -0
  72. package/scripts/heartbeat-memory-orchestrator.sh +28 -0
  73. package/scripts/report-sync-gap.js +26 -0
  74. package/scripts/review-queue-auto-resolve.js +21 -0
  75. package/scripts/sync-gap-auto-heal.sh +17 -0
  76. package/specs/20260207-dashboard-upgrade/context.md +38 -0
  77. package/specs/20260207-dashboard-upgrade/spec.md +96 -0
  78. package/src/cli/index.ts +391 -60
  79. package/src/core/consolidated-store.ts +63 -1
  80. package/src/core/consolidation-worker.ts +115 -6
  81. package/src/core/event-store.ts +14 -0
  82. package/src/core/index.ts +1 -0
  83. package/src/core/ingest-interceptor.ts +80 -0
  84. package/src/core/markdown-mirror.ts +70 -0
  85. package/src/core/md-mirror.ts +92 -0
  86. package/src/core/mongo-sync-config.ts +165 -0
  87. package/src/core/mongo-sync-worker.ts +381 -0
  88. package/src/core/retriever.ts +540 -150
  89. package/src/core/sqlite-event-store.ts +794 -7
  90. package/src/core/sqlite-wrapper.ts +8 -0
  91. package/src/core/tag-taxonomy.ts +51 -0
  92. package/src/core/turn-state.ts +159 -0
  93. package/src/core/types.ts +51 -8
  94. package/src/core/vector-store.ts +21 -3
  95. package/src/hooks/post-tool-use.ts +68 -23
  96. package/src/hooks/session-end.ts +8 -3
  97. package/src/hooks/stop.ts +96 -25
  98. package/src/hooks/user-prompt-submit.ts +44 -5
  99. package/src/server/api/chat.ts +244 -0
  100. package/src/server/api/citations.ts +3 -3
  101. package/src/server/api/events.ts +30 -5
  102. package/src/server/api/health.ts +53 -0
  103. package/src/server/api/index.ts +9 -1
  104. package/src/server/api/projects.ts +74 -0
  105. package/src/server/api/search.ts +3 -3
  106. package/src/server/api/sessions.ts +3 -3
  107. package/src/server/api/stats.ts +89 -8
  108. package/src/server/api/turns.ts +143 -0
  109. package/src/server/api/utils.ts +46 -0
  110. package/src/services/bootstrap-organizer.ts +443 -0
  111. package/src/services/codex-session-history-importer.ts +474 -0
  112. package/src/services/memory-service.ts +508 -71
  113. package/src/services/session-history-importer.ts +215 -51
  114. package/src/ui/app.js +1447 -55
  115. package/src/ui/index.html +318 -147
  116. package/src/ui/style.css +892 -0
  117. package/tests/bootstrap-organizer.test.ts +111 -0
  118. package/tests/consolidation-worker.test.ts +75 -0
  119. package/tests/ingest-interceptor.test.ts +38 -0
  120. package/tests/markdown-mirror.test.ts +85 -0
  121. package/tests/md-mirror.test.ts +50 -0
  122. package/tests/retriever-fallback-chain.test.ts +223 -0
  123. package/tests/retriever-strategy-scope.test.ts +97 -0
  124. package/tests/retriever.memu-adoption.test.ts +122 -0
  125. package/tests/sqlite-event-store-replication.test.ts +92 -0
  126. package/.claude/settings.local.json +0 -27
  127. package/.claude-memory/test.sqlite +0 -0
  128. package/.history/package_20260201112328.json +0 -45
  129. package/.history/package_20260201113602.json +0 -45
  130. package/.history/package_20260201113713.json +0 -45
  131. package/.history/package_20260201114110.json +0 -45
  132. package/.history/package_20260201114632.json +0 -46
  133. package/.history/package_20260201133143.json +0 -45
  134. package/.history/package_20260201134319.json +0 -45
  135. package/.history/package_20260201134326.json +0 -45
  136. package/.history/package_20260201134334.json +0 -45
  137. package/.history/package_20260201134912.json +0 -45
  138. package/.history/package_20260201142928.json +0 -46
  139. package/.history/package_20260201192048.json +0 -47
  140. package/.history/package_20260202114053.json +0 -49
  141. package/.history/package_20260202121115.json +0 -49
  142. package/test_access.js +0 -49
@@ -24,15 +24,17 @@ import {
24
24
  type SQLiteDatabase,
25
25
  type SQLiteOptions
26
26
  } from './sqlite-wrapper.js';
27
+ import { MarkdownMirror } from './markdown-mirror.js';
27
28
 
28
29
  export interface SQLiteEventStoreOptions extends SQLiteOptions {
29
- // Additional options can be added here
30
+ markdownMirrorRoot?: string;
30
31
  }
31
32
 
32
33
  export class SQLiteEventStore {
33
34
  private db: SQLiteDatabase;
34
35
  private initialized = false;
35
36
  private readonly readOnly: boolean;
37
+ private readonly markdownMirror: MarkdownMirror | null;
36
38
 
37
39
  constructor(private dbPath: string, options?: SQLiteEventStoreOptions) {
38
40
  this.readOnly = options?.readonly ?? false;
@@ -40,6 +42,9 @@ export class SQLiteEventStore {
40
42
  readonly: this.readOnly,
41
43
  walMode: !this.readOnly
42
44
  });
45
+ this.markdownMirror = this.readOnly || !options?.markdownMirrorRoot
46
+ ? null
47
+ : new MarkdownMirror(options.markdownMirrorRoot);
43
48
  }
44
49
 
45
50
  /**
@@ -251,6 +256,17 @@ export class SQLiteEventStore {
251
256
  created_at TEXT DEFAULT (datetime('now'))
252
257
  );
253
258
 
259
+ -- Consolidated Rules table (long-term stable memory)
260
+ CREATE TABLE IF NOT EXISTS consolidated_rules (
261
+ rule_id TEXT PRIMARY KEY,
262
+ rule TEXT NOT NULL,
263
+ topics TEXT,
264
+ source_memory_ids TEXT,
265
+ source_events TEXT,
266
+ confidence REAL DEFAULT 0.5,
267
+ created_at TEXT DEFAULT (datetime('now'))
268
+ );
269
+
254
270
  -- Endless Mode Config table
255
271
  CREATE TABLE IF NOT EXISTS endless_config (
256
272
  key TEXT PRIMARY KEY,
@@ -258,6 +274,41 @@ export class SQLiteEventStore {
258
274
  updated_at TEXT DEFAULT (datetime('now'))
259
275
  );
260
276
 
277
+ -- Memory Helpfulness tracking
278
+ CREATE TABLE IF NOT EXISTS memory_helpfulness (
279
+ id TEXT PRIMARY KEY,
280
+ event_id TEXT NOT NULL,
281
+ session_id TEXT NOT NULL,
282
+ retrieval_score REAL DEFAULT 0,
283
+ query_preview TEXT,
284
+ session_continued INTEGER DEFAULT 0,
285
+ prompt_count_after INTEGER DEFAULT 0,
286
+ tool_success_count INTEGER DEFAULT 0,
287
+ tool_total_count INTEGER DEFAULT 0,
288
+ was_reasked INTEGER DEFAULT 0,
289
+ helpfulness_score REAL DEFAULT 0.5,
290
+ created_at TEXT DEFAULT (datetime('now')),
291
+ measured_at TEXT
292
+ );
293
+
294
+ -- Retrieval trace log (query -> candidates -> selected for context)
295
+ CREATE TABLE IF NOT EXISTS retrieval_traces (
296
+ trace_id TEXT PRIMARY KEY,
297
+ session_id TEXT,
298
+ project_hash TEXT,
299
+ query_text TEXT NOT NULL,
300
+ strategy TEXT,
301
+ candidate_event_ids TEXT,
302
+ selected_event_ids TEXT,
303
+ candidate_details_json TEXT,
304
+ selected_details_json TEXT,
305
+ candidate_count INTEGER DEFAULT 0,
306
+ selected_count INTEGER DEFAULT 0,
307
+ confidence TEXT,
308
+ fallback_trace TEXT,
309
+ created_at TEXT DEFAULT (datetime('now'))
310
+ );
311
+
261
312
  -- Sync position tracking (for SQLite -> DuckDB sync)
262
313
  CREATE TABLE IF NOT EXISTS sync_positions (
263
314
  target_name TEXT PRIMARY KEY,
@@ -282,7 +333,14 @@ export class SQLiteEventStore {
282
333
  CREATE INDEX IF NOT EXISTS idx_working_set_relevance ON working_set(relevance_score);
283
334
  CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence);
284
335
  CREATE INDEX IF NOT EXISTS idx_continuity_created ON continuity_log(created_at);
336
+ CREATE INDEX IF NOT EXISTS idx_consolidated_rules_confidence ON consolidated_rules(confidence);
285
337
  CREATE INDEX IF NOT EXISTS idx_embedding_outbox_status ON embedding_outbox(status);
338
+ CREATE INDEX IF NOT EXISTS idx_helpfulness_event ON memory_helpfulness(event_id);
339
+ CREATE INDEX IF NOT EXISTS idx_helpfulness_session ON memory_helpfulness(session_id);
340
+ CREATE INDEX IF NOT EXISTS idx_helpfulness_score ON memory_helpfulness(helpfulness_score DESC);
341
+ CREATE INDEX IF NOT EXISTS idx_retrieval_traces_created_at ON retrieval_traces(created_at DESC);
342
+ CREATE INDEX IF NOT EXISTS idx_retrieval_traces_project_hash ON retrieval_traces(project_hash);
343
+ CREATE INDEX IF NOT EXISTS idx_retrieval_traces_session_id ON retrieval_traces(session_id);
286
344
 
287
345
  -- FTS5 Full-Text Search for fast keyword search
288
346
  CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
@@ -307,7 +365,20 @@ export class SQLiteEventStore {
307
365
  END;
308
366
  `);
309
367
 
310
- // Migrate existing events table to add access tracking columns if they don't exist
368
+
369
+ // Best-effort forward migration for retrieval trace detail column
370
+ try {
371
+ sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
372
+ } catch {
373
+ // column may already exist
374
+ }
375
+ try {
376
+ sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
377
+ } catch {
378
+ // column may already exist
379
+ }
380
+
381
+ // Migrate existing events table to add new columns if they don't exist
311
382
  // Check if columns exist before trying to add them
312
383
  const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
313
384
  const columnNames = tableInfo.map((col: any) => col.name);
@@ -332,6 +403,17 @@ export class SQLiteEventStore {
332
403
  }
333
404
  }
334
405
 
406
+ // Add turn_id column for grouping events within a conversation turn
407
+ if (!columnNames.includes('turn_id')) {
408
+ try {
409
+ sqliteExec(this.db, `
410
+ ALTER TABLE events ADD COLUMN turn_id TEXT;
411
+ `);
412
+ } catch (err: any) {
413
+ console.error('Error adding turn_id column:', err);
414
+ }
415
+ }
416
+
335
417
  // Create indexes for new columns if they don't exist
336
418
  try {
337
419
  sqliteExec(this.db, `
@@ -349,6 +431,14 @@ export class SQLiteEventStore {
349
431
  // Index may already exist, ignore
350
432
  }
351
433
 
434
+ try {
435
+ sqliteExec(this.db, `
436
+ CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
437
+ `);
438
+ } catch (err: any) {
439
+ // Index may already exist, ignore
440
+ }
441
+
352
442
  this.initialized = true;
353
443
  }
354
444
 
@@ -380,10 +470,14 @@ export class SQLiteEventStore {
380
470
  const timestamp = toSQLiteTimestamp(input.timestamp);
381
471
 
382
472
  try {
473
+ // Extract turnId from metadata if present
474
+ const metadata = input.metadata || {};
475
+ const turnId = (metadata.turnId as string) || null;
476
+
383
477
  // Use transaction for atomicity
384
478
  const insertEvent = this.db.prepare(`
385
- INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata)
386
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
479
+ INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata, turn_id)
480
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
387
481
  `);
388
482
 
389
483
  const insertDedup = this.db.prepare(`
@@ -403,7 +497,8 @@ export class SQLiteEventStore {
403
497
  input.content,
404
498
  canonicalKey,
405
499
  dedupeKey,
406
- JSON.stringify(input.metadata || {})
500
+ JSON.stringify(metadata),
501
+ turnId
407
502
  );
408
503
  insertDedup.run(dedupeKey, id);
409
504
  insertLevel.run(id);
@@ -411,6 +506,22 @@ export class SQLiteEventStore {
411
506
 
412
507
  transaction();
413
508
 
509
+ if (this.markdownMirror) {
510
+ const event: MemoryEvent = {
511
+ id,
512
+ eventType: input.eventType,
513
+ sessionId: input.sessionId,
514
+ timestamp: input.timestamp,
515
+ content: input.content,
516
+ canonicalKey,
517
+ dedupeKey,
518
+ metadata
519
+ };
520
+ this.markdownMirror.append(event).catch((err) => {
521
+ console.warn('[SQLiteEventStore] markdown mirror append failed:', err);
522
+ });
523
+ }
524
+
414
525
  return { success: true, eventId: id, isDuplicate: false };
415
526
  } catch (error) {
416
527
  return {
@@ -481,6 +592,114 @@ export class SQLiteEventStore {
481
592
  return rows.map(this.rowToEvent);
482
593
  }
483
594
 
595
+ /**
596
+ * Get events since a SQLite rowid (for robust incremental replication).
597
+ * Rowid is monotonic for append-only tables, independent of client timestamps.
598
+ */
599
+ async getEventsSinceRowid(
600
+ lastRowid: number,
601
+ limit: number = 1000
602
+ ): Promise<Array<{ rowid: number; event: MemoryEvent }>> {
603
+ await this.initialize();
604
+
605
+ const rows = sqliteAll<Record<string, unknown>>(
606
+ this.db,
607
+ `SELECT rowid as _rowid, * FROM events WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
608
+ [lastRowid, limit]
609
+ );
610
+
611
+ return rows.map(row => ({
612
+ rowid: row._rowid as number,
613
+ event: this.rowToEvent(row)
614
+ }));
615
+ }
616
+
617
+ /**
618
+ * Import events with fixed IDs (used for cross-machine replication).
619
+ * Idempotent: skips if event id or dedupeKey already exists.
620
+ *
621
+ * NOTE: This bypasses the append() id generation to preserve stable IDs.
622
+ */
623
+ async importEvents(events: MemoryEvent[]): Promise<{ inserted: number; skipped: number }> {
624
+ if (events.length === 0) return { inserted: 0, skipped: 0 };
625
+ if (this.readOnly) return { inserted: 0, skipped: events.length };
626
+
627
+ await this.initialize();
628
+
629
+ const getById = this.db.prepare(`SELECT id FROM events WHERE id = ?`);
630
+ const getByDedupe = this.db.prepare(`SELECT event_id FROM event_dedup WHERE dedupe_key = ?`);
631
+
632
+ const insertEvent = this.db.prepare(`
633
+ INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata, turn_id)
634
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
635
+ `);
636
+
637
+ const insertDedup = this.db.prepare(`
638
+ INSERT INTO event_dedup (dedupe_key, event_id) VALUES (?, ?)
639
+ `);
640
+
641
+ const insertLevel = this.db.prepare(`
642
+ INSERT INTO memory_levels (event_id, level) VALUES (?, 'L0')
643
+ `);
644
+
645
+ let inserted = 0;
646
+ let skipped = 0;
647
+ const insertedEvents: MemoryEvent[] = [];
648
+
649
+ const tx = this.db.transaction((batch: MemoryEvent[]) => {
650
+ for (const ev of batch) {
651
+ // Skip if already present by id
652
+ const existingById = getById.get(ev.id) as { id: string } | undefined;
653
+ if (existingById) {
654
+ skipped++;
655
+ continue;
656
+ }
657
+
658
+ const canonicalKey = ev.canonicalKey || makeCanonicalKey(ev.content);
659
+ const dedupeKey = ev.dedupeKey || makeDedupeKey(ev.content, ev.sessionId);
660
+
661
+ // Skip if already present by dedupe key
662
+ const existingByDedupe = getByDedupe.get(dedupeKey) as { event_id: string } | undefined;
663
+ if (existingByDedupe) {
664
+ skipped++;
665
+ continue;
666
+ }
667
+
668
+ const metadata = ev.metadata || {};
669
+ const turnId = (metadata as any).turnId as string | undefined;
670
+
671
+ insertEvent.run(
672
+ ev.id,
673
+ ev.eventType,
674
+ ev.sessionId,
675
+ toSQLiteTimestamp(ev.timestamp),
676
+ ev.content,
677
+ canonicalKey,
678
+ dedupeKey,
679
+ JSON.stringify(metadata),
680
+ turnId ?? null
681
+ );
682
+
683
+ insertDedup.run(dedupeKey, ev.id);
684
+ insertLevel.run(ev.id);
685
+ inserted++;
686
+ insertedEvents.push(ev);
687
+ }
688
+ });
689
+
690
+ tx(events);
691
+
692
+ if (this.markdownMirror && insertedEvents.length > 0) {
693
+ for (const ev of insertedEvents) {
694
+ this.markdownMirror.append(ev).catch((err) => {
695
+ console.warn('[SQLiteEventStore] markdown mirror append failed:', err);
696
+ });
697
+ }
698
+ }
699
+
700
+ return { inserted, skipped };
701
+ }
702
+
484
703
  /**
485
704
  * Create or update session
486
705
  */
@@ -664,6 +883,43 @@ export class SQLiteEventStore {
664
883
  );
665
884
  }
666
885
 
886
+
887
+ /**
888
+ * Get embedding/vector outbox health statistics
889
+ */
890
+ async getOutboxStats(): Promise<{
891
+ embedding: { pending: number; processing: number; failed: number; total: number };
892
+ vector: { pending: number; processing: number; failed: number; total: number };
893
+ }> {
894
+ await this.initialize();
895
+
896
+ const embeddingRows = sqliteAll<{ status: string; count: number }>(
897
+ this.db,
898
+ `SELECT status, COUNT(*) as count FROM embedding_outbox GROUP BY status`
899
+ );
900
+ const vectorRows = sqliteAll<{ status: string; count: number }>(
901
+ this.db,
902
+ `SELECT status, COUNT(*) as count FROM vector_outbox GROUP BY status`
903
+ );
904
+
905
+ const fromRows = (rows: Array<{ status: string; count: number }>) => {
906
+ const out = { pending: 0, processing: 0, failed: 0, total: 0 };
907
+ for (const row of rows) {
908
+ const key = row.status as 'pending' | 'processing' | 'failed' | 'done';
909
+ if (key === 'pending' || key === 'processing' || key === 'failed') {
910
+ out[key] += row.count;
911
+ }
912
+ out.total += row.count;
913
+ }
914
+ return out;
915
+ };
916
+
917
+ return {
918
+ embedding: fromRows(embeddingRows),
919
+ vector: fromRows(vectorRows)
920
+ };
921
+ }
922
+
667
923
  /**
668
924
  * Update memory level
669
925
  */
@@ -812,12 +1068,13 @@ export class SQLiteEventStore {
812
1068
  }
813
1069
 
814
1070
  /**
815
- * Get most accessed memories
1071
+ * Get most accessed memories (falls back to recent events if none accessed)
816
1072
  */
817
1073
  async getMostAccessed(limit: number = 10): Promise<MemoryEvent[]> {
818
1074
  await this.initialize();
819
1075
 
820
- const rows = sqliteAll<Record<string, unknown>>(
1076
+ // First try events with access_count > 0
1077
+ let rows = sqliteAll<Record<string, unknown>>(
821
1078
  this.db,
822
1079
  `SELECT * FROM events
823
1080
  WHERE access_count > 0
@@ -826,9 +1083,203 @@ export class SQLiteEventStore {
826
1083
  [limit]
827
1084
  );
828
1085
 
1086
+ // Fallback: if no accessed events, show recent events
1087
+ if (rows.length === 0) {
1088
+ rows = sqliteAll<Record<string, unknown>>(
1089
+ this.db,
1090
+ `SELECT * FROM events
1091
+ ORDER BY timestamp DESC
1092
+ LIMIT ?`,
1093
+ [limit]
1094
+ );
1095
+ }
1096
+
829
1097
  return rows.map(row => this.rowToEvent(row));
830
1098
  }
831
1099
 
1100
+ /**
1101
+ * Record a memory retrieval for helpfulness tracking
1102
+ */
1103
+ async recordRetrieval(eventId: string, sessionId: string, score: number, query: string): Promise<void> {
1104
+ if (this.readOnly) return;
1105
+ await this.initialize();
1106
+
1107
+ const id = randomUUID();
1108
+ sqliteRun(
1109
+ this.db,
1110
+ `INSERT INTO memory_helpfulness (id, event_id, session_id, retrieval_score, query_preview, created_at)
1111
+ VALUES (?, ?, ?, ?, ?, datetime('now'))`,
1112
+ [id, eventId, sessionId, score, query.slice(0, 100)]
1113
+ );
1114
+ }
1115
+
1116
+ /**
1117
+ * Evaluate helpfulness for all retrievals in a session
1118
+ * Called at session end - uses behavioral signals to compute score
1119
+ */
1120
+ async evaluateSessionHelpfulness(sessionId: string): Promise<void> {
1121
+ if (this.readOnly) return;
1122
+ await this.initialize();
1123
+
1124
+ // Get all retrieval records for this session
1125
+ const retrievals = sqliteAll<Record<string, unknown>>(
1126
+ this.db,
1127
+ `SELECT * FROM memory_helpfulness WHERE session_id = ? AND measured_at IS NULL`,
1128
+ [sessionId]
1129
+ );
1130
+
1131
+ if (retrievals.length === 0) return;
1132
+
1133
+ // Get session events to analyze behavior after retrieval
1134
+ const sessionEvents = sqliteAll<Record<string, unknown>>(
1135
+ this.db,
1136
+ `SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC`,
1137
+ [sessionId]
1138
+ );
1139
+
1140
+ const promptEvents = sessionEvents.filter((e: any) => e.event_type === 'user_prompt');
1141
+ const toolEvents = sessionEvents.filter((e: any) => e.event_type === 'tool_observation');
1142
+
1143
+ // Count successful vs failed tools
1144
+ let toolSuccessCount = 0;
1145
+ let toolTotalCount = toolEvents.length;
1146
+ for (const t of toolEvents) {
1147
+ try {
1148
+ const content = JSON.parse(t.content as string);
1149
+ if (content.success !== false) toolSuccessCount++;
1150
+ } catch {
1151
+ toolSuccessCount++; // Assume success if can't parse
1152
+ }
1153
+ }
1154
+ const toolSuccessRatio = toolTotalCount > 0 ? toolSuccessCount / toolTotalCount : 0.5;
1155
+
1156
+ for (const retrieval of retrievals) {
1157
+ const retrievalTime = retrieval.created_at as string;
1158
+
1159
+ // 1. Session continued after retrieval?
1160
+ const eventsAfter = sessionEvents.filter((e: any) => e.timestamp > retrievalTime);
1161
+ const sessionContinued = eventsAfter.length > 0 ? 1 : 0;
1162
+
1163
+ // 2. How many prompts came after?
1164
+ const promptsAfter = promptEvents.filter((e: any) => e.timestamp > retrievalTime);
1165
+ const promptCountAfter = promptsAfter.length;
1166
+
1167
+ // 3. Was a similar query asked again? (simple word overlap check)
1168
+ const queryWords = new Set((retrieval.query_preview as string || '').toLowerCase().split(/\s+/).filter(w => w.length > 2));
1169
+ let wasReasked = 0;
1170
+ for (const p of promptsAfter) {
1171
+ const pWords = new Set((p.content as string).toLowerCase().split(/\s+/).filter((w: string) => w.length > 2));
1172
+ let overlap = 0;
1173
+ for (const w of queryWords) {
1174
+ if (pWords.has(w)) overlap++;
1175
+ }
1176
+ if (queryWords.size > 0 && overlap / queryWords.size > 0.5) {
1177
+ wasReasked = 1;
1178
+ break;
1179
+ }
1180
+ }
1181
+
1182
+ // Calculate helpfulness score
1183
+ const retrievalScore = retrieval.retrieval_score as number || 0;
1184
+ const helpfulnessScore = (
1185
+ 0.30 * Math.min(retrievalScore, 1.0) +
1186
+ 0.25 * (sessionContinued ? 1.0 : 0.0) +
1187
+ 0.25 * toolSuccessRatio +
1188
+ 0.20 * (wasReasked ? 0.0 : 1.0)
1189
+ );
1190
+
1191
+ sqliteRun(
1192
+ this.db,
1193
+ `UPDATE memory_helpfulness
1194
+ SET session_continued = ?, prompt_count_after = ?,
1195
+ tool_success_count = ?, tool_total_count = ?,
1196
+ was_reasked = ?, helpfulness_score = ?,
1197
+ measured_at = datetime('now')
1198
+ WHERE id = ?`,
1199
+ [sessionContinued, promptCountAfter, toolSuccessCount, toolTotalCount,
1200
+ wasReasked, helpfulnessScore, retrieval.id]
1201
+ );
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * Get most helpful memories ranked by helpfulness score
1207
+ */
1208
+ async getHelpfulMemories(limit: number = 10): Promise<Array<{
1209
+ eventId: string;
1210
+ summary: string;
1211
+ helpfulnessScore: number;
1212
+ accessCount: number;
1213
+ evaluationCount: number;
1214
+ }>> {
1215
+ await this.initialize();
1216
+
1217
+ const rows = sqliteAll<Record<string, unknown>>(
1218
+ this.db,
1219
+ `SELECT
1220
+ mh.event_id,
1221
+ AVG(mh.helpfulness_score) as avg_score,
1222
+ COUNT(*) as eval_count,
1223
+ e.content,
1224
+ e.access_count
1225
+ FROM memory_helpfulness mh
1226
+ JOIN events e ON e.id = mh.event_id
1227
+ WHERE mh.measured_at IS NOT NULL
1228
+ GROUP BY mh.event_id
1229
+ ORDER BY avg_score DESC
1230
+ LIMIT ?`,
1231
+ [limit]
1232
+ );
1233
+
1234
+ return rows.map(r => ({
1235
+ eventId: r.event_id as string,
1236
+ summary: (r.content as string).substring(0, 200) + ((r.content as string).length > 200 ? '...' : ''),
1237
+ helpfulnessScore: Math.round((r.avg_score as number) * 100) / 100,
1238
+ accessCount: (r.access_count as number) || 0,
1239
+ evaluationCount: r.eval_count as number
1240
+ }));
1241
+ }
1242
+
1243
+ /**
1244
+ * Get helpfulness statistics for dashboard
1245
+ */
1246
+ async getHelpfulnessStats(): Promise<{
1247
+ avgScore: number;
1248
+ totalEvaluated: number;
1249
+ totalRetrievals: number;
1250
+ helpful: number;
1251
+ neutral: number;
1252
+ unhelpful: number;
1253
+ }> {
1254
+ await this.initialize();
1255
+
1256
+ const stats = sqliteGet<Record<string, unknown>>(
1257
+ this.db,
1258
+ `SELECT
1259
+ AVG(helpfulness_score) as avg_score,
1260
+ COUNT(*) as total_evaluated,
1261
+ SUM(CASE WHEN helpfulness_score >= 0.7 THEN 1 ELSE 0 END) as helpful,
1262
+ SUM(CASE WHEN helpfulness_score >= 0.4 AND helpfulness_score < 0.7 THEN 1 ELSE 0 END) as neutral,
1263
+ SUM(CASE WHEN helpfulness_score < 0.4 THEN 1 ELSE 0 END) as unhelpful
1264
+ FROM memory_helpfulness
1265
+ WHERE measured_at IS NOT NULL`
1266
+ );
1267
+
1268
+ const totalRow = sqliteGet<Record<string, unknown>>(
1269
+ this.db,
1270
+ `SELECT COUNT(*) as total FROM memory_helpfulness`
1271
+ );
1272
+
1273
+ return {
1274
+ avgScore: Math.round(((stats?.avg_score as number) || 0) * 100) / 100,
1275
+ totalEvaluated: (stats?.total_evaluated as number) || 0,
1276
+ totalRetrievals: (totalRow?.total as number) || 0,
1277
+ helpful: (stats?.helpful as number) || 0,
1278
+ neutral: (stats?.neutral as number) || 0,
1279
+ unhelpful: (stats?.unhelpful as number) || 0
1280
+ };
1281
+ }
1282
+
832
1283
  /**
833
1284
  * Fast keyword search using FTS5
834
1285
  * Returns events matching the search query, ranked by relevance
@@ -912,6 +1363,143 @@ export class SQLiteEventStore {
912
1363
  return this.db;
913
1364
  }
914
1365
 
1366
+
1367
+ async recordRetrievalTrace(input: {
1368
+ sessionId?: string;
1369
+ projectHash?: string;
1370
+ queryText: string;
1371
+ strategy?: string;
1372
+ candidateEventIds: string[];
1373
+ selectedEventIds: string[];
1374
+ candidateDetails?: Array<{
1375
+ eventId: string;
1376
+ score: number;
1377
+ semanticScore?: number;
1378
+ lexicalScore?: number;
1379
+ recencyScore?: number;
1380
+ }>;
1381
+ selectedDetails?: Array<{
1382
+ eventId: string;
1383
+ score: number;
1384
+ semanticScore?: number;
1385
+ lexicalScore?: number;
1386
+ recencyScore?: number;
1387
+ }>;
1388
+ confidence?: string;
1389
+ fallbackTrace?: string[];
1390
+ }): Promise<void> {
1391
+ await this.initialize();
1392
+
1393
+ const traceId = randomUUID();
1394
+ sqliteRun(
1395
+ this.db,
1396
+ `INSERT INTO retrieval_traces (
1397
+ trace_id, session_id, project_hash, query_text, strategy,
1398
+ candidate_event_ids, selected_event_ids, candidate_details_json, selected_details_json,
1399
+ candidate_count, selected_count, confidence, fallback_trace
1400
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1401
+ [
1402
+ traceId,
1403
+ input.sessionId || null,
1404
+ input.projectHash || null,
1405
+ input.queryText,
1406
+ input.strategy || null,
1407
+ JSON.stringify(input.candidateEventIds || []),
1408
+ JSON.stringify(input.selectedEventIds || []),
1409
+ JSON.stringify(input.candidateDetails || []),
1410
+ JSON.stringify(input.selectedDetails || []),
1411
+ (input.candidateEventIds || []).length,
1412
+ (input.selectedEventIds || []).length,
1413
+ input.confidence || null,
1414
+ JSON.stringify(input.fallbackTrace || [])
1415
+ ]
1416
+ );
1417
+ }
1418
+
1419
+ async getRecentRetrievalTraces(limit: number = 50): Promise<Array<{
1420
+ traceId: string;
1421
+ sessionId?: string;
1422
+ projectHash?: string;
1423
+ queryText: string;
1424
+ strategy?: string;
1425
+ candidateEventIds: string[];
1426
+ selectedEventIds: string[];
1427
+ candidateDetails: Array<{
1428
+ eventId: string;
1429
+ score: number;
1430
+ semanticScore?: number;
1431
+ lexicalScore?: number;
1432
+ recencyScore?: number;
1433
+ }>;
1434
+ selectedDetails: Array<{
1435
+ eventId: string;
1436
+ score: number;
1437
+ semanticScore?: number;
1438
+ lexicalScore?: number;
1439
+ recencyScore?: number;
1440
+ }>;
1441
+ candidateCount: number;
1442
+ selectedCount: number;
1443
+ confidence?: string;
1444
+ fallbackTrace: string[];
1445
+ createdAt: Date;
1446
+ }>> {
1447
+ await this.initialize();
1448
+
1449
+ const rows = sqliteAll<Record<string, unknown>>(
1450
+ this.db,
1451
+ `SELECT * FROM retrieval_traces ORDER BY created_at DESC LIMIT ?`,
1452
+ [limit]
1453
+ );
1454
+
1455
+ return rows.map((row) => ({
1456
+ traceId: row.trace_id as string,
1457
+ sessionId: (row.session_id as string) || undefined,
1458
+ projectHash: (row.project_hash as string) || undefined,
1459
+ queryText: row.query_text as string,
1460
+ strategy: (row.strategy as string) || undefined,
1461
+ candidateEventIds: row.candidate_event_ids ? JSON.parse(row.candidate_event_ids as string) : [],
1462
+ selectedEventIds: row.selected_event_ids ? JSON.parse(row.selected_event_ids as string) : [],
1463
+ candidateDetails: row.candidate_details_json ? JSON.parse(row.candidate_details_json as string) : [],
1464
+ selectedDetails: row.selected_details_json ? JSON.parse(row.selected_details_json as string) : [],
1465
+ candidateCount: Number(row.candidate_count || 0),
1466
+ selectedCount: Number(row.selected_count || 0),
1467
+ confidence: (row.confidence as string) || undefined,
1468
+ fallbackTrace: row.fallback_trace ? JSON.parse(row.fallback_trace as string) : [],
1469
+ createdAt: toDateFromSQLite(row.created_at),
1470
+ }));
1471
+ }
1472
+
1473
+ async getRetrievalTraceStats(): Promise<{
1474
+ totalQueries: number;
1475
+ avgCandidateCount: number;
1476
+ avgSelectedCount: number;
1477
+ selectionRate: number;
1478
+ }> {
1479
+ await this.initialize();
1480
+
1481
+ const row = sqliteGet<Record<string, unknown>>(
1482
+ this.db,
1483
+ `SELECT
1484
+ COUNT(*) as total_queries,
1485
+ AVG(candidate_count) as avg_candidate_count,
1486
+ AVG(selected_count) as avg_selected_count,
1487
+ CASE
1488
+ WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
1489
+ ELSE 0
1490
+ END as selection_rate
1491
+ FROM retrieval_traces`,
1492
+ []
1493
+ );
1494
+
1495
+ return {
1496
+ totalQueries: Number(row?.total_queries || 0),
1497
+ avgCandidateCount: Number(row?.avg_candidate_count || 0),
1498
+ avgSelectedCount: Number(row?.avg_selected_count || 0),
1499
+ selectionRate: Number(row?.selection_rate || 0),
1500
+ };
1501
+ }
1502
+
915
1503
  /**
916
1504
  * Close database connection
917
1505
  */
@@ -919,6 +1507,201 @@ export class SQLiteEventStore {
919
1507
  sqliteClose(this.db);
920
1508
  }
921
1509
 
1510
+ /**
1511
+ * Get events grouped by turn_id for a session
1512
+ * Returns turns ordered by first event timestamp (newest first)
1513
+ */
1514
+ async getSessionTurns(sessionId: string, options?: { limit?: number; offset?: number }): Promise<Array<{
1515
+ turnId: string;
1516
+ events: MemoryEvent[];
1517
+ startedAt: Date;
1518
+ promptPreview: string;
1519
+ eventCount: number;
1520
+ toolCount: number;
1521
+ hasResponse: boolean;
1522
+ }>> {
1523
+ await this.initialize();
1524
+
1525
+ const limit = options?.limit || 20;
1526
+ const offset = options?.offset || 0;
1527
+
1528
+ // Get distinct turn_ids for this session, ordered by first event timestamp
1529
+ const turnRows = sqliteAll<{ turn_id: string; min_ts: string }>(
1530
+ this.db,
1531
+ `SELECT turn_id, MIN(timestamp) as min_ts
1532
+ FROM events
1533
+ WHERE session_id = ? AND turn_id IS NOT NULL
1534
+ GROUP BY turn_id
1535
+ ORDER BY min_ts DESC
1536
+ LIMIT ? OFFSET ?`,
1537
+ [sessionId, limit, offset]
1538
+ );
1539
+
1540
+ const turns: Array<{
1541
+ turnId: string;
1542
+ events: MemoryEvent[];
1543
+ startedAt: Date;
1544
+ promptPreview: string;
1545
+ eventCount: number;
1546
+ toolCount: number;
1547
+ hasResponse: boolean;
1548
+ }> = [];
1549
+
1550
+ for (const turnRow of turnRows) {
1551
+ const events = await this.getEventsByTurn(turnRow.turn_id);
1552
+
1553
+ const promptEvent = events.find(e => e.eventType === 'user_prompt');
1554
+ const toolEvents = events.filter(e => e.eventType === 'tool_observation');
1555
+ const hasResponse = events.some(e => e.eventType === 'agent_response');
1556
+
1557
+ turns.push({
1558
+ turnId: turnRow.turn_id,
1559
+ events,
1560
+ startedAt: toDateFromSQLite(turnRow.min_ts),
1561
+ promptPreview: promptEvent
1562
+ ? promptEvent.content.slice(0, 200) + (promptEvent.content.length > 200 ? '...' : '')
1563
+ : '(no prompt)',
1564
+ eventCount: events.length,
1565
+ toolCount: toolEvents.length,
1566
+ hasResponse
1567
+ });
1568
+ }
1569
+
1570
+ return turns;
1571
+ }
1572
+
1573
+ /**
1574
+ * Get all events for a specific turn_id
1575
+ */
1576
+ async getEventsByTurn(turnId: string): Promise<MemoryEvent[]> {
1577
+ await this.initialize();
1578
+
1579
+ const rows = sqliteAll<Record<string, unknown>>(
1580
+ this.db,
1581
+ `SELECT * FROM events WHERE turn_id = ? ORDER BY timestamp ASC`,
1582
+ [turnId]
1583
+ );
1584
+
1585
+ return rows.map(this.rowToEvent);
1586
+ }
1587
+
1588
+ /**
1589
+ * Count total turns for a session
1590
+ */
1591
+ async countSessionTurns(sessionId: string): Promise<number> {
1592
+ await this.initialize();
1593
+
1594
+ const row = sqliteGet<{ count: number }>(
1595
+ this.db,
1596
+ `SELECT COUNT(DISTINCT turn_id) as count
1597
+ FROM events
1598
+ WHERE session_id = ? AND turn_id IS NOT NULL`,
1599
+ [sessionId]
1600
+ );
1601
+
1602
+ return row?.count || 0;
1603
+ }
1604
+
1605
+ /**
1606
+ * Migrate existing events: backfill turn_id for events that have turnId in metadata
1607
+ * but no turn_id column value (for events stored before this migration)
1608
+ */
1609
+ async backfillTurnIds(): Promise<number> {
1610
+ await this.initialize();
1611
+
1612
+ // Find events with turnId in metadata JSON but no turn_id column value
1613
+ const rows = sqliteAll<{ id: string; metadata: string }>(
1614
+ this.db,
1615
+ `SELECT id, metadata FROM events
1616
+ WHERE turn_id IS NULL AND metadata IS NOT NULL AND metadata LIKE '%turnId%'`
1617
+ );
1618
+
1619
+ let updated = 0;
1620
+ for (const row of rows) {
1621
+ try {
1622
+ const metadata = JSON.parse(row.metadata);
1623
+ if (metadata.turnId) {
1624
+ sqliteRun(
1625
+ this.db,
1626
+ `UPDATE events SET turn_id = ? WHERE id = ?`,
1627
+ [metadata.turnId, row.id]
1628
+ );
1629
+ updated++;
1630
+ }
1631
+ } catch {
1632
+ // Skip rows with invalid JSON
1633
+ }
1634
+ }
1635
+
1636
+ return updated;
1637
+ }
1638
+
1639
+ /**
1640
+ * Delete all events for a session (for force reimport)
1641
+ */
1642
+ async deleteSessionEvents(sessionId: string): Promise<number> {
1643
+ await this.initialize();
1644
+
1645
+ // Get event IDs first for cascading deletes
1646
+ const events = sqliteAll<{ id: string }>(
1647
+ this.db,
1648
+ `SELECT id FROM events WHERE session_id = ?`,
1649
+ [sessionId]
1650
+ );
1651
+
1652
+ if (events.length === 0) return 0;
1653
+
1654
+ const eventIds = events.map(e => e.id);
1655
+ const placeholders = eventIds.map(() => '?').join(',');
1656
+
1657
+ // Drop FTS triggers to prevent SQLITE_CORRUPT_VTAB during bulk delete
1658
+ const ftsTriggersDropped: string[] = [];
1659
+ for (const triggerName of ['events_fts_delete', 'events_fts_update', 'events_fts_insert']) {
1660
+ try {
1661
+ sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
1662
+ ftsTriggersDropped.push(triggerName);
1663
+ } catch {
1664
+ // Trigger may not exist
1665
+ }
1666
+ }
1667
+
1668
+ // Delete from related tables first (some may not exist depending on DB version)
1669
+ for (const table of ['event_dedup', 'memory_levels', 'embedding_queue', 'embedding_outbox', 'vector_outbox']) {
1670
+ try {
1671
+ sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
1672
+ } catch {
1673
+ // Table may not exist
1674
+ }
1675
+ }
1676
+
1677
+ // Delete events
1678
+ const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
1679
+
1680
+ // Rebuild FTS index if we dropped triggers
1681
+ if (ftsTriggersDropped.length > 0) {
1682
+ try {
1683
+ // Rebuild FTS from remaining events
1684
+ sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
1685
+
1686
+ // Recreate triggers
1687
+ sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
1688
+ INSERT INTO events_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
1689
+ END`);
1690
+ sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
1691
+ INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
1692
+ END`);
1693
+ sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
1694
+ INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
1695
+ INSERT INTO events_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
1696
+ END`);
1697
+ } catch {
1698
+ // FTS rebuild failed - non-critical, will be rebuilt on next initialize
1699
+ }
1700
+ }
1701
+
1702
+ return result.changes || 0;
1703
+ }
1704
+
922
1705
  /**
923
1706
  * Convert database row to MemoryEvent
924
1707
  */
@@ -941,6 +1724,10 @@ export class SQLiteEventStore {
941
1724
  if (row.last_accessed_at !== undefined) {
942
1725
  event.last_accessed_at = row.last_accessed_at;
943
1726
  }
1727
+ // Include turn_id if present
1728
+ if (row.turn_id !== undefined && row.turn_id !== null) {
1729
+ event.turn_id = row.turn_id;
1730
+ }
944
1731
 
945
1732
  return event;
946
1733
  }