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
@@ -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
+ });
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Retriever } from '../src/core/retriever.js';
3
+ import { Matcher } from '../src/core/matcher.js';
4
+ import type { MemoryEvent } from '../src/core/types.js';
5
+
6
+ function ev(id: string, content: string): MemoryEvent {
7
+ return {
8
+ id,
9
+ eventType: 'user_prompt',
10
+ sessionId: 's1',
11
+ timestamp: new Date('2026-02-24T00:00:00.000Z'),
12
+ content,
13
+ canonicalKey: id,
14
+ dedupeKey: id,
15
+ metadata: {}
16
+ };
17
+ }
18
+
19
+ describe('Retriever fallback chain', () => {
20
+ it('falls back from fast to deep when fast has no result', async () => {
21
+ const e = ev('e1', 'deep result memory');
22
+ let vectorCalls = 0;
23
+
24
+ const fakeEventStore = {
25
+ async keywordSearch() {
26
+ return [];
27
+ },
28
+ async getRecentEvents() {
29
+ return [e];
30
+ },
31
+ async getEvent(id: string) {
32
+ return id === 'e1' ? e : null;
33
+ },
34
+ async getSessionEvents() {
35
+ return [e];
36
+ }
37
+ };
38
+
39
+ const fakeVectorStore = {
40
+ async search() {
41
+ vectorCalls += 1;
42
+ return [{ id: 'v1', eventId: 'e1', content: e.content, score: 0.9, sessionId: 's1', eventType: e.eventType, timestamp: e.timestamp.toISOString() }];
43
+ }
44
+ };
45
+
46
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
47
+
48
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
49
+ const out = await retriever.retrieve('result', { strategy: 'auto', topK: 3, includeSessionContext: false });
50
+
51
+ expect(out.memories.length).toBeGreaterThan(0);
52
+ expect(vectorCalls).toBeGreaterThan(0);
53
+ expect(out.fallbackTrace).toContain('fallback:deep');
54
+ });
55
+
56
+ it('applies custom rerank weights when provided', async () => {
57
+ const e1 = ev('a', 'keyword hit exact');
58
+ const e2 = ev('b', 'less related');
59
+
60
+ const fakeEventStore = {
61
+ async keywordSearch() { return []; },
62
+ async getRecentEvents() { return [e1, e2]; },
63
+ async getEvent(id: string) { return id === 'a' ? e1 : id === 'b' ? e2 : null; },
64
+ async getSessionEvents() { return [e1, e2]; }
65
+ };
66
+
67
+ const fakeVectorStore = {
68
+ async search() {
69
+ return [
70
+ { id: 'v1', eventId: 'b', content: e2.content, score: 0.95, sessionId: 's1', eventType: e2.eventType, timestamp: e2.timestamp.toISOString() },
71
+ { id: 'v2', eventId: 'a', content: e1.content, score: 0.7, sessionId: 's1', eventType: e1.eventType, timestamp: e1.timestamp.toISOString() },
72
+ ];
73
+ }
74
+ };
75
+
76
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
77
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
78
+
79
+ const out = await retriever.retrieve('keyword hit', {
80
+ strategy: 'deep',
81
+ topK: 3,
82
+ includeSessionContext: false,
83
+ rerankWeights: { semantic: 0.2, lexical: 0.7, recency: 0.1 }
84
+ });
85
+
86
+ expect(out.memories[0]?.event.id).toBe('a');
87
+ });
88
+
89
+ it('applies TTL/decay penalty for stale low-overlap memories', async () => {
90
+ const old = {
91
+ ...ev('old', 'generic memory'),
92
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 120),
93
+ };
94
+ const fresh = {
95
+ ...ev('fresh', 'generic memory'),
96
+ timestamp: new Date(),
97
+ };
98
+
99
+ const fakeEventStore = {
100
+ async keywordSearch() { return []; },
101
+ async getRecentEvents() { return [old, fresh]; },
102
+ async getEvent(id: string) { return id === 'old' ? old : id === 'fresh' ? fresh : null; },
103
+ async getSessionEvents() { return [old, fresh]; }
104
+ };
105
+
106
+ const fakeVectorStore = {
107
+ async search() {
108
+ return [
109
+ { id: 'v1', eventId: 'old', content: old.content, score: 0.9, sessionId: old.sessionId, eventType: old.eventType, timestamp: old.timestamp.toISOString() },
110
+ { id: 'v2', eventId: 'fresh', content: fresh.content, score: 0.85, sessionId: fresh.sessionId, eventType: fresh.eventType, timestamp: fresh.timestamp.toISOString() },
111
+ ];
112
+ }
113
+ };
114
+
115
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
116
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
117
+
118
+ const out = await retriever.retrieve('different query', {
119
+ strategy: 'deep',
120
+ topK: 2,
121
+ includeSessionContext: false,
122
+ decayPolicy: { enabled: true, windowDays: 30, maxPenalty: 0.3 }
123
+ });
124
+
125
+ expect(out.memories[0]?.event.id).toBe('fresh');
126
+ });
127
+
128
+ it('merges rewritten deep query results when intentRewrite is enabled', async () => {
129
+ const a = ev('a', '원문 질의에서는 약한 결과');
130
+ const b = ev('b', '재작성 질의에서 강한 결과');
131
+
132
+ const fakeEventStore = {
133
+ async keywordSearch() { return []; },
134
+ async getRecentEvents() { return [a, b]; },
135
+ async getEvent(id: string) { return id === 'a' ? a : id === 'b' ? b : null; },
136
+ async getSessionEvents() { return [a, b]; }
137
+ };
138
+
139
+ let call = 0;
140
+ const fakeVectorStore = {
141
+ async search() {
142
+ call += 1;
143
+ if (call === 1) {
144
+ return [{ id: 'v1', eventId: 'a', content: a.content, score: 0.8, sessionId: 's1', eventType: a.eventType, timestamp: a.timestamp.toISOString() }];
145
+ }
146
+ return [{ id: 'v2', eventId: 'b', content: b.content, score: 0.95, sessionId: 's1', eventType: b.eventType, timestamp: b.timestamp.toISOString() }];
147
+ }
148
+ };
149
+
150
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
151
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
152
+ retriever.setQueryRewriter(async () => '확장된 재작성 질의');
153
+
154
+ const out = await retriever.retrieve('원문 질의', {
155
+ strategy: 'deep',
156
+ topK: 3,
157
+ includeSessionContext: false,
158
+ intentRewrite: true,
159
+ });
160
+
161
+ expect(out.memories[0]?.event.id).toBe('b');
162
+ });
163
+
164
+ it('expands related events with graph-hop retrieval', async () => {
165
+ const seed = ev('seed', 'seed event');
166
+ const neighbor = {
167
+ ...ev('neighbor', 'related artifact memory'),
168
+ metadata: { relatedEventIds: ['seed'] },
169
+ };
170
+ const seedWithEdge = { ...seed, metadata: { relatedEventIds: ['neighbor'] } };
171
+
172
+ const fakeEventStore = {
173
+ async keywordSearch() { return []; },
174
+ async getRecentEvents() { return [seedWithEdge, neighbor]; },
175
+ async getEvent(id: string) {
176
+ if (id === 'seed') return seedWithEdge;
177
+ if (id === 'neighbor') return neighbor;
178
+ return null;
179
+ },
180
+ async getSessionEvents() { return [seedWithEdge, neighbor]; }
181
+ };
182
+
183
+ const fakeVectorStore = {
184
+ async search() {
185
+ return [{ id: 'v1', eventId: 'seed', content: seedWithEdge.content, score: 0.95, sessionId: 's1', eventType: seedWithEdge.eventType, timestamp: seedWithEdge.timestamp.toISOString() }];
186
+ }
187
+ };
188
+
189
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
190
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
191
+
192
+ const out = await retriever.retrieve('seed event', {
193
+ strategy: 'deep',
194
+ topK: 5,
195
+ includeSessionContext: false,
196
+ graphHop: { enabled: true, maxHops: 1, hopPenalty: 0.1 }
197
+ });
198
+
199
+ const ids = out.memories.map((m) => m.event.id);
200
+ expect(ids).toContain('seed');
201
+ expect(ids).toContain('neighbor');
202
+ });
203
+
204
+ it('uses summary fallback when both fast and deep fail', async () => {
205
+ const e = ev('e2', 'keyword overlap fallback candidate');
206
+
207
+ const fakeEventStore = {
208
+ async keywordSearch() { return []; },
209
+ async getRecentEvents() { return [e]; },
210
+ async getEvent(id: string) { return id === 'e2' ? e : null; },
211
+ async getSessionEvents() { return [e]; }
212
+ };
213
+
214
+ const fakeVectorStore = { async search() { return []; } };
215
+ const fakeEmbedder = { async embed() { return { vector: [0.1, 0.2] }; } };
216
+
217
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
218
+ const out = await retriever.retrieve('fallback candidate', { strategy: 'auto', topK: 3, includeSessionContext: false });
219
+
220
+ expect(out.fallbackTrace).toContain('fallback:summary');
221
+ expect(out.memories[0]?.event.id).toBe('e2');
222
+ });
223
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Retriever } from '../src/core/retriever.js';
3
+ import { Matcher } from '../src/core/matcher.js';
4
+ import type { MemoryEvent } from '../src/core/types.js';
5
+
6
+ function ev(id: string, sessionId: string, eventType: MemoryEvent['eventType'], content: string, canonicalKey: string): MemoryEvent {
7
+ return {
8
+ id,
9
+ sessionId,
10
+ eventType,
11
+ content,
12
+ canonicalKey,
13
+ dedupeKey: `${sessionId}:${id}`,
14
+ timestamp: new Date('2026-02-24T00:00:00.000Z'),
15
+ metadata: {}
16
+ };
17
+ }
18
+
19
+ describe('Retriever strategy/scope', () => {
20
+ const e1 = ev('e1', 'agent:main:alpha', 'user_prompt', '아침 브리핑 선호', 'pref/briefing/morning');
21
+ const e2 = ev('e2', 'agent:main:beta', 'agent_response', '점심 이후 요약은 잘 안봄', 'pref/briefing/lunch');
22
+
23
+ const fakeEventStore = {
24
+ async keywordSearch() {
25
+ return [
26
+ { event: e1, rank: -0.1 },
27
+ { event: e2, rank: -0.2 }
28
+ ];
29
+ },
30
+ async getRecentEvents() {
31
+ return [e1, e2];
32
+ },
33
+ async getEvent(id: string) {
34
+ return id === 'e1' ? e1 : id === 'e2' ? e2 : null;
35
+ },
36
+ async getSessionEvents(sessionId: string) {
37
+ return [e1, e2].filter((x) => x.sessionId === sessionId);
38
+ }
39
+ };
40
+
41
+ const fakeVectorStore = {
42
+ async search() {
43
+ return [
44
+ {
45
+ id: 'v1',
46
+ eventId: 'e2',
47
+ content: e2.content,
48
+ score: 0.92,
49
+ sessionId: e2.sessionId,
50
+ eventType: e2.eventType,
51
+ timestamp: e2.timestamp.toISOString()
52
+ },
53
+ {
54
+ id: 'v2',
55
+ eventId: 'e1',
56
+ content: e1.content,
57
+ score: 0.8,
58
+ sessionId: e1.sessionId,
59
+ eventType: e1.eventType,
60
+ timestamp: e1.timestamp.toISOString()
61
+ }
62
+ ];
63
+ }
64
+ };
65
+
66
+ const fakeEmbedder = {
67
+ async embed() {
68
+ return { vector: [0.1, 0.2, 0.3] };
69
+ }
70
+ };
71
+
72
+ it('uses fast strategy keyword path', async () => {
73
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
74
+ const out = await retriever.retrieve('브리핑', { strategy: 'fast', topK: 2, includeSessionContext: false });
75
+
76
+ expect(out.memories.length).toBe(2);
77
+ expect(out.memories[0].event.id).toBe('e1');
78
+ });
79
+
80
+ it('applies scoped filters (session prefix + canonical prefix + includes)', async () => {
81
+ const retriever = new Retriever(fakeEventStore as any, fakeVectorStore as any, fakeEmbedder as any, new Matcher());
82
+
83
+ const out = await retriever.retrieve('브리핑', {
84
+ strategy: 'deep',
85
+ topK: 5,
86
+ includeSessionContext: false,
87
+ scope: {
88
+ sessionIdPrefix: 'agent:main:alpha',
89
+ canonicalKeyPrefix: 'pref/briefing/morning',
90
+ contentIncludes: ['아침']
91
+ }
92
+ });
93
+
94
+ expect(out.memories.length).toBe(1);
95
+ expect(out.memories[0].event.id).toBe('e1');
96
+ });
97
+ });