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.
- package/AGENTS.md +60 -0
- package/README.md +166 -2
- package/bootstrap-kb/decisions/decisions.md +244 -0
- package/bootstrap-kb/glossary/glossary.md +46 -0
- package/bootstrap-kb/modules/.claude-plugin.md +22 -0
- package/bootstrap-kb/modules/agents.md.md +15 -0
- package/bootstrap-kb/modules/claude.md.md +15 -0
- package/bootstrap-kb/modules/context.md.md +15 -0
- package/bootstrap-kb/modules/docs.md +18 -0
- package/bootstrap-kb/modules/handoff.md.md +15 -0
- package/bootstrap-kb/modules/package-lock.json.md +15 -0
- package/bootstrap-kb/modules/package.json.md +15 -0
- package/bootstrap-kb/modules/plan.md.md +15 -0
- package/bootstrap-kb/modules/readme.md.md +15 -0
- package/bootstrap-kb/modules/scripts.md +26 -0
- package/bootstrap-kb/modules/spec.md.md +15 -0
- package/bootstrap-kb/modules/specs.md +20 -0
- package/bootstrap-kb/modules/src.md +51 -0
- package/bootstrap-kb/modules/tests.md +42 -0
- package/bootstrap-kb/modules/tsconfig.json.md +15 -0
- package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
- package/bootstrap-kb/overview/overview.md +40 -0
- package/bootstrap-kb/sources/manifest.json +950 -0
- package/bootstrap-kb/sources/manifest.md +227 -0
- package/bootstrap-kb/timeline/timeline.md +57 -0
- package/d.sh +3 -0
- package/deploy.sh +3 -0
- package/dist/cli/index.js +2389 -286
- package/dist/cli/index.js.map +4 -4
- package/dist/core/index.js +1017 -132
- package/dist/core/index.js.map +4 -4
- package/dist/hooks/post-tool-use.js +1347 -202
- package/dist/hooks/post-tool-use.js.map +4 -4
- package/dist/hooks/session-end.js +1339 -194
- package/dist/hooks/session-end.js.map +4 -4
- package/dist/hooks/session-start.js +1343 -198
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +1351 -206
- package/dist/hooks/stop.js.map +4 -4
- package/dist/hooks/user-prompt-submit.js +1347 -202
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +1436 -211
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +1445 -220
- package/dist/server/index.js.map +4 -4
- package/dist/services/memory-service.js +1345 -199
- package/dist/services/memory-service.js.map +4 -4
- package/dist/ui/app.js +69 -2
- package/dist/ui/index.html +8 -0
- package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
- package/docs/MEMU_ADOPTION.md +40 -0
- package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
- package/memory/_index.md +405 -0
- package/memory/default/uncategorized/2026-02-25.md +4839 -0
- package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
- package/memory/specs/citations-system/2026-02-25.md +1121 -0
- package/memory/specs/endless-mode/2026-02-25.md +1392 -0
- package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
- package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
- package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
- package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
- package/memory/specs/private-tags/2026-02-25.md +1057 -0
- package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
- package/memory/specs/task-entity-system/2026-02-25.md +924 -0
- package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
- package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
- package/package.json +2 -1
- package/scripts/build.ts +6 -0
- package/scripts/bump-patch-version.sh +18 -0
- package/src/cli/index.ts +281 -2
- package/src/core/consolidated-store.ts +63 -1
- package/src/core/consolidation-worker.ts +115 -6
- package/src/core/event-store.ts +14 -0
- package/src/core/index.ts +1 -0
- package/src/core/ingest-interceptor.ts +80 -0
- package/src/core/markdown-mirror.ts +70 -0
- package/src/core/md-mirror.ts +92 -0
- package/src/core/mongo-sync-config.ts +165 -0
- package/src/core/mongo-sync-worker.ts +381 -0
- package/src/core/retriever.ts +540 -150
- package/src/core/sqlite-event-store.ts +350 -1
- package/src/core/tag-taxonomy.ts +51 -0
- package/src/core/types.ts +28 -0
- package/src/server/api/health.ts +53 -0
- package/src/server/api/index.ts +3 -1
- package/src/server/api/stats.ts +46 -1
- package/src/services/bootstrap-organizer.ts +443 -0
- package/src/services/codex-session-history-importer.ts +474 -0
- package/src/services/memory-service.ts +373 -68
- package/src/services/session-history-importer.ts +53 -25
- package/src/ui/app.js +69 -2
- package/src/ui/index.html +8 -0
- package/tests/bootstrap-organizer.test.ts +111 -0
- package/tests/consolidation-worker.test.ts +75 -0
- package/tests/ingest-interceptor.test.ts +38 -0
- package/tests/markdown-mirror.test.ts +85 -0
- package/tests/md-mirror.test.ts +50 -0
- package/tests/retriever-fallback-chain.test.ts +223 -0
- package/tests/retriever-strategy-scope.test.ts +97 -0
- package/tests/retriever.memu-adoption.test.ts +122 -0
- package/tests/sqlite-event-store-replication.test.ts +92 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Retriever } from '../src/core/retriever.js';
|
|
3
|
+
import type { MemoryEvent, MatchResult } from '../src/core/types.js';
|
|
4
|
+
import type { SearchResult } from '../src/core/vector-store.js';
|
|
5
|
+
|
|
6
|
+
class FakeEventStore {
|
|
7
|
+
constructor(private readonly events: Record<string, MemoryEvent>) {}
|
|
8
|
+
|
|
9
|
+
async getEvent(id: string): Promise<MemoryEvent | null> {
|
|
10
|
+
return this.events[id] || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async getSessionEvents(sessionId: string): Promise<MemoryEvent[]> {
|
|
14
|
+
return Object.values(this.events).filter((e) => e.sessionId === sessionId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getRecentEvents(): Promise<MemoryEvent[]> {
|
|
18
|
+
return Object.values(this.events);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async keywordSearch(query: string): Promise<Array<{ event: MemoryEvent; rank: number }>> {
|
|
22
|
+
const lowered = query.toLowerCase();
|
|
23
|
+
const matches = Object.values(this.events)
|
|
24
|
+
.filter((event) => event.content.toLowerCase().includes(lowered))
|
|
25
|
+
.map((event, index) => ({ event, rank: -(index + 1) }));
|
|
26
|
+
return matches;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class FakeVectorStore {
|
|
31
|
+
constructor(private readonly results: SearchResult[]) {}
|
|
32
|
+
|
|
33
|
+
async search(): Promise<SearchResult[]> {
|
|
34
|
+
return this.results;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class FakeEmbedder {
|
|
39
|
+
async embed(): Promise<{ vector: number[] }> {
|
|
40
|
+
return { vector: [0.1, 0.2, 0.3] };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class FakeMatcher {
|
|
45
|
+
matchSearchResults(results: SearchResult[]): MatchResult {
|
|
46
|
+
if (results.length === 0) {
|
|
47
|
+
return { match: null, confidence: 'none' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const top = results[0];
|
|
51
|
+
return {
|
|
52
|
+
match: {
|
|
53
|
+
event: {
|
|
54
|
+
id: top.eventId,
|
|
55
|
+
eventType: top.eventType as MemoryEvent['eventType'],
|
|
56
|
+
sessionId: top.sessionId,
|
|
57
|
+
timestamp: new Date(top.timestamp),
|
|
58
|
+
content: top.content,
|
|
59
|
+
canonicalKey: top.eventId,
|
|
60
|
+
dedupeKey: top.eventId,
|
|
61
|
+
metadata: {}
|
|
62
|
+
},
|
|
63
|
+
score: top.score
|
|
64
|
+
},
|
|
65
|
+
confidence: 'suggested'
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeEvent(id: string, content: string, metadata?: Record<string, unknown>): MemoryEvent {
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
eventType: 'user_prompt',
|
|
74
|
+
sessionId: 's1',
|
|
75
|
+
timestamp: new Date('2026-01-01T00:00:00.000Z'),
|
|
76
|
+
content,
|
|
77
|
+
canonicalKey: id,
|
|
78
|
+
dedupeKey: id,
|
|
79
|
+
metadata
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('Retriever memU-inspired enhancements', () => {
|
|
84
|
+
it('applies metadata scope filter with hierarchical key path', async () => {
|
|
85
|
+
const e1 = makeEvent('e1', 'first memory', { scope: { project: { id: 'alpha' } } });
|
|
86
|
+
const e2 = makeEvent('e2', 'second memory', { scope: { project: { id: 'beta' } } });
|
|
87
|
+
|
|
88
|
+
const retriever = new Retriever(
|
|
89
|
+
new FakeEventStore({ e1, e2 }) as any,
|
|
90
|
+
new FakeVectorStore([
|
|
91
|
+
{ id: '1', eventId: 'e1', content: e1.content, score: 0.9, sessionId: 's1', eventType: e1.eventType, timestamp: e1.timestamp.toISOString() },
|
|
92
|
+
{ id: '2', eventId: 'e2', content: e2.content, score: 0.89, sessionId: 's1', eventType: e2.eventType, timestamp: e2.timestamp.toISOString() }
|
|
93
|
+
]) as any,
|
|
94
|
+
new FakeEmbedder() as any,
|
|
95
|
+
new FakeMatcher() as any
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const result = await retriever.retrieve('memory', {
|
|
99
|
+
scope: { metadata: { 'scope.project.id': 'alpha' } }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.memories).toHaveLength(1);
|
|
103
|
+
expect(result.memories[0].event.id).toBe('e1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('uses fast strategy keyword retrieval when requested', async () => {
|
|
107
|
+
const e1 = makeEvent('e1', 'fix deployment issue with nginx');
|
|
108
|
+
const e2 = makeEvent('e2', 'random unrelated text');
|
|
109
|
+
|
|
110
|
+
const retriever = new Retriever(
|
|
111
|
+
new FakeEventStore({ e1, e2 }) as any,
|
|
112
|
+
new FakeVectorStore([]) as any,
|
|
113
|
+
new FakeEmbedder() as any,
|
|
114
|
+
new FakeMatcher() as any
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const result = await retriever.retrieve('deployment', { strategy: 'fast', topK: 5 });
|
|
118
|
+
|
|
119
|
+
expect(result.memories.length).toBeGreaterThan(0);
|
|
120
|
+
expect(result.memories[0].event.id).toBe('e1');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SQLiteEventStore replication helpers used by Mongo sync.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
|
|
10
|
+
import { SQLiteEventStore } from '../src/core/sqlite-event-store.js';
|
|
11
|
+
|
|
12
|
+
describe('SQLiteEventStore replication helpers', () => {
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
let storeA: SQLiteEventStore;
|
|
15
|
+
let storeB: SQLiteEventStore;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-memory-layer-test-'));
|
|
19
|
+
storeA = new SQLiteEventStore(path.join(tempDir, 'a.sqlite'));
|
|
20
|
+
storeB = new SQLiteEventStore(path.join(tempDir, 'b.sqlite'));
|
|
21
|
+
await storeA.initialize();
|
|
22
|
+
await storeB.initialize();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try { storeA.close(); } catch {}
|
|
27
|
+
try { storeB.close(); } catch {}
|
|
28
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('getEventsSinceRowid returns incremental batches in insertion order', async () => {
|
|
32
|
+
const sessionId = 'session-1';
|
|
33
|
+
|
|
34
|
+
await storeA.append({ eventType: 'user_prompt', sessionId, timestamp: new Date(), content: 'a' });
|
|
35
|
+
await storeA.append({ eventType: 'user_prompt', sessionId, timestamp: new Date(), content: 'b' });
|
|
36
|
+
await storeA.append({ eventType: 'user_prompt', sessionId, timestamp: new Date(), content: 'c' });
|
|
37
|
+
|
|
38
|
+
const batch1 = await storeA.getEventsSinceRowid(0, 10);
|
|
39
|
+
expect(batch1).toHaveLength(3);
|
|
40
|
+
expect(batch1.map(x => x.event.content)).toEqual(['a', 'b', 'c']);
|
|
41
|
+
|
|
42
|
+
// rowid should be strictly increasing
|
|
43
|
+
expect(batch1[0].rowid).toBeLessThan(batch1[1].rowid);
|
|
44
|
+
expect(batch1[1].rowid).toBeLessThan(batch1[2].rowid);
|
|
45
|
+
|
|
46
|
+
const lastRowid = batch1[2].rowid;
|
|
47
|
+
|
|
48
|
+
await storeA.append({ eventType: 'user_prompt', sessionId, timestamp: new Date(), content: 'd' });
|
|
49
|
+
|
|
50
|
+
const batch2 = await storeA.getEventsSinceRowid(lastRowid, 10);
|
|
51
|
+
expect(batch2).toHaveLength(1);
|
|
52
|
+
expect(batch2[0].event.content).toBe('d');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('importEvents preserves stable IDs and is idempotent via dedupeKey', async () => {
|
|
56
|
+
const sessionId = 'session-2';
|
|
57
|
+
const appendRes = await storeA.append({
|
|
58
|
+
eventType: 'user_prompt',
|
|
59
|
+
sessionId,
|
|
60
|
+
timestamp: new Date(),
|
|
61
|
+
content: 'hello world'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(appendRes.success).toBe(true);
|
|
65
|
+
const sourceEvent = await storeA.getEvent(appendRes.eventId!);
|
|
66
|
+
expect(sourceEvent).not.toBeNull();
|
|
67
|
+
|
|
68
|
+
const imported1 = await storeB.importEvents([sourceEvent!]);
|
|
69
|
+
expect(imported1.inserted).toBe(1);
|
|
70
|
+
expect(imported1.skipped).toBe(0);
|
|
71
|
+
|
|
72
|
+
const importedEvent = await storeB.getEvent(sourceEvent!.id);
|
|
73
|
+
expect(importedEvent?.content).toBe('hello world');
|
|
74
|
+
|
|
75
|
+
// Importing again should be a no-op
|
|
76
|
+
const imported2 = await storeB.importEvents([sourceEvent!]);
|
|
77
|
+
expect(imported2.inserted).toBe(0);
|
|
78
|
+
expect(imported2.skipped).toBe(1);
|
|
79
|
+
|
|
80
|
+
// append() should treat it as duplicate due to event_dedup entry
|
|
81
|
+
const dup = await storeB.append({
|
|
82
|
+
eventType: 'user_prompt',
|
|
83
|
+
sessionId,
|
|
84
|
+
timestamp: new Date(),
|
|
85
|
+
content: 'hello world'
|
|
86
|
+
});
|
|
87
|
+
expect(dup.success).toBe(true);
|
|
88
|
+
expect(dup.isDuplicate).toBe(true);
|
|
89
|
+
expect(dup.eventId).toBe(sourceEvent!.id);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|