claude-memory-layer 1.0.11 → 1.0.13

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 (101) 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 +2389 -286
  29. package/dist/cli/index.js.map +4 -4
  30. package/dist/core/index.js +1017 -132
  31. package/dist/core/index.js.map +4 -4
  32. package/dist/hooks/post-tool-use.js +1347 -202
  33. package/dist/hooks/post-tool-use.js.map +4 -4
  34. package/dist/hooks/session-end.js +1339 -194
  35. package/dist/hooks/session-end.js.map +4 -4
  36. package/dist/hooks/session-start.js +1343 -198
  37. package/dist/hooks/session-start.js.map +4 -4
  38. package/dist/hooks/stop.js +1351 -206
  39. package/dist/hooks/stop.js.map +4 -4
  40. package/dist/hooks/user-prompt-submit.js +1347 -202
  41. package/dist/hooks/user-prompt-submit.js.map +4 -4
  42. package/dist/server/api/index.js +1436 -211
  43. package/dist/server/api/index.js.map +4 -4
  44. package/dist/server/index.js +1445 -220
  45. package/dist/server/index.js.map +4 -4
  46. package/dist/services/memory-service.js +1345 -199
  47. package/dist/services/memory-service.js.map +4 -4
  48. package/dist/ui/app.js +69 -2
  49. package/dist/ui/index.html +8 -0
  50. package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
  51. package/docs/MEMU_ADOPTION.md +40 -0
  52. package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
  53. package/memory/_index.md +405 -0
  54. package/memory/default/uncategorized/2026-02-25.md +4839 -0
  55. package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
  56. package/memory/specs/citations-system/2026-02-25.md +1121 -0
  57. package/memory/specs/endless-mode/2026-02-25.md +1392 -0
  58. package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
  59. package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
  60. package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
  61. package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
  62. package/memory/specs/private-tags/2026-02-25.md +1057 -0
  63. package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
  64. package/memory/specs/task-entity-system/2026-02-25.md +924 -0
  65. package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
  66. package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
  67. package/package.json +2 -1
  68. package/scripts/build.ts +6 -0
  69. package/scripts/bump-patch-version.sh +18 -0
  70. package/src/cli/index.ts +281 -2
  71. package/src/core/consolidated-store.ts +63 -1
  72. package/src/core/consolidation-worker.ts +115 -6
  73. package/src/core/event-store.ts +14 -0
  74. package/src/core/index.ts +1 -0
  75. package/src/core/ingest-interceptor.ts +80 -0
  76. package/src/core/markdown-mirror.ts +70 -0
  77. package/src/core/md-mirror.ts +92 -0
  78. package/src/core/mongo-sync-config.ts +165 -0
  79. package/src/core/mongo-sync-worker.ts +381 -0
  80. package/src/core/retriever.ts +540 -150
  81. package/src/core/sqlite-event-store.ts +350 -1
  82. package/src/core/tag-taxonomy.ts +51 -0
  83. package/src/core/types.ts +28 -0
  84. package/src/server/api/health.ts +53 -0
  85. package/src/server/api/index.ts +3 -1
  86. package/src/server/api/stats.ts +46 -1
  87. package/src/services/bootstrap-organizer.ts +443 -0
  88. package/src/services/codex-session-history-importer.ts +474 -0
  89. package/src/services/memory-service.ts +373 -68
  90. package/src/services/session-history-importer.ts +53 -25
  91. package/src/ui/app.js +69 -2
  92. package/src/ui/index.html +8 -0
  93. package/tests/bootstrap-organizer.test.ts +111 -0
  94. package/tests/consolidation-worker.test.ts +75 -0
  95. package/tests/ingest-interceptor.test.ts +38 -0
  96. package/tests/markdown-mirror.test.ts +85 -0
  97. package/tests/md-mirror.test.ts +50 -0
  98. package/tests/retriever-fallback-chain.test.ts +223 -0
  99. package/tests/retriever-strategy-scope.test.ts +97 -0
  100. package/tests/retriever.memu-adoption.test.ts +122 -0
  101. package/tests/sqlite-event-store-replication.test.ts +92 -0
@@ -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,
@@ -275,6 +291,24 @@ export class SQLiteEventStore {
275
291
  measured_at TEXT
276
292
  );
277
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
+
278
312
  -- Sync position tracking (for SQLite -> DuckDB sync)
279
313
  CREATE TABLE IF NOT EXISTS sync_positions (
280
314
  target_name TEXT PRIMARY KEY,
@@ -299,10 +333,14 @@ export class SQLiteEventStore {
299
333
  CREATE INDEX IF NOT EXISTS idx_working_set_relevance ON working_set(relevance_score);
300
334
  CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence);
301
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);
302
337
  CREATE INDEX IF NOT EXISTS idx_embedding_outbox_status ON embedding_outbox(status);
303
338
  CREATE INDEX IF NOT EXISTS idx_helpfulness_event ON memory_helpfulness(event_id);
304
339
  CREATE INDEX IF NOT EXISTS idx_helpfulness_session ON memory_helpfulness(session_id);
305
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);
306
344
 
307
345
  -- FTS5 Full-Text Search for fast keyword search
308
346
  CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
@@ -327,6 +365,19 @@ export class SQLiteEventStore {
327
365
  END;
328
366
  `);
329
367
 
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
+
330
381
  // Migrate existing events table to add new columns if they don't exist
331
382
  // Check if columns exist before trying to add them
332
383
  const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
@@ -455,6 +506,22 @@ export class SQLiteEventStore {
455
506
 
456
507
  transaction();
457
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
+
458
525
  return { success: true, eventId: id, isDuplicate: false };
459
526
  } catch (error) {
460
527
  return {
@@ -525,6 +592,114 @@ export class SQLiteEventStore {
525
592
  return rows.map(this.rowToEvent);
526
593
  }
527
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
+
528
703
  /**
529
704
  * Create or update session
530
705
  */
@@ -708,6 +883,43 @@ export class SQLiteEventStore {
708
883
  );
709
884
  }
710
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
+
711
923
  /**
712
924
  * Update memory level
713
925
  */
@@ -1151,6 +1363,143 @@ export class SQLiteEventStore {
1151
1363
  return this.db;
1152
1364
  }
1153
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
+
1154
1503
  /**
1155
1504
  * Close database connection
1156
1505
  */
@@ -0,0 +1,51 @@
1
+ export const TAG_NAMESPACES = {
2
+ SYSTEM: 'sys:',
3
+ QUALITY: 'q:',
4
+ PROJECT: 'proj:',
5
+ TOPIC: 'topic:',
6
+ TEMPORAL: 't:',
7
+ USER: 'user:',
8
+ AGENT: 'agent:'
9
+ } as const;
10
+
11
+ export const VALID_TAG_NAMESPACES = new Set<string>(Object.values(TAG_NAMESPACES));
12
+
13
+ export function parseTag(tag: string): { namespace?: string; value: string } {
14
+ const value = (tag || '').trim();
15
+ const idx = value.indexOf(':');
16
+ if (idx <= 0) return { value };
17
+
18
+ const namespace = `${value.slice(0, idx)}:`;
19
+ const tagValue = value.slice(idx + 1);
20
+ if (!tagValue) return { value };
21
+
22
+ return { namespace, value: tagValue };
23
+ }
24
+
25
+ export function validateTag(tag: string): boolean {
26
+ const normalized = (tag || '').trim();
27
+ if (!normalized) return false;
28
+
29
+ const { namespace } = parseTag(normalized);
30
+ if (!namespace) return true; // backward compatibility for legacy tags
31
+ return VALID_TAG_NAMESPACES.has(namespace);
32
+ }
33
+
34
+ export function withNamespace(value: string, namespace: string): string {
35
+ const clean = parseTag(value).value.trim();
36
+ return `${namespace}${clean}`;
37
+ }
38
+
39
+ export function normalizeTags(tags: unknown): string[] {
40
+ if (!Array.isArray(tags)) return [];
41
+
42
+ const dedup = new Set<string>();
43
+ for (const item of tags) {
44
+ if (typeof item !== 'string') continue;
45
+ const normalized = item.trim();
46
+ if (!validateTag(normalized)) continue;
47
+ dedup.add(normalized);
48
+ }
49
+
50
+ return [...dedup];
51
+ }
package/src/core/types.ts CHANGED
@@ -818,6 +818,34 @@ export interface ConsolidatedMemoryInput {
818
818
  confidence: number;
819
819
  }
820
820
 
821
+ // Long-term Rule (promoted from stable summaries)
822
+ export const ConsolidationRuleSchema = z.object({
823
+ ruleId: z.string(),
824
+ rule: z.string(),
825
+ topics: z.array(z.string()),
826
+ sourceMemoryIds: z.array(z.string()),
827
+ sourceEvents: z.array(z.string()),
828
+ confidence: z.number(),
829
+ createdAt: z.date()
830
+ });
831
+ export type ConsolidationRule = z.infer<typeof ConsolidationRuleSchema>;
832
+
833
+ export interface ConsolidationRuleInput {
834
+ rule: string;
835
+ topics: string[];
836
+ sourceMemoryIds: string[];
837
+ sourceEvents: string[];
838
+ confidence: number;
839
+ }
840
+
841
+ export interface ConsolidationCostQualityReport {
842
+ beforeTokenEstimate: number;
843
+ afterTokenEstimate: number;
844
+ reductionRatio: number;
845
+ qualityGuardPassed: boolean;
846
+ details: string;
847
+ }
848
+
821
849
  // Event Group (for consolidation)
822
850
  export interface EventGroup {
823
851
  topics: string[];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Health API
3
+ * Operational health checks including outbox backlog/failures
4
+ */
5
+
6
+ import { Hono } from 'hono';
7
+ import { getServiceFromQuery } from './utils.js';
8
+
9
+ export const healthRouter = new Hono();
10
+
11
+ // GET /api/health
12
+ healthRouter.get('/', async (c) => {
13
+ const memoryService = getServiceFromQuery(c);
14
+ try {
15
+ await memoryService.initialize();
16
+
17
+ const [stats, outbox] = await Promise.all([
18
+ memoryService.getStats(),
19
+ memoryService.getOutboxStats()
20
+ ]);
21
+
22
+ const outboxPending = outbox.embedding.pending + outbox.vector.pending;
23
+ const outboxFailed = outbox.embedding.failed + outbox.vector.failed;
24
+
25
+ const status = outboxFailed > 0 ? 'needs-attention' : 'ok';
26
+
27
+ return c.json({
28
+ status,
29
+ timestamp: new Date().toISOString(),
30
+ storage: {
31
+ totalEvents: stats.totalEvents,
32
+ vectorCount: stats.vectorCount
33
+ },
34
+ outbox: {
35
+ embedding: outbox.embedding,
36
+ vector: outbox.vector,
37
+ totals: {
38
+ pending: outboxPending,
39
+ failed: outboxFailed
40
+ }
41
+ },
42
+ levelStats: stats.levelStats
43
+ });
44
+ } catch (error) {
45
+ return c.json({
46
+ status: 'error',
47
+ timestamp: new Date().toISOString(),
48
+ error: (error as Error).message
49
+ }, 500);
50
+ } finally {
51
+ await memoryService.shutdown();
52
+ }
53
+ });
@@ -12,6 +12,7 @@ import { citationsRouter } from './citations.js';
12
12
  import { turnsRouter } from './turns.js';
13
13
  import { projectsRouter } from './projects.js';
14
14
  import { chatRouter } from './chat.js';
15
+ import { healthRouter } from './health.js';
15
16
 
16
17
  export const apiRouter = new Hono()
17
18
  .route('/sessions', sessionsRouter)
@@ -21,4 +22,5 @@ export const apiRouter = new Hono()
21
22
  .route('/citations', citationsRouter)
22
23
  .route('/turns', turnsRouter)
23
24
  .route('/projects', projectsRouter)
24
- .route('/chat', chatRouter);
25
+ .route('/chat', chatRouter)
26
+ .route('/health', healthRouter);
@@ -158,6 +158,8 @@ statsRouter.get('/', async (c) => {
158
158
  return acc;
159
159
  }, {} as Record<string, number>);
160
160
 
161
+ const retrievalTrace = await memoryService.getRetrievalTraceStats();
162
+
161
163
  return c.json({
162
164
  storage: {
163
165
  eventCount: stats.totalEvents,
@@ -175,7 +177,8 @@ statsRouter.get('/', async (c) => {
175
177
  heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
176
178
  heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024)
177
179
  },
178
- levelStats: stats.levelStats
180
+ levelStats: stats.levelStats,
181
+ retrievalTrace
179
182
  });
180
183
  } catch (error) {
181
184
  return c.json({ error: (error as Error).message }, 500);
@@ -291,6 +294,48 @@ statsRouter.get('/helpfulness', async (c) => {
291
294
  }
292
295
  });
293
296
 
297
+
298
+
299
+ // GET /api/stats/retrieval-traces - Get recent retrieval traces (query -> selected context)
300
+ statsRouter.get('/retrieval-traces', async (c) => {
301
+ const limit = parseInt(c.req.query('limit') || '50', 10);
302
+ const memoryService = getServiceFromQuery(c);
303
+
304
+ try {
305
+ await memoryService.initialize();
306
+ const traces = await memoryService.getRecentRetrievalTraces(limit);
307
+ const traceStats = await memoryService.getRetrievalTraceStats();
308
+
309
+ return c.json({
310
+ stats: traceStats,
311
+ traces: traces.map((t) => ({
312
+ traceId: t.traceId,
313
+ sessionId: t.sessionId || null,
314
+ projectHash: t.projectHash || null,
315
+ queryText: t.queryText,
316
+ strategy: t.strategy || null,
317
+ candidateEventIds: t.candidateEventIds,
318
+ selectedEventIds: t.selectedEventIds,
319
+ candidateDetails: t.candidateDetails || [],
320
+ selectedDetails: t.selectedDetails || [],
321
+ candidateCount: t.candidateCount,
322
+ selectedCount: t.selectedCount,
323
+ confidence: t.confidence || null,
324
+ fallbackTrace: t.fallbackTrace,
325
+ createdAt: t.createdAt.toISOString()
326
+ }))
327
+ });
328
+ } catch (error) {
329
+ return c.json({
330
+ stats: { totalQueries: 0, avgCandidateCount: 0, avgSelectedCount: 0, selectionRate: 0 },
331
+ traces: [],
332
+ error: (error as Error).message
333
+ }, 500);
334
+ } finally {
335
+ await memoryService.shutdown();
336
+ }
337
+ });
338
+
294
339
  // POST /api/stats/graduation/run - Force graduation evaluation
295
340
  statsRouter.post('/graduation/run', async (c) => {
296
341
  const memoryService = getServiceFromQuery(c);