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
package/src/ui/app.js CHANGED
@@ -11,6 +11,7 @@ const state = {
11
11
  sharedStats: null,
12
12
  mostAccessed: null,
13
13
  helpfulness: null,
14
+ retrievalTraces: null,
14
15
  currentLevel: 'L0',
15
16
  currentSort: 'recent',
16
17
  currentView: 'overview',
@@ -235,17 +236,19 @@ async function refreshData() {
235
236
  if(btn) btn.classList.add('loading');
236
237
 
237
238
  try {
238
- const [stats, shared, mostAccessed, helpfulness] = await Promise.all([
239
+ const [stats, shared, mostAccessed, helpfulness, retrievalTraces] = await Promise.all([
239
240
  fetch(apiUrl(`${API_BASE}/stats`)).then(r => r.json()).catch(() => null),
240
241
  fetch(apiUrl(`${API_BASE}/stats/shared`)).then(r => r.json()).catch(() => null),
241
242
  fetch(apiUrl(`${API_BASE}/stats/most-accessed`, { limit: 10 })).then(r => r.json()).catch(() => null),
242
- fetch(apiUrl(`${API_BASE}/stats/helpfulness`, { limit: 5 })).then(r => r.json()).catch(() => null)
243
+ fetch(apiUrl(`${API_BASE}/stats/helpfulness`, { limit: 5 })).then(r => r.json()).catch(() => null),
244
+ fetch(apiUrl(`${API_BASE}/stats/retrieval-traces`, { limit: 20 })).then(r => r.json()).catch(() => null)
243
245
  ]);
244
246
 
245
247
  state.stats = stats;
246
248
  state.sharedStats = shared;
247
249
  state.mostAccessed = mostAccessed;
248
250
  state.helpfulness = helpfulness;
251
+ state.retrievalTraces = retrievalTraces;
249
252
 
250
253
  updateStatsUI();
251
254
  updateSharedUI();
@@ -397,6 +400,7 @@ function updateMemoryUsageUI() {
397
400
  updateGraduationBars();
398
401
  updateHelpfulnessUI();
399
402
  updateMostHelpfulList();
403
+ updateRetrievalTraceUI();
400
404
  }
401
405
 
402
406
  function updateGraduationBars() {
@@ -483,6 +487,69 @@ function updateMostHelpfulList() {
483
487
  }).join('');
484
488
  }
485
489
 
490
+
491
+ function updateRetrievalTraceUI() {
492
+ const summaryEl = document.getElementById('retrieval-trace-summary');
493
+ const listEl = document.getElementById('retrieval-trace-list');
494
+ if (!summaryEl || !listEl) return;
495
+
496
+ const payload = state.retrievalTraces;
497
+ const stats = payload?.stats;
498
+ const traces = payload?.traces || [];
499
+
500
+ if (!stats || !Number.isFinite(stats.totalQueries) || stats.totalQueries === 0) {
501
+ summaryEl.innerHTML = '<span style="color:var(--text-muted);">No retrieval traces yet.</span>';
502
+ listEl.innerHTML = '<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">No query/context trace data</div>';
503
+ return;
504
+ }
505
+
506
+ const selectionRate = ((stats.selectionRate || 0) * 100).toFixed(1);
507
+ summaryEl.innerHTML = `
508
+ <div style="display:flex; gap:14px; flex-wrap:wrap; font-size:12px;">
509
+ <span><strong>${formatNumber(stats.totalQueries)}</strong> queries</span>
510
+ <span><strong>${Number(stats.avgCandidateCount || 0).toFixed(1)}</strong> avg candidates</span>
511
+ <span><strong>${Number(stats.avgSelectedCount || 0).toFixed(1)}</strong> avg selected</span>
512
+ <span><strong>${selectionRate}%</strong> selection rate</span>
513
+ </div>
514
+ `;
515
+
516
+ listEl.innerHTML = traces.slice(0, 8).map((t) => {
517
+ const ts = t.createdAt ? new Date(t.createdAt).toLocaleString() : '-';
518
+ const confidence = t.confidence || 'n/a';
519
+ const selected = Number(t.selectedCount || 0);
520
+ const candidates = Number(t.candidateCount || 0);
521
+ const selectedDetails = (t.selectedDetails || []).slice(0, 2);
522
+ const candidateDetails = (t.candidateDetails || []).slice(0, 3);
523
+ const selectedIdsHtml = selectedDetails.length > 0
524
+ ? selectedDetails.map((d) => {
525
+ const breakdown = `score=${Number(d.score || 0).toFixed(3)} · s=${Number(d.semanticScore || 0).toFixed(3)} · l=${Number(d.lexicalScore || 0).toFixed(3)} · r=${Number(d.recencyScore || 0).toFixed(3)}`;
526
+ return `<span class="event-type-badge" style="cursor:pointer;" onclick="openDetailModal('${d.eventId}')" title="${escapeHtml(breakdown)}">${escapeHtml((d.eventId || '').slice(0, 8))}...</span>`;
527
+ }).join(' ')
528
+ : ((t.selectedEventIds || []).slice(0, 2).map((id) => `<span class="event-type-badge" style="cursor:pointer;" onclick="openDetailModal('${id}')">${escapeHtml((id || '').slice(0, 8))}...</span>`).join(' ') || '-');
529
+
530
+ const scoreBreakdownHtml = selectedDetails.length > 0
531
+ ? selectedDetails.map((d) => `<div style="font-size:10px; color:var(--text-muted);">${escapeHtml((d.eventId || '').slice(0, 8))}... → score ${Number(d.score || 0).toFixed(3)} (s ${Number(d.semanticScore || 0).toFixed(3)}, l ${Number(d.lexicalScore || 0).toFixed(3)}, r ${Number(d.recencyScore || 0).toFixed(3)})</div>`).join('')
532
+ : '';
533
+
534
+ return `
535
+ <div class="shared-item" style="align-items:flex-start;">
536
+ <div class="shared-info" style="align-items:flex-start; flex-direction:column; gap:4px;">
537
+ <span style="font-size:12px; color:var(--text-secondary);"><strong>Q:</strong> ${escapeHtml((t.queryText || '').slice(0, 120))}</span>
538
+ <span style="font-size:11px; color:var(--text-muted);">${ts} · strategy=${escapeHtml(t.strategy || 'auto')} · conf=${escapeHtml(confidence)}</span>
539
+ <span style="font-size:11px; color:var(--text-muted);">selected IDs: ${selectedIdsHtml}</span>
540
+ <span style="font-size:11px; color:var(--text-muted);">candidates: ${candidateDetails.map((d) => `<span class=\"event-type-badge\" style=\"cursor:pointer;\" onclick=\"openDetailModal('${d.eventId}')\">${escapeHtml((d.eventId || '').slice(0, 8))}...</span>`).join(' ') || '-'}</span>
541
+ ${scoreBreakdownHtml}
542
+ </div>
543
+ <div style="display:flex; flex-direction:column; align-items:flex-end; gap:2px; min-width:68px;">
544
+ <span style="font-size:13px; font-weight:600; color:var(--accent-primary);">${selected}/${candidates}</span>
545
+ <span style="font-size:10px; color:var(--text-muted);">sel/cand</span>
546
+ </div>
547
+ </div>
548
+ `;
549
+ }).join('');
550
+ }
551
+
552
+
486
553
  // --- Charts ---
487
554
 
488
555
  async function initActivityChart() {
package/src/ui/index.html CHANGED
@@ -271,6 +271,14 @@
271
271
  <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
272
272
  </div>
273
273
  </div>
274
+
275
+ <div style="margin-top:20px;">
276
+ <div class="section-label">Retrieval Trace (1:1)</div>
277
+ <div id="retrieval-trace-summary" style="padding:8px 0; font-size:13px; color:var(--text-muted);">Loading...</div>
278
+ <div id="retrieval-trace-list" class="shared-list">
279
+ <div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
280
+ </div>
281
+ </div>
274
282
  </div>
275
283
 
276
284
  </div>
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { bootstrapKnowledgeBase } from '../src/services/bootstrap-organizer.js';
7
+
8
+ async function makeTempRepo(): Promise<string> {
9
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cml-bootstrap-'));
10
+ execSync('git init', { cwd: root, stdio: 'ignore' });
11
+ execSync('git config user.email test@example.com', { cwd: root, stdio: 'ignore' });
12
+ execSync('git config user.name test-bot', { cwd: root, stdio: 'ignore' });
13
+
14
+ await fs.mkdir(path.join(root, 'src'), { recursive: true });
15
+ await fs.mkdir(path.join(root, 'tests'), { recursive: true });
16
+
17
+ await fs.writeFile(path.join(root, 'src', 'index.ts'), 'export const hello = () => "world";\n', 'utf8');
18
+ await fs.writeFile(path.join(root, 'src', 'worker.ts'), 'export const runWorker = () => true;\n', 'utf8');
19
+ await fs.writeFile(path.join(root, 'tests', 'index.test.ts'), 'describe("x", () => {});\n', 'utf8');
20
+
21
+ execSync('git add .', { cwd: root, stdio: 'ignore' });
22
+ execSync('git commit -m "feat: initial project scaffolding"', { cwd: root, stdio: 'ignore' });
23
+
24
+ await fs.writeFile(path.join(root, 'src', 'worker.ts'), 'export const runWorker = () => "ok";\n', 'utf8');
25
+ execSync('git add src/worker.ts', { cwd: root, stdio: 'ignore' });
26
+ execSync('git commit -m "refactor: stabilize worker return type"', { cwd: root, stdio: 'ignore' });
27
+
28
+ return root;
29
+ }
30
+
31
+ describe('bootstrapKnowledgeBase', () => {
32
+ it('generates structured bootstrap outputs with metadata', async () => {
33
+ const repo = await makeTempRepo();
34
+ const outDir = path.join(repo, '.generated-kb');
35
+
36
+ const result = await bootstrapKnowledgeBase({
37
+ repoPath: repo,
38
+ outDir,
39
+ since: '365 days ago',
40
+ maxCommits: 50
41
+ });
42
+
43
+ expect(result.fileCount).toBeGreaterThan(0);
44
+ expect(result.commitCount).toBeGreaterThan(0);
45
+
46
+ const expected = [
47
+ path.join(outDir, 'overview', 'overview.md'),
48
+ path.join(outDir, 'modules', 'src.md'),
49
+ path.join(outDir, 'decisions', 'decisions.md'),
50
+ path.join(outDir, 'timeline', 'timeline.md'),
51
+ path.join(outDir, 'glossary', 'glossary.md'),
52
+ path.join(outDir, 'sources', 'manifest.md'),
53
+ path.join(outDir, 'sources', 'manifest.json')
54
+ ];
55
+
56
+ for (const file of expected) {
57
+ const stat = await fs.stat(file);
58
+ expect(stat.isFile()).toBe(true);
59
+ }
60
+
61
+ const overview = await fs.readFile(path.join(outDir, 'overview', 'overview.md'), 'utf8');
62
+ expect(overview).toContain('deterministicPipeline: true');
63
+ expect(overview).toContain('- confidence:');
64
+ expect(overview).toContain('- source:');
65
+
66
+ const decisions = await fs.readFile(path.join(outDir, 'decisions', 'decisions.md'), 'utf8');
67
+ expect(decisions).toContain('commit:');
68
+
69
+ const manifestJson = JSON.parse(await fs.readFile(path.join(outDir, 'sources', 'manifest.json'), 'utf8')) as {
70
+ deterministicPipeline: boolean;
71
+ outputs: string[];
72
+ };
73
+ expect(manifestJson.deterministicPipeline).toBe(true);
74
+ expect(manifestJson.outputs.length).toBeGreaterThan(0);
75
+ });
76
+
77
+ it('supports incremental bootstrap mode with manifest baseline', async () => {
78
+ const repo = await makeTempRepo();
79
+ const outDir = path.join(repo, '.generated-kb');
80
+
81
+ await bootstrapKnowledgeBase({
82
+ repoPath: repo,
83
+ outDir,
84
+ since: '365 days ago',
85
+ maxCommits: 50,
86
+ incremental: false
87
+ });
88
+
89
+ await fs.writeFile(path.join(repo, 'src', 'index.ts'), 'export const hello = () => "world2";\n', 'utf8');
90
+ execSync('git add src/index.ts', { cwd: repo, stdio: 'ignore' });
91
+ execSync('git commit -m "feat: change index output"', { cwd: repo, stdio: 'ignore' });
92
+
93
+ const result = await bootstrapKnowledgeBase({
94
+ repoPath: repo,
95
+ outDir,
96
+ maxCommits: 50,
97
+ incremental: true
98
+ });
99
+
100
+ expect(result.commitCount).toBeGreaterThan(0);
101
+
102
+ const manifestJson = JSON.parse(await fs.readFile(path.join(outDir, 'sources', 'manifest.json'), 'utf8')) as {
103
+ mode: string;
104
+ options?: { incremental?: boolean };
105
+ stats?: { modulesGenerated?: number };
106
+ };
107
+ expect(manifestJson.mode).toBe('incremental');
108
+ expect(manifestJson.options?.incremental).toBe(true);
109
+ expect((manifestJson.stats?.modulesGenerated ?? 0)).toBeGreaterThan(0);
110
+ });
111
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { ConsolidationWorker } from '../src/core/consolidation-worker.js';
4
+ import type { EndlessModeConfig, MemoryEvent } from '../src/core/types.js';
5
+
6
+ function makeEvent(id: string, content: string, hoursAgo = 20): MemoryEvent {
7
+ return {
8
+ id,
9
+ eventType: 'user_prompt',
10
+ sessionId: 's1',
11
+ timestamp: new Date(Date.now() - hoursAgo * 60 * 60 * 1000),
12
+ content,
13
+ canonicalKey: id,
14
+ dedupeKey: id,
15
+ metadata: {},
16
+ };
17
+ }
18
+
19
+ describe('ConsolidationWorker hierarchy automation', () => {
20
+ it('creates consolidated memories, promotes rules, and returns cost-quality report', async () => {
21
+ const events = [
22
+ makeEvent('e1', 'implement auth bug fix and add tests for token refresh'),
23
+ makeEvent('e2', 'fix auth error in middleware and update tests'),
24
+ makeEvent('e3', 'auth feature update with regression test and bug notes'),
25
+ makeEvent('e4', 'implement retry logic for auth and fix issue with token cache'),
26
+ makeEvent('e5', 'add integration test for auth flow and bug reproduction'),
27
+ ];
28
+
29
+ const created: Array<{ memoryId: string; summary: string; topics: string[]; sourceEvents: string[]; confidence: number }> = [];
30
+ const rules: Array<{ rule: string; sourceMemoryIds: string[] }> = [];
31
+
32
+ const workingSetStore = {
33
+ async get() {
34
+ return { recentEvents: events, lastActivity: new Date(), continuityScore: 0.8 };
35
+ },
36
+ async prune(_ids: string[]) {
37
+ return;
38
+ }
39
+ };
40
+
41
+ const consolidatedStore = {
42
+ async isAlreadyConsolidated() { return false; },
43
+ async create(input: any) {
44
+ const memoryId = `m-${created.length + 1}`;
45
+ created.push({ memoryId, ...input });
46
+ return memoryId;
47
+ },
48
+ async get(memoryId: string) {
49
+ return created.find((m) => m.memoryId === memoryId) || null;
50
+ },
51
+ async hasRuleForSourceMemory() { return false; },
52
+ async createRule(input: any) {
53
+ rules.push({ rule: input.rule, sourceMemoryIds: input.sourceMemoryIds });
54
+ return `r-${rules.length}`;
55
+ }
56
+ };
57
+
58
+ const config: EndlessModeConfig = {
59
+ enabled: true,
60
+ workingSet: { maxEvents: 100, timeWindowHours: 24, minRelevanceScore: 0.5 },
61
+ consolidation: { triggerIntervalMs: 3600000, triggerEventCount: 3, triggerIdleMs: 1000, useLLMSummarization: false },
62
+ continuity: { minScoreForSeamless: 0.7, topicDecayHours: 48 }
63
+ };
64
+
65
+ const worker = new ConsolidationWorker(workingSetStore as any, consolidatedStore as any, config);
66
+ const out = await worker.forceRunWithReport();
67
+
68
+ expect(out.consolidatedCount).toBeGreaterThan(0);
69
+ expect(out.promotedRuleCount).toBeGreaterThan(0);
70
+ expect(out.report.beforeTokenEstimate).toBeGreaterThan(0);
71
+ expect(out.report.reductionRatio).toBeGreaterThanOrEqual(0);
72
+ expect(out.report.qualityGuardPassed).toBe(true);
73
+ expect(rules[0]?.sourceMemoryIds?.length).toBeGreaterThan(0);
74
+ });
75
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { IngestInterceptorRegistry, mergeHierarchicalMetadata } from '../src/core/ingest-interceptor.js';
3
+
4
+ describe('IngestInterceptorRegistry', () => {
5
+ it('runs before/after interceptors in staged order', async () => {
6
+ const registry = new IngestInterceptorRegistry();
7
+ const stages: string[] = [];
8
+
9
+ registry.registerBefore((ctx) => stages.push(`before:${ctx.operation}`));
10
+ registry.registerAfter((ctx) => stages.push(`after:${ctx.operation}`));
11
+
12
+ const event = {
13
+ eventType: 'user_prompt' as const,
14
+ sessionId: 's1',
15
+ timestamp: new Date(),
16
+ content: 'hello'
17
+ };
18
+
19
+ await registry.run('before', { operation: 'user_prompt', sessionId: 's1', event });
20
+ await registry.run('after', { operation: 'user_prompt', sessionId: 's1', event });
21
+
22
+ expect(stages).toEqual(['before:user_prompt', 'after:user_prompt']);
23
+ });
24
+ });
25
+
26
+ describe('mergeHierarchicalMetadata', () => {
27
+ it('deep merges nested metadata without clobbering siblings', () => {
28
+ const merged = mergeHierarchicalMetadata(
29
+ { scope: { project: { id: 'alpha', env: 'dev' } }, ingest: { source: 'hook' } },
30
+ { scope: { project: { env: 'prod' }, turn: { id: 't1' } } }
31
+ );
32
+
33
+ expect(merged).toEqual({
34
+ scope: { project: { id: 'alpha', env: 'prod' }, turn: { id: 't1' } },
35
+ ingest: { source: 'hook' }
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs/promises';
5
+ import { randomUUID } from 'crypto';
6
+ import { buildMirrorPath, MarkdownMirror } from '../src/core/markdown-mirror.js';
7
+ import { SQLiteEventStore } from '../src/core/sqlite-event-store.js';
8
+
9
+ describe('markdown mirror', () => {
10
+ it('builds sanitized path with defaults', () => {
11
+ const event = {
12
+ id: randomUUID(),
13
+ eventType: 'user_prompt' as const,
14
+ sessionId: 's1',
15
+ timestamp: new Date('2026-02-24T00:49:00.000Z'),
16
+ content: 'hello',
17
+ canonicalKey: 'k',
18
+ dedupeKey: 'd',
19
+ metadata: {
20
+ namespace: 'Team ../Prod',
21
+ categoryPath: ['Ops & Alerts', 'Night Shift']
22
+ }
23
+ };
24
+
25
+ const out = buildMirrorPath('/tmp/root', event);
26
+ expect(out).toContain(path.join('memory', 'team-prod', 'ops-alerts', 'night-shift', '2026-02-24.md'));
27
+ });
28
+
29
+ it('appends without overwriting existing content', async () => {
30
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cml-md-mirror-'));
31
+ try {
32
+ const file = path.join(root, 'memory', 'default', 'session_summary', '2026-02-24.md');
33
+ await fs.mkdir(path.dirname(file), { recursive: true });
34
+ await fs.writeFile(file, 'PREEXISTING\n', 'utf8');
35
+
36
+ const mirror = new MarkdownMirror(root);
37
+ await mirror.append({
38
+ id: randomUUID(),
39
+ eventType: 'session_summary',
40
+ sessionId: 's2',
41
+ timestamp: new Date('2026-02-24T11:00:00.000Z'),
42
+ content: 'summary line',
43
+ canonicalKey: 'k2',
44
+ dedupeKey: 'd2',
45
+ metadata: {}
46
+ });
47
+
48
+ const content = await fs.readFile(file, 'utf8');
49
+ expect(content.startsWith('PREEXISTING\n')).toBe(true);
50
+ expect(content).toContain('summary line');
51
+ } finally {
52
+ await fs.rm(root, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ it('is wired to sqlite append flow', async () => {
57
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cml-md-flow-'));
58
+ try {
59
+ const store = new SQLiteEventStore(path.join(root, 'events.sqlite'), {
60
+ markdownMirrorRoot: root
61
+ });
62
+ const ts = new Date('2026-02-24T12:00:00.000Z');
63
+ const result = await store.append({
64
+ eventType: 'agent_response',
65
+ sessionId: 'sess-flow',
66
+ timestamp: ts,
67
+ content: 'flow content',
68
+ metadata: { namespace: 'app', category: 'responses' }
69
+ });
70
+
71
+ expect(result.success).toBe(true);
72
+
73
+ // mirror append is async fire-and-forget; allow small delay
74
+ await new Promise((r) => setTimeout(r, 50));
75
+
76
+ const file = path.join(root, 'memory', 'app', 'responses', '2026-02-24.md');
77
+ const content = await fs.readFile(file, 'utf8');
78
+ expect(content).toContain('flow content');
79
+
80
+ await store.close();
81
+ } finally {
82
+ await fs.rm(root, { recursive: true, force: true });
83
+ }
84
+ });
85
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import * as fs from 'node:fs';
5
+ import { buildMirrorPath, MarkdownMirror } from '../src/core/md-mirror.js';
6
+ import type { MemoryEventInput } from '../src/core/types.js';
7
+
8
+ function makeEvent(): MemoryEventInput {
9
+ return {
10
+ eventType: 'user_prompt',
11
+ sessionId: 'agent:main:test',
12
+ timestamp: new Date('2026-02-24T01:00:00.000Z'),
13
+ content: '아침 브리핑 포맷 기억해줘',
14
+ metadata: {
15
+ namespace: 'Briefing',
16
+ categoryPath: ['Preferences', 'Morning']
17
+ }
18
+ };
19
+ }
20
+
21
+ describe('MarkdownMirror', () => {
22
+ it('builds sanitized categorized path', () => {
23
+ const p = buildMirrorPath('/tmp/demo', makeEvent());
24
+ expect(p).toContain(path.join('memory', 'briefing', 'preferences', 'morning', '2026-02-24.md'));
25
+ });
26
+
27
+ it('uses uncategorized when category is missing', () => {
28
+ const ev = { ...makeEvent(), metadata: { namespace: 'Briefing' } };
29
+ const p = buildMirrorPath('/tmp/demo', ev);
30
+ expect(p).toContain(path.join('memory', 'briefing', 'uncategorized', '2026-02-24.md'));
31
+ });
32
+
33
+ it('appends without overwrite', async () => {
34
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cl-md-mirror-'));
35
+ const mirror = new MarkdownMirror(tmp);
36
+ const ev = makeEvent();
37
+
38
+ await mirror.append(ev, 'e1');
39
+ await mirror.append({ ...ev, content: '두번째 기록' }, 'e2');
40
+
41
+ const out = buildMirrorPath(tmp, ev);
42
+ const text = fs.readFileSync(out, 'utf8');
43
+ expect(text).toContain('e1');
44
+ expect(text).toContain('e2');
45
+ expect(text).toContain('두번째 기록');
46
+
47
+ const index = fs.readFileSync(path.join(tmp, 'memory', '_index.md'), 'utf8');
48
+ expect(index).toContain('memory/briefing/preferences/morning/2026-02-24.md');
49
+ });
50
+ });