claude-memory-layer 1.0.11 → 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 (99) 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/src/cli/index.ts +281 -2
  70. package/src/core/consolidated-store.ts +63 -1
  71. package/src/core/consolidation-worker.ts +115 -6
  72. package/src/core/event-store.ts +14 -0
  73. package/src/core/index.ts +1 -0
  74. package/src/core/ingest-interceptor.ts +80 -0
  75. package/src/core/markdown-mirror.ts +70 -0
  76. package/src/core/md-mirror.ts +92 -0
  77. package/src/core/mongo-sync-config.ts +165 -0
  78. package/src/core/mongo-sync-worker.ts +381 -0
  79. package/src/core/retriever.ts +540 -150
  80. package/src/core/sqlite-event-store.ts +350 -1
  81. package/src/core/tag-taxonomy.ts +51 -0
  82. package/src/core/types.ts +28 -0
  83. package/src/server/api/health.ts +53 -0
  84. package/src/server/api/index.ts +3 -1
  85. package/src/server/api/stats.ts +46 -1
  86. package/src/services/bootstrap-organizer.ts +443 -0
  87. package/src/services/codex-session-history-importer.ts +474 -0
  88. package/src/services/memory-service.ts +373 -68
  89. package/src/ui/app.js +69 -2
  90. package/src/ui/index.html +8 -0
  91. package/tests/bootstrap-organizer.test.ts +111 -0
  92. package/tests/consolidation-worker.test.ts +75 -0
  93. package/tests/ingest-interceptor.test.ts +38 -0
  94. package/tests/markdown-mirror.test.ts +85 -0
  95. package/tests/md-mirror.test.ts +50 -0
  96. package/tests/retriever-fallback-chain.test.ts +223 -0
  97. package/tests/retriever-strategy-scope.test.ts +97 -0
  98. package/tests/retriever.memu-adoption.test.ts +122 -0
  99. package/tests/sqlite-event-store-replication.test.ts +92 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-layer",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Claude Code plugin that learns from conversations to provide personalized assistance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "commander": "^12.0.0",
43
43
  "duckdb": "^0.10.0",
44
44
  "hono": "^4.0.0",
45
+ "mongodb": "^6.14.0",
45
46
  "zod": "^3.22.0"
46
47
  },
47
48
  "devDependencies": {
package/scripts/build.ts CHANGED
@@ -8,6 +8,8 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
10
  const outdir = 'dist';
11
+ const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8')) as { version?: string };
12
+ const appVersion = packageJson.version ?? '0.0.0';
11
13
 
12
14
  // Clean output directory
13
15
  if (fs.existsSync(outdir)) {
@@ -30,11 +32,15 @@ const commonOptions: esbuild.BuildOptions = {
30
32
  'duckdb',
31
33
  'better-sqlite3',
32
34
  'commander',
35
+ 'mongodb',
33
36
  'zod',
34
37
  'hono',
35
38
  'hono/cors',
36
39
  'hono/logger'
37
40
  ],
41
+ define: {
42
+ 'process.env.CLAUDE_MEMORY_LAYER_VERSION': JSON.stringify(appVersion)
43
+ },
38
44
  banner: {
39
45
  js: `import { createRequire } from 'module';
40
46
  import { fileURLToPath } from 'url';
package/src/cli/index.ts CHANGED
@@ -11,10 +11,14 @@ import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import {
13
13
  getDefaultMemoryService,
14
- getMemoryServiceForProject
14
+ getMemoryServiceForProject,
15
+ getProjectStoragePath
15
16
  } from '../services/memory-service.js';
16
17
  import { createSessionHistoryImporter, type ProgressEvent } from '../services/session-history-importer.js';
18
+ import { bootstrapKnowledgeBase } from '../services/bootstrap-organizer.js';
17
19
  import { startServer, stopServer, isServerRunning } from '../server/index.js';
20
+ import { SQLiteEventStore } from '../core/sqlite-event-store.js';
21
+ import { MongoSyncWorker } from '../core/mongo-sync-worker.js';
18
22
 
19
23
  // ============================================================
20
24
  // Hook Installation Utilities
@@ -107,7 +111,7 @@ const program = new Command();
107
111
  program
108
112
  .name('claude-memory-layer')
109
113
  .description('Claude Code Memory Plugin CLI')
110
- .version('1.0.0');
114
+ .version(process.env.CLAUDE_MEMORY_LAYER_VERSION || '0.0.0');
111
115
 
112
116
  // ============================================================
113
117
  // Install / Uninstall Commands
@@ -427,6 +431,103 @@ program
427
431
  }
428
432
  });
429
433
 
434
+ /**
435
+ * Mongo Sync command - sync local SQLite events with a shared MongoDB database (optional)
436
+ */
437
+ program
438
+ .command('mongo-sync')
439
+ .description('Sync events with MongoDB for multi-server collaboration (optional)')
440
+ .option('-p, --project <path>', 'Project path (defaults to cwd)')
441
+ .option('--mongo-uri <uri>', 'MongoDB connection URI (env: CLAUDE_MEMORY_MONGO_URI)')
442
+ .option('--mongo-db <name>', 'MongoDB database name (env: CLAUDE_MEMORY_MONGO_DB)')
443
+ .option('--mongo-project <key>', 'Remote project key (env: CLAUDE_MEMORY_MONGO_PROJECT, default: basename(projectPath))')
444
+ .option('--direction <dir>', 'push|pull|both', 'both')
445
+ .option('--batch-size <n>', 'Batch size', '500')
446
+ .option('--interval <ms>', 'Watch interval ms', '30000')
447
+ .option('--watch', 'Run continuously')
448
+ .action(async (options) => {
449
+ const projectPath = options.project || process.cwd();
450
+ const mongoUri = options.mongoUri || process.env.CLAUDE_MEMORY_MONGO_URI;
451
+ const mongoDb = options.mongoDb || process.env.CLAUDE_MEMORY_MONGO_DB;
452
+ const projectKey = options.mongoProject || process.env.CLAUDE_MEMORY_MONGO_PROJECT || path.basename(projectPath);
453
+ const direction = String(options.direction || 'both').toLowerCase();
454
+
455
+ if (!mongoUri || !mongoDb) {
456
+ console.error('\n❌ MongoDB sync is not configured.');
457
+ console.error(' Set --mongo-uri/--mongo-db or env CLAUDE_MEMORY_MONGO_URI/CLAUDE_MEMORY_MONGO_DB.\n');
458
+ process.exit(1);
459
+ }
460
+
461
+ if (!['push', 'pull', 'both'].includes(direction)) {
462
+ console.error('\n❌ Invalid --direction. Use: push | pull | both\n');
463
+ process.exit(1);
464
+ }
465
+
466
+ const storagePath = getProjectStoragePath(projectPath);
467
+ if (!fs.existsSync(storagePath)) {
468
+ fs.mkdirSync(storagePath, { recursive: true });
469
+ }
470
+
471
+ const batchSizeParsed = parseInt(options.batchSize, 10);
472
+ const intervalParsed = parseInt(options.interval, 10);
473
+ const batchSize = (Number.isFinite(batchSizeParsed) && batchSizeParsed > 0) ? batchSizeParsed : 500;
474
+ const intervalMs = (Number.isFinite(intervalParsed) && intervalParsed > 0) ? intervalParsed : 30000;
475
+
476
+ const sqliteStore = new SQLiteEventStore(path.join(storagePath, 'events.sqlite'));
477
+ const worker = new MongoSyncWorker(sqliteStore, {
478
+ uri: mongoUri,
479
+ dbName: mongoDb,
480
+ projectKey,
481
+ direction,
482
+ batchSize,
483
+ intervalMs
484
+ });
485
+
486
+ const runOnce = async () => {
487
+ const { pushed, pulled } = await worker.syncNow();
488
+ const ts = new Date().toISOString();
489
+ process.stdout.write(`[mongo-sync] ${ts} project=${projectKey} pushed=${pushed} pulled=${pulled}\n`);
490
+ };
491
+
492
+ try {
493
+ if (!options.watch) {
494
+ await runOnce();
495
+ await worker.shutdown();
496
+ sqliteStore.close();
497
+ return;
498
+ }
499
+
500
+ console.log(`[mongo-sync] Watch mode started (interval=${intervalMs}ms, project=${projectKey})`);
501
+
502
+ const handle = setInterval(() => {
503
+ runOnce().catch((err) => {
504
+ console.error('[mongo-sync] Sync failed:', err);
505
+ });
506
+ }, intervalMs);
507
+
508
+ const shutdown = async () => {
509
+ clearInterval(handle);
510
+ console.log('\n[mongo-sync] Shutting down...');
511
+ try {
512
+ await worker.shutdown();
513
+ } finally {
514
+ sqliteStore.close();
515
+ }
516
+ process.exit(0);
517
+ };
518
+
519
+ process.on('SIGINT', () => { void shutdown(); });
520
+ process.on('SIGTERM', () => { void shutdown(); });
521
+
522
+ // Run immediately, then keep alive
523
+ await runOnce();
524
+ await new Promise(() => {});
525
+ } catch (error) {
526
+ console.error('[mongo-sync] Failed:', error);
527
+ process.exit(1);
528
+ }
529
+ });
530
+
430
531
  /**
431
532
  * Render import progress to terminal
432
533
  */
@@ -502,6 +603,184 @@ function printImportSummary(result: import('../services/session-history-importer
502
603
  }
503
604
  }
504
605
 
606
+ function sanitizeSegment(input: string | undefined, fallback: string): string {
607
+ const v = (input || '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
608
+ return v || fallback;
609
+ }
610
+
611
+ async function listMarkdownFiles(root: string): Promise<string[]> {
612
+ const out: string[] = [];
613
+ const stack = [root];
614
+
615
+ while (stack.length > 0) {
616
+ const dir = stack.pop()!;
617
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
618
+ for (const e of entries) {
619
+ const full = path.join(dir, e.name);
620
+ if (e.isDirectory()) stack.push(full);
621
+ else if (e.isFile() && e.name.endsWith('.md') && e.name !== '_index.md') out.push(full);
622
+ }
623
+ }
624
+
625
+ return out.sort();
626
+ }
627
+
628
+ function deriveNamespaceCategory(sourceRoot: string, filePath: string): { namespace: string; categoryPath: string[] } {
629
+ const rel = path.relative(sourceRoot, filePath);
630
+ const dirSeg = path.dirname(rel).split(path.sep).filter(Boolean);
631
+
632
+ if (dirSeg.length >= 2) {
633
+ const namespace = sanitizeSegment(dirSeg[0], 'default');
634
+ const categoryPath = dirSeg.slice(1).map((s) => sanitizeSegment(s, 'uncategorized'));
635
+ return { namespace, categoryPath: categoryPath.length > 0 ? categoryPath : ['uncategorized'] };
636
+ }
637
+
638
+ return { namespace: 'default', categoryPath: ['uncategorized'] };
639
+ }
640
+
641
+ function extractImportEvidence(markdown: string): { confidence?: string; sources: string[] } {
642
+ const confidenceMatch = markdown.match(/^-\s*confidence:\s*([^\n]+)/m);
643
+ const sources = markdown
644
+ .split(/\r?\n/)
645
+ .map((line) => line.trim())
646
+ .filter((line) => line.startsWith('- source:'))
647
+ .map((line) => line.replace(/^-\s*source:\s*/i, '').trim())
648
+ .filter(Boolean)
649
+ .slice(0, 30);
650
+
651
+ return {
652
+ confidence: confidenceMatch ? confidenceMatch[1].trim() : undefined,
653
+ sources
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Organize-import command - import legacy markdown memories into structured mirror
659
+ */
660
+ program
661
+ .command('organize-import [sourceDir]')
662
+ .description('Import existing markdown memory files, or bootstrap knowledge docs from codebase/git when markdown is missing')
663
+ .option('-p, --project <path>', 'Project path (defaults to cwd)')
664
+ .option('--session <id>', 'Session id for imported events (default: import:organized)')
665
+ .option('--limit <n>', 'Limit number of files to import')
666
+ .option('--dry-run', 'Preview mapping without writing')
667
+ .option('--bootstrap', 'Force-generate structured markdown from codebase + git history before import')
668
+ .option('--bootstrap-if-empty', 'Auto-bootstrap when source has no markdown files (default: true)', true)
669
+ .option('--no-bootstrap-if-empty', 'Disable auto-bootstrap when source has no markdown files')
670
+ .option('--force-bootstrap', 'Run bootstrap even when markdown files exist')
671
+ .option('--repo <path>', 'Repository root for bootstrap analysis (default: project path)')
672
+ .option('--out <path>', 'Output directory for generated bootstrap markdown (default: <sourceDir>/bootstrap-kb)')
673
+ .option('--since <range>', 'Git history range for bootstrap (default: "180 days ago")')
674
+ .option('--max-commits <n>', 'Max commits to analyze for bootstrap (default: 1000)')
675
+ .option('--incremental', 'Use previous bootstrap manifest as baseline for incremental updates (default: true)', true)
676
+ .option('--no-incremental', 'Disable incremental bootstrap; regenerate full snapshot')
677
+ .action(async (sourceDir: string | undefined, options) => {
678
+ const projectPath = options.project || process.cwd();
679
+ const sessionId = options.session || 'import:organized';
680
+ const sourceRoot = path.resolve(sourceDir || options.out || projectPath);
681
+ const repoPath = path.resolve(options.repo || projectPath);
682
+
683
+ if (!fs.existsSync(sourceRoot)) {
684
+ fs.mkdirSync(sourceRoot, { recursive: true });
685
+ }
686
+
687
+ const service = getMemoryServiceForProject(projectPath);
688
+
689
+ try {
690
+ let activeSourceRoot = sourceRoot;
691
+ let importRoot = sourceRoot;
692
+ let files = await listMarkdownFiles(importRoot);
693
+ const hasMarkdown = files.length > 0;
694
+ const shouldBootstrap = Boolean(options.forceBootstrap || options.bootstrap || (!hasMarkdown && options.bootstrapIfEmpty));
695
+
696
+ if (shouldBootstrap) {
697
+ const outDir = path.resolve(options.out || path.join(sourceRoot, 'bootstrap-kb'));
698
+ const since = options.since || '180 days ago';
699
+ const maxCommits = options.maxCommits ? Math.max(1, parseInt(options.maxCommits, 10)) : 1000;
700
+
701
+ console.log('\n🧠 Bootstrapping markdown knowledge base...');
702
+ const bootstrap = await bootstrapKnowledgeBase({
703
+ repoPath,
704
+ outDir,
705
+ since,
706
+ maxCommits,
707
+ incremental: options.incremental
708
+ });
709
+ console.log(` Repo: ${repoPath}`);
710
+ console.log(` Output: ${bootstrap.outDir}`);
711
+ console.log(` Files analyzed: ${bootstrap.fileCount}`);
712
+ console.log(` Commits analyzed: ${bootstrap.commitCount}`);
713
+ console.log(` Modules: ${bootstrap.moduleCount}`);
714
+
715
+ activeSourceRoot = outDir;
716
+ importRoot = outDir;
717
+ files = await listMarkdownFiles(importRoot);
718
+ }
719
+
720
+ if (files.length === 0) {
721
+ console.error('\n❌ organize-import found no markdown files to import.\n');
722
+ process.exit(1);
723
+ }
724
+
725
+ const limit = options.limit ? Math.max(1, parseInt(options.limit, 10)) : files.length;
726
+ const targets = files.slice(0, limit);
727
+
728
+ console.log(`\n📦 organize-import`);
729
+ console.log(` Source: ${activeSourceRoot}`);
730
+ console.log(` Project: ${projectPath}`);
731
+ console.log(` Files: ${targets.length}${targets.length < files.length ? `/${files.length}` : ''}`);
732
+ console.log(` Dry-run: ${options.dryRun ? 'yes' : 'no'}\n`);
733
+
734
+ if (!options.dryRun) {
735
+ await service.initialize();
736
+ }
737
+
738
+ let imported = 0;
739
+ let skipped = 0;
740
+
741
+ for (const file of targets) {
742
+ const text = await fs.promises.readFile(file, 'utf8');
743
+ if (!text.trim()) {
744
+ skipped += 1;
745
+ continue;
746
+ }
747
+
748
+ const { namespace, categoryPath } = deriveNamespaceCategory(activeSourceRoot, file);
749
+ const rel = path.relative(activeSourceRoot, file);
750
+ const evidence = extractImportEvidence(text);
751
+
752
+ if (options.dryRun) {
753
+ console.log(`- ${rel} -> namespace=${namespace} category=${categoryPath.join('/')} confidence=${evidence.confidence || 'n/a'} sources=${evidence.sources.length}`);
754
+ continue;
755
+ }
756
+
757
+ await service.storeSessionSummary(sessionId, text, {
758
+ namespace,
759
+ categoryPath,
760
+ confidence: evidence.confidence,
761
+ sources: evidence.sources,
762
+ import: {
763
+ sourceFile: rel,
764
+ importedAt: new Date().toISOString(),
765
+ bootstrap: shouldBootstrap === true
766
+ }
767
+ });
768
+ imported += 1;
769
+ }
770
+
771
+ if (!options.dryRun) {
772
+ const embed = await service.processPendingEmbeddings();
773
+ await service.shutdown();
774
+ console.log(`\n✅ Imported: ${imported}, skipped-empty: ${skipped}, embeddings: ${embed}\n`);
775
+ } else {
776
+ console.log(`\n✅ Dry-run complete (planned imports: ${targets.length - skipped}, skipped-empty: ${skipped})\n`);
777
+ }
778
+ } catch (error) {
779
+ console.error('\n❌ organize-import failed:', error);
780
+ process.exit(1);
781
+ }
782
+ });
783
+
505
784
  /**
506
785
  * Import command - import existing Claude Code sessions
507
786
  */
@@ -8,7 +8,9 @@ import { randomUUID } from 'crypto';
8
8
  import { dbRun, dbAll, toDate, type Database } from './db-wrapper.js';
9
9
  import type {
10
10
  ConsolidatedMemory,
11
- ConsolidatedMemoryInput
11
+ ConsolidatedMemoryInput,
12
+ ConsolidationRule,
13
+ ConsolidationRuleInput
12
14
  } from './types.js';
13
15
  import { EventStore } from './event-store.js';
14
16
 
@@ -170,6 +172,66 @@ export class ConsolidatedStore {
170
172
  );
171
173
  }
172
174
 
175
+ /**
176
+ * Create a long-term rule promoted from stable summaries
177
+ */
178
+ async createRule(input: ConsolidationRuleInput): Promise<string> {
179
+ const ruleId = randomUUID();
180
+
181
+ await dbRun(
182
+ this.db,
183
+ `INSERT INTO consolidated_rules
184
+ (rule_id, rule, topics, source_memory_ids, source_events, confidence, created_at)
185
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
186
+ [
187
+ ruleId,
188
+ input.rule,
189
+ JSON.stringify(input.topics),
190
+ JSON.stringify(input.sourceMemoryIds),
191
+ JSON.stringify(input.sourceEvents),
192
+ input.confidence
193
+ ]
194
+ );
195
+
196
+ return ruleId;
197
+ }
198
+
199
+ async getRules(options?: { limit?: number }): Promise<ConsolidationRule[]> {
200
+ const limit = options?.limit || 100;
201
+ const rows = await dbAll<Record<string, unknown>>(
202
+ this.db,
203
+ `SELECT * FROM consolidated_rules ORDER BY confidence DESC, created_at DESC LIMIT ?`,
204
+ [limit]
205
+ );
206
+
207
+ return rows.map((row) => ({
208
+ ruleId: row.rule_id as string,
209
+ rule: row.rule as string,
210
+ topics: JSON.parse((row.topics as string) || '[]'),
211
+ sourceMemoryIds: JSON.parse((row.source_memory_ids as string) || '[]'),
212
+ sourceEvents: JSON.parse((row.source_events as string) || '[]'),
213
+ confidence: Number(row.confidence ?? 0.5),
214
+ createdAt: toDate(row.created_at) || new Date()
215
+ }));
216
+ }
217
+
218
+ async countRules(): Promise<number> {
219
+ const result = await dbAll<{ count: number }>(
220
+ this.db,
221
+ `SELECT COUNT(*) as count FROM consolidated_rules`
222
+ );
223
+ return result[0]?.count || 0;
224
+ }
225
+
226
+ async hasRuleForSourceMemory(memoryId: string): Promise<boolean> {
227
+ const rows = await dbAll<{ count: number }>(
228
+ this.db,
229
+ `SELECT COUNT(*) as count FROM consolidated_rules WHERE source_memory_ids LIKE ?`,
230
+ [`%"${memoryId}"%`]
231
+ );
232
+ return (rows[0]?.count || 0) > 0;
233
+ }
234
+
173
235
  /**
174
236
  * Get count of consolidated memories
175
237
  */
@@ -8,7 +8,8 @@ import type {
8
8
  EndlessModeConfig,
9
9
  MemoryEvent,
10
10
  EventGroup,
11
- WorkingSet
11
+ WorkingSet,
12
+ ConsolidationCostQualityReport
12
13
  } from './types.js';
13
14
  import { WorkingSetStore } from './working-set-store.js';
14
15
  import { ConsolidatedStore } from './consolidated-store.js';
@@ -62,7 +63,19 @@ export class ConsolidationWorker {
62
63
  * Force a consolidation run (manual trigger)
63
64
  */
64
65
  async forceRun(): Promise<number> {
65
- return await this.consolidate();
66
+ const out = await this.consolidateWithReport();
67
+ return out.consolidatedCount;
68
+ }
69
+
70
+ /**
71
+ * Force a consolidation run and return metrics report
72
+ */
73
+ async forceRunWithReport(): Promise<{
74
+ consolidatedCount: number;
75
+ promotedRuleCount: number;
76
+ report: ConsolidationCostQualityReport;
77
+ }> {
78
+ return this.consolidateWithReport();
66
79
  }
67
80
 
68
81
  /**
@@ -109,15 +122,29 @@ export class ConsolidationWorker {
109
122
  * Perform consolidation
110
123
  */
111
124
  private async consolidate(): Promise<number> {
125
+ const out = await this.consolidateWithReport();
126
+ return out.consolidatedCount;
127
+ }
128
+
129
+ private async consolidateWithReport(): Promise<{
130
+ consolidatedCount: number;
131
+ promotedRuleCount: number;
132
+ report: ConsolidationCostQualityReport;
133
+ }> {
112
134
  const workingSet = await this.workingSetStore.get();
113
135
 
114
136
  if (workingSet.recentEvents.length < 3) {
115
- return 0; // Not enough events to consolidate
137
+ return {
138
+ consolidatedCount: 0,
139
+ promotedRuleCount: 0,
140
+ report: this.buildCostQualityReport(workingSet.recentEvents, [], 0)
141
+ };
116
142
  }
117
143
 
118
144
  // Group events by topic
119
145
  const groups = this.groupByTopic(workingSet.recentEvents);
120
146
  let consolidatedCount = 0;
147
+ const createdMemoryIds: string[] = [];
121
148
 
122
149
  for (const group of groups) {
123
150
  // Require minimum 3 events per group
@@ -132,16 +159,18 @@ export class ConsolidationWorker {
132
159
  const summary = await this.summarize(group);
133
160
 
134
161
  // Create consolidated memory
135
- await this.consolidatedStore.create({
162
+ const memoryId = await this.consolidatedStore.create({
136
163
  summary,
137
164
  topics: group.topics,
138
165
  sourceEvents: eventIds,
139
166
  confidence: this.calculateConfidence(group)
140
167
  });
141
-
168
+ createdMemoryIds.push(memoryId);
142
169
  consolidatedCount++;
143
170
  }
144
171
 
172
+ const promotedRuleCount = await this.promoteStableSummariesToRules(createdMemoryIds);
173
+
145
174
  // Prune consolidated events from working set
146
175
  if (consolidatedCount > 0) {
147
176
  const consolidatedEventIds = groups
@@ -161,7 +190,87 @@ export class ConsolidationWorker {
161
190
  }
162
191
  }
163
192
 
164
- return consolidatedCount;
193
+ const report = this.buildCostQualityReport(workingSet.recentEvents, groups, consolidatedCount);
194
+ return { consolidatedCount, promotedRuleCount, report };
195
+ }
196
+
197
+ private async promoteStableSummariesToRules(memoryIds: string[]): Promise<number> {
198
+ let promoted = 0;
199
+
200
+ for (const memoryId of memoryIds) {
201
+ const memory = await this.consolidatedStore.get(memoryId);
202
+ if (!memory) continue;
203
+ if (memory.confidence < 0.55) continue;
204
+ if (memory.sourceEvents.length < 4) continue;
205
+
206
+ const exists = await this.consolidatedStore.hasRuleForSourceMemory(memoryId);
207
+ if (exists) continue;
208
+
209
+ const rule = this.buildRuleFromSummary(memory.summary, memory.topics);
210
+ if (!rule) continue;
211
+
212
+ await this.consolidatedStore.createRule({
213
+ rule,
214
+ topics: memory.topics,
215
+ sourceMemoryIds: [memory.memoryId],
216
+ sourceEvents: memory.sourceEvents,
217
+ confidence: Math.min(1, memory.confidence + 0.08)
218
+ });
219
+ promoted++;
220
+ }
221
+
222
+ return promoted;
223
+ }
224
+
225
+ private buildRuleFromSummary(summary: string, topics: string[]): string | null {
226
+ const lines = summary
227
+ .split(/\r?\n/)
228
+ .map((l) => l.trim())
229
+ .filter(Boolean)
230
+ .filter((l) => !l.toLowerCase().startsWith('topics:'));
231
+
232
+ const bullet = lines.find((l) => l.startsWith('- '))?.replace(/^-\s*/, '');
233
+ const seed = bullet || lines[0];
234
+ if (!seed || seed.length < 8) return null;
235
+
236
+ const topicPrefix = topics.length > 0 ? `[${topics.slice(0, 2).join(', ')}] ` : '';
237
+ return `${topicPrefix}${seed}`;
238
+ }
239
+
240
+ private buildCostQualityReport(
241
+ events: MemoryEvent[],
242
+ groups: EventGroup[],
243
+ consolidatedCount: number
244
+ ): ConsolidationCostQualityReport {
245
+ const beforeTokenEstimate = events.reduce((acc, e) => acc + this.estimateTokens(e.content), 0);
246
+
247
+ const afterSummaries = groups
248
+ .filter((g) => g.events.length >= 3)
249
+ .slice(0, Math.max(consolidatedCount, 1));
250
+
251
+ const afterTokenEstimate = afterSummaries.length > 0
252
+ ? afterSummaries.reduce((acc, g) => acc + this.estimateTokens(this.ruleBasedSummary(g)), 0)
253
+ : beforeTokenEstimate;
254
+
255
+ const reductionRatio = beforeTokenEstimate > 0
256
+ ? Math.max(0, (beforeTokenEstimate - afterTokenEstimate) / beforeTokenEstimate)
257
+ : 0;
258
+
259
+ const qualityGuardPassed = consolidatedCount === 0
260
+ ? true
261
+ : groups.filter((g) => g.events.length >= 3).every((g) => this.calculateConfidence(g) >= 0.55);
262
+
263
+ return {
264
+ beforeTokenEstimate,
265
+ afterTokenEstimate,
266
+ reductionRatio,
267
+ qualityGuardPassed,
268
+ details: `groups=${groups.length}, consolidated=${consolidatedCount}`
269
+ };
270
+ }
271
+
272
+ private estimateTokens(text: string): number {
273
+ return Math.ceil((text || '').length / 4);
165
274
  }
166
275
 
167
276
  /**
@@ -280,6 +280,19 @@ export class EventStore {
280
280
  )
281
281
  `);
282
282
 
283
+ // Consolidated Rules table (long-term stable memory)
284
+ await dbRun(this.db, `
285
+ CREATE TABLE IF NOT EXISTS consolidated_rules (
286
+ rule_id VARCHAR PRIMARY KEY,
287
+ rule TEXT NOT NULL,
288
+ topics JSON,
289
+ source_memory_ids JSON,
290
+ source_events JSON,
291
+ confidence FLOAT DEFAULT 0.5,
292
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
293
+ )
294
+ `);
295
+
283
296
  // Endless Mode Config table
284
297
  await dbRun(this.db, `
285
298
  CREATE TABLE IF NOT EXISTS endless_config (
@@ -314,6 +327,7 @@ export class EventStore {
314
327
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_working_set_expires ON working_set(expires_at)`);
315
328
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_working_set_relevance ON working_set(relevance_score DESC)`);
316
329
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence DESC)`);
330
+ await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_consolidated_rules_confidence ON consolidated_rules(confidence DESC)`);
317
331
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_continuity_created ON continuity_log(created_at)`);
318
332
 
319
333
  this.initialized = true;
package/src/core/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './event-store.js';
14
14
  export * from './sqlite-wrapper.js';
15
15
  export * from './sqlite-event-store.js';
16
16
  export * from './sync-worker.js';
17
+ export * from './mongo-sync-worker.js';
17
18
  export * from './entity-repo.js';
18
19
  export * from './edge-repo.js';
19
20