@exaudeus/memory-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
- package/dist/__tests__/clock-and-validators.test.js +237 -0
- package/dist/__tests__/config-manager.test.d.ts +1 -0
- package/dist/__tests__/config-manager.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +236 -0
- package/dist/__tests__/crash-journal.test.d.ts +1 -0
- package/dist/__tests__/crash-journal.test.js +203 -0
- package/dist/__tests__/e2e.test.d.ts +1 -0
- package/dist/__tests__/e2e.test.js +788 -0
- package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
- package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
- package/dist/__tests__/ephemeral.test.d.ts +1 -0
- package/dist/__tests__/ephemeral.test.js +435 -0
- package/dist/__tests__/git-service.test.d.ts +1 -0
- package/dist/__tests__/git-service.test.js +43 -0
- package/dist/__tests__/normalize.test.d.ts +1 -0
- package/dist/__tests__/normalize.test.js +161 -0
- package/dist/__tests__/store.test.d.ts +1 -0
- package/dist/__tests__/store.test.js +1153 -0
- package/dist/config-manager.d.ts +49 -0
- package/dist/config-manager.js +126 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +162 -0
- package/dist/crash-journal.d.ts +38 -0
- package/dist/crash-journal.js +198 -0
- package/dist/ephemeral-weights.json +1847 -0
- package/dist/ephemeral.d.ts +20 -0
- package/dist/ephemeral.js +516 -0
- package/dist/formatters.d.ts +10 -0
- package/dist/formatters.js +92 -0
- package/dist/git-service.d.ts +5 -0
- package/dist/git-service.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1197 -0
- package/dist/normalize.d.ts +2 -0
- package/dist/normalize.js +69 -0
- package/dist/store.d.ts +84 -0
- package/dist/store.js +813 -0
- package/dist/text-analyzer.d.ts +32 -0
- package/dist/text-analyzer.js +190 -0
- package/dist/thresholds.d.ts +39 -0
- package/dist/thresholds.js +75 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.js +33 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { MarkdownMemoryStore } from '../store.js';
|
|
7
|
+
import { DEFAULT_STORAGE_BUDGET_BYTES } from '../types.js';
|
|
8
|
+
import { stem, extractKeywords, jaccardSimilarity, containmentSimilarity, similarity, parseFilter, } from '../text-analyzer.js';
|
|
9
|
+
// Helper to create a temp directory for each test
|
|
10
|
+
async function createTempDir() {
|
|
11
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), 'memory-mcp-test-'));
|
|
12
|
+
}
|
|
13
|
+
async function cleanupTempDir(dir) {
|
|
14
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
function makeConfig(repoRoot) {
|
|
17
|
+
return { repoRoot, memoryPath: path.join(repoRoot, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES };
|
|
18
|
+
}
|
|
19
|
+
describe('MarkdownMemoryStore', () => {
|
|
20
|
+
let tempDir;
|
|
21
|
+
let store;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
tempDir = await createTempDir();
|
|
24
|
+
store = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
25
|
+
await store.init();
|
|
26
|
+
});
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
if (tempDir)
|
|
29
|
+
await cleanupTempDir(tempDir).catch(() => { });
|
|
30
|
+
});
|
|
31
|
+
describe('init', () => {
|
|
32
|
+
it('creates memory directory', async () => {
|
|
33
|
+
const memDir = path.join(tempDir, '.memory');
|
|
34
|
+
const stat = await fs.stat(memDir);
|
|
35
|
+
assert.ok(stat.isDirectory());
|
|
36
|
+
});
|
|
37
|
+
it('succeeds on empty directory', async () => {
|
|
38
|
+
const stats = await store.stats();
|
|
39
|
+
assert.strictEqual(stats.totalEntries, 0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('store', () => {
|
|
43
|
+
it('stores a basic entry', async () => {
|
|
44
|
+
const result = await store.store('architecture', 'Test Entry', 'This is test content');
|
|
45
|
+
assert.ok(result.stored);
|
|
46
|
+
if (!result.stored)
|
|
47
|
+
return; // narrow for TS
|
|
48
|
+
assert.ok(result.id.startsWith('arch-'));
|
|
49
|
+
assert.strictEqual(result.topic, 'architecture');
|
|
50
|
+
assert.strictEqual(result.confidence, 0.70); // agent-inferred default
|
|
51
|
+
});
|
|
52
|
+
it('stores with explicit trust level', async () => {
|
|
53
|
+
const result = await store.store('conventions', 'Code Style', 'Use tabs', [], 'user');
|
|
54
|
+
assert.ok(result.stored);
|
|
55
|
+
if (!result.stored)
|
|
56
|
+
return;
|
|
57
|
+
assert.strictEqual(result.confidence, 1.0);
|
|
58
|
+
});
|
|
59
|
+
it('persists to individual Markdown file', async () => {
|
|
60
|
+
const result = await store.store('architecture', 'Arch Entry', 'Architecture content here');
|
|
61
|
+
assert.ok(result.stored);
|
|
62
|
+
if (!result.stored)
|
|
63
|
+
return;
|
|
64
|
+
const filePath = path.join(tempDir, '.memory', 'architecture', `${result.id}.md`);
|
|
65
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
66
|
+
assert.ok(content.includes('# Arch Entry'));
|
|
67
|
+
assert.ok(content.includes('Architecture content here'));
|
|
68
|
+
assert.ok(content.includes('- **topic**: architecture'));
|
|
69
|
+
});
|
|
70
|
+
it('generates random hex IDs (no collisions)', async () => {
|
|
71
|
+
const r1 = await store.store('architecture', 'Entry 1', 'Content 1');
|
|
72
|
+
const r2 = await store.store('architecture', 'Entry 2', 'Content 2');
|
|
73
|
+
assert.ok(r1.stored && r2.stored);
|
|
74
|
+
if (!r1.stored || !r2.stored)
|
|
75
|
+
return;
|
|
76
|
+
assert.notStrictEqual(r1.id, r2.id);
|
|
77
|
+
assert.match(r1.id, /^arch-[0-9a-f]{8}$/);
|
|
78
|
+
assert.match(r2.id, /^arch-[0-9a-f]{8}$/);
|
|
79
|
+
});
|
|
80
|
+
it('overwrites entry with same title in same topic', async () => {
|
|
81
|
+
await store.store('conventions', 'Naming', 'Use camelCase');
|
|
82
|
+
const r2 = await store.store('conventions', 'Naming', 'Use snake_case');
|
|
83
|
+
assert.ok(r2.stored);
|
|
84
|
+
if (!r2.stored)
|
|
85
|
+
return;
|
|
86
|
+
assert.ok(r2.warning?.includes('Overwrote'));
|
|
87
|
+
const query = await store.query('conventions', 'full');
|
|
88
|
+
assert.strictEqual(query.totalEntries, 1);
|
|
89
|
+
assert.ok(query.entries[0].content?.includes('snake_case'));
|
|
90
|
+
});
|
|
91
|
+
it('uses correct ID prefix per topic', async () => {
|
|
92
|
+
const arch = await store.store('architecture', 'A', 'a');
|
|
93
|
+
const conv = await store.store('conventions', 'B', 'b');
|
|
94
|
+
const gotcha = await store.store('gotchas', 'C', 'c');
|
|
95
|
+
const recent = await store.store('recent-work', 'D', 'd');
|
|
96
|
+
assert.ok(arch.stored && conv.stored && gotcha.stored && recent.stored);
|
|
97
|
+
if (!arch.stored || !conv.stored || !gotcha.stored || !recent.stored)
|
|
98
|
+
return;
|
|
99
|
+
assert.ok(arch.id.startsWith('arch-'));
|
|
100
|
+
assert.ok(conv.id.startsWith('conv-'));
|
|
101
|
+
assert.ok(gotcha.id.startsWith('gotcha-'));
|
|
102
|
+
assert.ok(recent.id.startsWith('recent-'));
|
|
103
|
+
});
|
|
104
|
+
it('stores module-scoped entries in subdirectory', async () => {
|
|
105
|
+
const result = await store.store('modules/messaging', 'Messaging Module', 'Handles chat');
|
|
106
|
+
assert.ok(result.stored);
|
|
107
|
+
if (!result.stored)
|
|
108
|
+
return;
|
|
109
|
+
assert.ok(result.id.startsWith('mod-'));
|
|
110
|
+
// Verify file exists in modules/messaging/ subdirectory
|
|
111
|
+
const filePath = path.join(tempDir, '.memory', 'modules', 'messaging', `${result.id}.md`);
|
|
112
|
+
const stat = await fs.stat(filePath);
|
|
113
|
+
assert.ok(stat.isFile());
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('one file per entry', () => {
|
|
117
|
+
it('creates separate files for each entry', async () => {
|
|
118
|
+
const r1 = await store.store('architecture', 'Entry 1', 'Content 1');
|
|
119
|
+
const r2 = await store.store('architecture', 'Entry 2', 'Content 2');
|
|
120
|
+
assert.ok(r1.stored && r2.stored);
|
|
121
|
+
if (!r1.stored || !r2.stored)
|
|
122
|
+
return;
|
|
123
|
+
const archDir = path.join(tempDir, '.memory', 'architecture');
|
|
124
|
+
const files = await fs.readdir(archDir);
|
|
125
|
+
assert.strictEqual(files.length, 2);
|
|
126
|
+
assert.ok(files.includes(`${r1.id}.md`));
|
|
127
|
+
assert.ok(files.includes(`${r2.id}.md`));
|
|
128
|
+
});
|
|
129
|
+
it('each file contains exactly one entry', async () => {
|
|
130
|
+
const r1 = await store.store('gotchas', 'Gotcha 1', 'Watch out for this');
|
|
131
|
+
assert.ok(r1.stored);
|
|
132
|
+
if (!r1.stored)
|
|
133
|
+
return;
|
|
134
|
+
const filePath = path.join(tempDir, '.memory', 'gotchas', `${r1.id}.md`);
|
|
135
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
136
|
+
// Should have exactly one # heading
|
|
137
|
+
const headings = content.match(/^# /gm);
|
|
138
|
+
assert.strictEqual(headings?.length, 1);
|
|
139
|
+
});
|
|
140
|
+
it('deleting an entry removes the file', async () => {
|
|
141
|
+
const r1 = await store.store('conventions', 'To Delete', 'Temporary');
|
|
142
|
+
assert.ok(r1.stored);
|
|
143
|
+
if (!r1.stored)
|
|
144
|
+
return;
|
|
145
|
+
const filePath = path.join(tempDir, '.memory', 'conventions', `${r1.id}.md`);
|
|
146
|
+
// File exists
|
|
147
|
+
const stat = await fs.stat(filePath);
|
|
148
|
+
assert.ok(stat.isFile());
|
|
149
|
+
// Delete via correct
|
|
150
|
+
await store.correct(r1.id, '', 'delete');
|
|
151
|
+
// File is gone
|
|
152
|
+
await assert.rejects(fs.stat(filePath));
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('query', () => {
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
await store.store('architecture', 'Arch Pattern', 'Uses MVI architecture', [], 'user');
|
|
158
|
+
await store.store('conventions', 'Naming', 'Use camelCase for functions', [], 'agent-confirmed');
|
|
159
|
+
await store.store('gotchas', 'Build Gotcha', 'Must run pod install first', [], 'user');
|
|
160
|
+
});
|
|
161
|
+
it('queries all entries with wildcard scope', async () => {
|
|
162
|
+
const result = await store.query('*', 'brief');
|
|
163
|
+
assert.strictEqual(result.totalEntries, 3);
|
|
164
|
+
});
|
|
165
|
+
it('queries specific topic', async () => {
|
|
166
|
+
const result = await store.query('architecture', 'brief');
|
|
167
|
+
assert.strictEqual(result.totalEntries, 1);
|
|
168
|
+
assert.strictEqual(result.entries[0].title, 'Arch Pattern');
|
|
169
|
+
});
|
|
170
|
+
it('respects detail levels', async () => {
|
|
171
|
+
const brief = await store.query('architecture', 'brief');
|
|
172
|
+
const full = await store.query('architecture', 'full');
|
|
173
|
+
assert.ok(!brief.entries[0].content);
|
|
174
|
+
assert.ok(full.entries[0].content);
|
|
175
|
+
assert.ok(full.entries[0].trust);
|
|
176
|
+
assert.ok(full.entries[0].sources);
|
|
177
|
+
});
|
|
178
|
+
it('filters by keyword', async () => {
|
|
179
|
+
const result = await store.query('*', 'brief', 'MVI');
|
|
180
|
+
assert.strictEqual(result.totalEntries, 1);
|
|
181
|
+
assert.strictEqual(result.entries[0].title, 'Arch Pattern');
|
|
182
|
+
});
|
|
183
|
+
it('returns empty for non-matching filter', async () => {
|
|
184
|
+
const result = await store.query('*', 'brief', 'nonexistent-keyword-xyz');
|
|
185
|
+
assert.strictEqual(result.totalEntries, 0);
|
|
186
|
+
});
|
|
187
|
+
it('sorts by confidence descending', async () => {
|
|
188
|
+
const result = await store.query('*', 'brief');
|
|
189
|
+
assert.ok(result.entries[0].confidence >= result.entries[1].confidence);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('correct', () => {
|
|
193
|
+
it('appends to existing entry', async () => {
|
|
194
|
+
const stored = await store.store('conventions', 'Style', 'Use tabs');
|
|
195
|
+
assert.ok(stored.stored);
|
|
196
|
+
if (!stored.stored)
|
|
197
|
+
return;
|
|
198
|
+
const corrected = await store.correct(stored.id, 'Also use 2-space indentation for YAML', 'append');
|
|
199
|
+
assert.ok(corrected.corrected);
|
|
200
|
+
if (!corrected.corrected)
|
|
201
|
+
return;
|
|
202
|
+
assert.strictEqual(corrected.newConfidence, 1.0);
|
|
203
|
+
assert.strictEqual(corrected.trust, 'user');
|
|
204
|
+
const query = await store.query('conventions', 'full');
|
|
205
|
+
assert.ok(query.entries[0].content?.includes('tabs'));
|
|
206
|
+
assert.ok(query.entries[0].content?.includes('YAML'));
|
|
207
|
+
});
|
|
208
|
+
it('replaces existing entry content', async () => {
|
|
209
|
+
const stored = await store.store('conventions', 'Style', 'Use tabs');
|
|
210
|
+
assert.ok(stored.stored);
|
|
211
|
+
if (!stored.stored)
|
|
212
|
+
return;
|
|
213
|
+
const corrected = await store.correct(stored.id, 'Use spaces', 'replace');
|
|
214
|
+
assert.ok(corrected.corrected);
|
|
215
|
+
const query = await store.query('conventions', 'full');
|
|
216
|
+
assert.ok(!query.entries[0].content?.includes('tabs'));
|
|
217
|
+
assert.ok(query.entries[0].content?.includes('spaces'));
|
|
218
|
+
});
|
|
219
|
+
it('deletes an entry', async () => {
|
|
220
|
+
const stored = await store.store('conventions', 'Style', 'Use tabs');
|
|
221
|
+
assert.ok(stored.stored);
|
|
222
|
+
if (!stored.stored)
|
|
223
|
+
return;
|
|
224
|
+
const corrected = await store.correct(stored.id, '', 'delete');
|
|
225
|
+
assert.ok(corrected.corrected);
|
|
226
|
+
const query = await store.query('conventions', 'brief');
|
|
227
|
+
assert.strictEqual(query.totalEntries, 0);
|
|
228
|
+
});
|
|
229
|
+
it('returns error for non-existent entry', async () => {
|
|
230
|
+
const result = await store.correct('nonexistent-999', 'fix', 'replace');
|
|
231
|
+
assert.ok(!result.corrected);
|
|
232
|
+
if (result.corrected)
|
|
233
|
+
return;
|
|
234
|
+
assert.ok(result.error?.includes('not found'));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe('briefing', () => {
|
|
238
|
+
it('returns bootstrap suggestion when empty', async () => {
|
|
239
|
+
const result = await store.briefing();
|
|
240
|
+
assert.ok(result.briefing.includes('No knowledge stored'));
|
|
241
|
+
assert.ok(result.suggestion?.includes('bootstrap'));
|
|
242
|
+
});
|
|
243
|
+
it('generates briefing with entries', async () => {
|
|
244
|
+
await store.store('architecture', 'Pattern', 'Uses MVVM', [], 'user');
|
|
245
|
+
await store.store('gotchas', 'Build Issue', 'Must clean build folder', [], 'user');
|
|
246
|
+
const result = await store.briefing();
|
|
247
|
+
assert.ok(result.briefing.includes('Build Issue'));
|
|
248
|
+
assert.strictEqual(result.entryCount, 2);
|
|
249
|
+
});
|
|
250
|
+
it('prioritizes gotchas in briefing', async () => {
|
|
251
|
+
await store.store('architecture', 'Pattern', 'Uses MVVM', [], 'user');
|
|
252
|
+
await store.store('gotchas', 'Critical Bug', 'Avoid using force unwrap', [], 'user');
|
|
253
|
+
const result = await store.briefing();
|
|
254
|
+
const gotchaPos = result.briefing.indexOf('Gotchas');
|
|
255
|
+
const archPos = result.briefing.indexOf('Architecture');
|
|
256
|
+
if (gotchaPos >= 0 && archPos >= 0) {
|
|
257
|
+
assert.ok(gotchaPos < archPos, 'Gotchas should appear before Architecture');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe('stats', () => {
|
|
262
|
+
it('returns zero stats for empty store', async () => {
|
|
263
|
+
const stats = await store.stats();
|
|
264
|
+
assert.strictEqual(stats.totalEntries, 0);
|
|
265
|
+
assert.strictEqual(stats.corruptFiles, 0);
|
|
266
|
+
assert.deepStrictEqual(stats.byTrust, { 'user': 0, 'agent-confirmed': 0, 'agent-inferred': 0 });
|
|
267
|
+
});
|
|
268
|
+
it('returns correct counts', async () => {
|
|
269
|
+
await store.store('architecture', 'A', 'a', [], 'user');
|
|
270
|
+
await store.store('conventions', 'B', 'b', [], 'agent-confirmed');
|
|
271
|
+
await store.store('gotchas', 'C', 'c', [], 'agent-inferred');
|
|
272
|
+
const stats = await store.stats();
|
|
273
|
+
assert.strictEqual(stats.totalEntries, 3);
|
|
274
|
+
assert.strictEqual(stats.byTrust['user'], 1);
|
|
275
|
+
assert.strictEqual(stats.byTrust['agent-confirmed'], 1);
|
|
276
|
+
assert.strictEqual(stats.byTrust['agent-inferred'], 1);
|
|
277
|
+
assert.strictEqual(stats.byTopic['architecture'], 1);
|
|
278
|
+
});
|
|
279
|
+
it('reports memory path', async () => {
|
|
280
|
+
const stats = await store.stats();
|
|
281
|
+
assert.strictEqual(stats.memoryPath, path.join(tempDir, '.memory'));
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe('persistence and reload', () => {
|
|
285
|
+
it('survives restart (reload from disk)', async () => {
|
|
286
|
+
await store.store('architecture', 'Persistence Test', 'This should survive', [], 'user');
|
|
287
|
+
await store.store('gotchas', 'Gotcha Test', 'This too', [], 'agent-confirmed');
|
|
288
|
+
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
289
|
+
await store2.init();
|
|
290
|
+
const query = await store2.query('*', 'full');
|
|
291
|
+
assert.strictEqual(query.totalEntries, 2);
|
|
292
|
+
const archEntry = query.entries.find(e => e.title === 'Persistence Test');
|
|
293
|
+
assert.ok(archEntry);
|
|
294
|
+
assert.ok(archEntry.content?.includes('should survive'));
|
|
295
|
+
assert.strictEqual(archEntry.trust, 'user');
|
|
296
|
+
});
|
|
297
|
+
it('handles corrected entries across restart', async () => {
|
|
298
|
+
const stored = await store.store('conventions', 'Style', 'Use tabs', [], 'agent-inferred');
|
|
299
|
+
assert.ok(stored.stored);
|
|
300
|
+
if (!stored.stored)
|
|
301
|
+
return;
|
|
302
|
+
await store.correct(stored.id, 'Use spaces', 'replace');
|
|
303
|
+
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
304
|
+
await store2.init();
|
|
305
|
+
const query = await store2.query('conventions', 'full');
|
|
306
|
+
assert.strictEqual(query.totalEntries, 1);
|
|
307
|
+
assert.ok(query.entries[0].content?.includes('spaces'));
|
|
308
|
+
assert.strictEqual(query.entries[0].trust, 'user');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
describe('cross-process safety', () => {
|
|
312
|
+
it('two stores see each other\'s writes after reload', async () => {
|
|
313
|
+
const sharedConfig = makeConfig(tempDir);
|
|
314
|
+
const storeA = new MarkdownMemoryStore(sharedConfig);
|
|
315
|
+
const storeB = new MarkdownMemoryStore(sharedConfig);
|
|
316
|
+
await storeA.init();
|
|
317
|
+
await storeB.init();
|
|
318
|
+
await storeA.store('gotchas', 'Process A Gotcha', 'Found by process A', [], 'user');
|
|
319
|
+
const result = await storeB.query('gotchas', 'full');
|
|
320
|
+
assert.strictEqual(result.totalEntries, 1);
|
|
321
|
+
assert.strictEqual(result.entries[0].title, 'Process A Gotcha');
|
|
322
|
+
});
|
|
323
|
+
it('two stores writing different entries don\'t clobber', async () => {
|
|
324
|
+
const sharedConfig = makeConfig(tempDir);
|
|
325
|
+
const storeA = new MarkdownMemoryStore(sharedConfig);
|
|
326
|
+
const storeB = new MarkdownMemoryStore(sharedConfig);
|
|
327
|
+
await storeA.init();
|
|
328
|
+
await storeB.init();
|
|
329
|
+
await storeA.store('architecture', 'Pattern A', 'Content A');
|
|
330
|
+
await storeB.store('architecture', 'Pattern B', 'Content B');
|
|
331
|
+
const storeC = new MarkdownMemoryStore(sharedConfig);
|
|
332
|
+
await storeC.init();
|
|
333
|
+
const result = await storeC.query('architecture', 'brief');
|
|
334
|
+
assert.strictEqual(result.totalEntries, 2);
|
|
335
|
+
const titles = result.entries.map(e => e.title).sort();
|
|
336
|
+
assert.deepStrictEqual(titles, ['Pattern A', 'Pattern B']);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
describe('branch tagging', () => {
|
|
340
|
+
it('recent-work entries include branch field', async () => {
|
|
341
|
+
await store.store('recent-work', 'Session Context', 'Working on messaging');
|
|
342
|
+
const query = await store.query('recent-work', 'full', undefined, '*');
|
|
343
|
+
assert.strictEqual(query.totalEntries, 1);
|
|
344
|
+
assert.ok(query.entries[0].branch !== undefined);
|
|
345
|
+
});
|
|
346
|
+
it('non-recent-work entries do not have branch', async () => {
|
|
347
|
+
await store.store('architecture', 'Pattern', 'Uses MVI');
|
|
348
|
+
const query = await store.query('architecture', 'full');
|
|
349
|
+
assert.strictEqual(query.entries[0].branch, undefined);
|
|
350
|
+
});
|
|
351
|
+
it('recent-work stored in branch-scoped subdirectory', async () => {
|
|
352
|
+
const result = await store.store('recent-work', 'Session', 'Context');
|
|
353
|
+
assert.ok(result.stored);
|
|
354
|
+
const recentDir = path.join(tempDir, '.memory', 'recent-work');
|
|
355
|
+
const branchDirs = await fs.readdir(recentDir);
|
|
356
|
+
assert.strictEqual(branchDirs.length, 1);
|
|
357
|
+
const branchDir = path.join(recentDir, branchDirs[0]);
|
|
358
|
+
const files = await fs.readdir(branchDir);
|
|
359
|
+
assert.strictEqual(files.length, 1);
|
|
360
|
+
assert.ok(files[0].endsWith('.md'));
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
describe('bootstrap', () => {
|
|
364
|
+
it('seeds entries from repo structure', async () => {
|
|
365
|
+
await fs.mkdir(path.join(tempDir, 'src'), { recursive: true });
|
|
366
|
+
await fs.mkdir(path.join(tempDir, 'tests'), { recursive: true });
|
|
367
|
+
await fs.writeFile(path.join(tempDir, 'package.json'), '{}');
|
|
368
|
+
await fs.writeFile(path.join(tempDir, 'README.md'), '# Test Project\n\nA test project for unit tests.');
|
|
369
|
+
const results = await store.bootstrap();
|
|
370
|
+
const stored = results.filter(r => r.stored);
|
|
371
|
+
assert.ok(stored.length >= 2, `Expected at least 2 entries, got ${stored.length}`);
|
|
372
|
+
const topics = stored.map(r => r.topic);
|
|
373
|
+
assert.ok(topics.includes('architecture'), 'Should have architecture entries');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
describe('storage budget', () => {
|
|
377
|
+
it('rejects writes when budget exceeded', async () => {
|
|
378
|
+
const tinyConfig = {
|
|
379
|
+
repoRoot: tempDir,
|
|
380
|
+
memoryPath: path.join(tempDir, '.memory'),
|
|
381
|
+
storageBudgetBytes: 100,
|
|
382
|
+
};
|
|
383
|
+
const tinyStore = new MarkdownMemoryStore(tinyConfig);
|
|
384
|
+
await tinyStore.init();
|
|
385
|
+
const r1 = await tinyStore.store('architecture', 'Entry 1', 'Some content that is long enough to exceed our budget');
|
|
386
|
+
assert.ok(r1.stored);
|
|
387
|
+
const r2 = await tinyStore.store('conventions', 'Entry 2', 'More content');
|
|
388
|
+
assert.ok(!r2.stored);
|
|
389
|
+
assert.ok(r2.warning?.includes('budget'));
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
describe('sanitizeBranchName', () => {
|
|
393
|
+
it('handles branch names with slashes', async () => {
|
|
394
|
+
const result = await store.store('recent-work', 'Test', 'Content');
|
|
395
|
+
assert.ok(result.stored);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
describe('user and preferences topics', () => {
|
|
399
|
+
it('stores user entries with correct ID prefix', async () => {
|
|
400
|
+
const result = await store.store('user', 'Name and Role', 'Etienne, Senior Android Engineer', [], 'user');
|
|
401
|
+
assert.ok(result.stored);
|
|
402
|
+
if (!result.stored)
|
|
403
|
+
return;
|
|
404
|
+
assert.ok(result.id.startsWith('user-'), `ID should start with user-, got ${result.id}`);
|
|
405
|
+
assert.strictEqual(result.topic, 'user');
|
|
406
|
+
assert.strictEqual(result.confidence, 1.0);
|
|
407
|
+
});
|
|
408
|
+
it('stores preferences with correct ID prefix', async () => {
|
|
409
|
+
const result = await store.store('preferences', 'Naming Convention', 'Use Real prefix instead of Impl postfix', [], 'user');
|
|
410
|
+
assert.ok(result.stored);
|
|
411
|
+
if (!result.stored)
|
|
412
|
+
return;
|
|
413
|
+
assert.ok(result.id.startsWith('pref-'), `ID should start with pref-, got ${result.id}`);
|
|
414
|
+
});
|
|
415
|
+
it('user and preferences are always fresh', async () => {
|
|
416
|
+
await store.store('user', 'Role', 'Engineer');
|
|
417
|
+
await store.store('preferences', 'Style', 'Prefer sealed interfaces');
|
|
418
|
+
const result = await store.query('*', 'full');
|
|
419
|
+
const userEntries = result.entries.filter(e => e.content?.includes('Engineer'));
|
|
420
|
+
const prefEntries = result.entries.filter(e => e.content?.includes('sealed'));
|
|
421
|
+
assert.ok(userEntries.length > 0);
|
|
422
|
+
assert.ok(prefEntries.length > 0);
|
|
423
|
+
assert.strictEqual(userEntries[0].fresh, true);
|
|
424
|
+
assert.strictEqual(prefEntries[0].fresh, true);
|
|
425
|
+
});
|
|
426
|
+
it('briefing shows user before preferences before gotchas', async () => {
|
|
427
|
+
await store.store('gotchas', 'Build Gotcha', 'Must clean build after changes', [], 'user');
|
|
428
|
+
await store.store('user', 'Identity', 'Etienne, Android Engineer', [], 'user');
|
|
429
|
+
await store.store('preferences', 'No Emojis', 'Never use emojis in commit messages', [], 'user');
|
|
430
|
+
await store.store('architecture', 'Pattern', 'MVI architecture', [], 'agent-confirmed');
|
|
431
|
+
const briefing = await store.briefing(2000);
|
|
432
|
+
const text = briefing.briefing;
|
|
433
|
+
const userIdx = text.indexOf('About You');
|
|
434
|
+
const prefIdx = text.indexOf('Your Preferences');
|
|
435
|
+
const gotchaIdx = text.indexOf('Active Gotchas');
|
|
436
|
+
const archIdx = text.indexOf('Architecture');
|
|
437
|
+
assert.ok(userIdx >= 0, 'Should have About You section');
|
|
438
|
+
assert.ok(prefIdx >= 0, 'Should have Your Preferences section');
|
|
439
|
+
assert.ok(gotchaIdx >= 0, 'Should have Active Gotchas section');
|
|
440
|
+
assert.ok(archIdx >= 0, 'Should have Architecture section');
|
|
441
|
+
assert.ok(userIdx < prefIdx, `About You (${userIdx}) should come before Your Preferences (${prefIdx})`);
|
|
442
|
+
assert.ok(prefIdx < gotchaIdx, `Your Preferences (${prefIdx}) should come before Active Gotchas (${gotchaIdx})`);
|
|
443
|
+
assert.ok(gotchaIdx < archIdx, `Active Gotchas (${gotchaIdx}) should come before Architecture (${archIdx})`);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
// --- Text analyzer tests ---
|
|
447
|
+
describe('stemming', () => {
|
|
448
|
+
it('strips common suffixes', () => {
|
|
449
|
+
assert.strictEqual(stem('reducers'), 'reducer');
|
|
450
|
+
assert.strictEqual(stem('implementations'), 'implement');
|
|
451
|
+
assert.strictEqual(stem('handling'), 'handl');
|
|
452
|
+
assert.strictEqual(stem('sealed'), 'seal');
|
|
453
|
+
assert.strictEqual(stem('patterns'), 'pattern');
|
|
454
|
+
assert.strictEqual(stem('classes'), 'class');
|
|
455
|
+
});
|
|
456
|
+
it('leaves short words alone', () => {
|
|
457
|
+
assert.strictEqual(stem('mvi'), 'mvi');
|
|
458
|
+
assert.strictEqual(stem('api'), 'api');
|
|
459
|
+
});
|
|
460
|
+
it('leaves already-stemmed words alone', () => {
|
|
461
|
+
assert.strictEqual(stem('kotlin'), 'kotlin');
|
|
462
|
+
assert.strictEqual(stem('swift'), 'swift');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
describe('keyword extraction and similarity', () => {
|
|
466
|
+
it('extracts meaningful keywords with stemming', () => {
|
|
467
|
+
const keywords = extractKeywords('Use Real prefix instead of Impl postfix for implementations');
|
|
468
|
+
assert.ok(keywords.has('real'));
|
|
469
|
+
assert.ok(keywords.has('prefix'));
|
|
470
|
+
assert.ok(keywords.has('impl'));
|
|
471
|
+
assert.ok(keywords.has('postfix'));
|
|
472
|
+
assert.ok(keywords.has('implement')); // stemmed from "implementations"
|
|
473
|
+
assert.ok(!keywords.has('of'));
|
|
474
|
+
assert.ok(!keywords.has('for'));
|
|
475
|
+
assert.ok(!keywords.has('use'));
|
|
476
|
+
});
|
|
477
|
+
it('stems plurals so reducer matches reducers', () => {
|
|
478
|
+
const kwA = extractKeywords('standalone reducer');
|
|
479
|
+
const kwB = extractKeywords('standalone reducers');
|
|
480
|
+
assert.ok(kwA.has('reducer'));
|
|
481
|
+
assert.ok(kwB.has('reducer'));
|
|
482
|
+
});
|
|
483
|
+
it('handles code-like content', () => {
|
|
484
|
+
const keywords = extractKeywords('MutableStateFlow causes race conditions in ViewModel');
|
|
485
|
+
assert.ok(keywords.has('mutablestateflow'));
|
|
486
|
+
assert.ok(keywords.has('race'));
|
|
487
|
+
assert.ok(keywords.has('viewmodel'));
|
|
488
|
+
});
|
|
489
|
+
it('computes Jaccard similarity correctly', () => {
|
|
490
|
+
const a = new Set(['mvi', 'pattern', 'architecture', 'reducer']);
|
|
491
|
+
const b = new Set(['mvi', 'architecture', 'standalone', 'reducer']);
|
|
492
|
+
const sim = jaccardSimilarity(a, b);
|
|
493
|
+
assert.strictEqual(sim, 0.6);
|
|
494
|
+
});
|
|
495
|
+
it('computes containment similarity correctly', () => {
|
|
496
|
+
const small = new Set(['mvi', 'pattern']);
|
|
497
|
+
const large = new Set(['mvi', 'pattern', 'standalone', 'reducer', 'viewmodel']);
|
|
498
|
+
const sim = containmentSimilarity(small, large);
|
|
499
|
+
assert.strictEqual(sim, 1.0);
|
|
500
|
+
});
|
|
501
|
+
it('containment catches subset relationships that Jaccard misses', () => {
|
|
502
|
+
const small = new Set(['mvi', 'pattern']);
|
|
503
|
+
const large = new Set(['mvi', 'pattern', 'standalone', 'reducer', 'viewmodel']);
|
|
504
|
+
const jaccard = jaccardSimilarity(small, large);
|
|
505
|
+
const containment = containmentSimilarity(small, large);
|
|
506
|
+
assert.ok(jaccard <= 0.4, `Jaccard ${jaccard} should be low`);
|
|
507
|
+
assert.ok(containment === 1.0, `Containment ${containment} should be 1.0`);
|
|
508
|
+
});
|
|
509
|
+
it('hybrid similarity uses max of jaccard and containment', () => {
|
|
510
|
+
const sim = similarity('MVI Pattern', 'Use MVI pattern', 'Architecture Overview', 'MVI pattern with standalone reducers ViewModels and sealed interfaces');
|
|
511
|
+
assert.ok(sim > 0.5, `Hybrid similarity ${sim} should be > 0.5`);
|
|
512
|
+
});
|
|
513
|
+
it('returns 0 for disjoint sets', () => {
|
|
514
|
+
assert.strictEqual(jaccardSimilarity(new Set(['kotlin', 'android']), new Set(['swift', 'ios'])), 0);
|
|
515
|
+
assert.strictEqual(containmentSimilarity(new Set(['kotlin', 'android']), new Set(['swift', 'ios'])), 0);
|
|
516
|
+
});
|
|
517
|
+
it('returns 0 for empty sets', () => {
|
|
518
|
+
assert.strictEqual(jaccardSimilarity(new Set(), new Set()), 0);
|
|
519
|
+
assert.strictEqual(containmentSimilarity(new Set(), new Set()), 0);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
describe('dedup detection', () => {
|
|
523
|
+
it('surfaces related entries when storing similar content', async () => {
|
|
524
|
+
await store.store('architecture', 'MVI Pattern', 'This repo uses MVI architecture with standalone reducers and ViewModels');
|
|
525
|
+
const result = await store.store('architecture', 'Architecture Overview', 'MVI architecture pattern with standalone reducers for state management');
|
|
526
|
+
assert.ok(result.stored);
|
|
527
|
+
if (!result.stored)
|
|
528
|
+
return;
|
|
529
|
+
assert.ok(result.relatedEntries, 'Should have relatedEntries');
|
|
530
|
+
assert.ok(result.relatedEntries.length > 0, 'Should have at least one related entry');
|
|
531
|
+
assert.strictEqual(result.relatedEntries[0].title, 'MVI Pattern');
|
|
532
|
+
});
|
|
533
|
+
it('does not flag unrelated entries', async () => {
|
|
534
|
+
await store.store('architecture', 'Build System', 'Uses Gradle with Kotlin DSL for dependency management');
|
|
535
|
+
const result = await store.store('architecture', 'Networking', 'Retrofit with OkHttp for REST API calls and coroutines');
|
|
536
|
+
assert.ok(result.stored);
|
|
537
|
+
if (!result.stored)
|
|
538
|
+
return;
|
|
539
|
+
assert.ok(!result.relatedEntries || result.relatedEntries.length === 0);
|
|
540
|
+
});
|
|
541
|
+
it('only flags entries in the same topic', async () => {
|
|
542
|
+
await store.store('conventions', 'Naming', 'Use Real prefix instead of Impl postfix');
|
|
543
|
+
const result = await store.store('architecture', 'Naming Patterns', 'Use Real prefix for implementation classes in architecture');
|
|
544
|
+
assert.ok(result.stored);
|
|
545
|
+
if (!result.stored)
|
|
546
|
+
return;
|
|
547
|
+
assert.ok(!result.relatedEntries || result.relatedEntries.length === 0);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
describe('preference surfacing', () => {
|
|
551
|
+
it('surfaces relevant preferences when storing conventions', async () => {
|
|
552
|
+
await store.store('preferences', 'Kotlin Naming', 'Use Real prefix instead of Impl postfix for Kotlin implementation classes', [], 'user');
|
|
553
|
+
const result = await store.store('conventions', 'Implementation Naming', 'Kotlin implementation classes should use naming prefix convention Real instead of Impl');
|
|
554
|
+
assert.ok(result.stored);
|
|
555
|
+
if (!result.stored)
|
|
556
|
+
return;
|
|
557
|
+
assert.ok(result.relevantPreferences, 'Should have relevantPreferences');
|
|
558
|
+
assert.ok(result.relevantPreferences.length > 0);
|
|
559
|
+
assert.ok(result.relevantPreferences[0].title.includes('Naming'));
|
|
560
|
+
});
|
|
561
|
+
it('does not surface preferences for preference entries', async () => {
|
|
562
|
+
await store.store('preferences', 'Style A', 'Prefer composition over inheritance');
|
|
563
|
+
const result = await store.store('preferences', 'Style B', 'Use composition patterns in architecture');
|
|
564
|
+
assert.ok(result.stored);
|
|
565
|
+
if (!result.stored)
|
|
566
|
+
return;
|
|
567
|
+
assert.ok(!result.relevantPreferences || result.relevantPreferences.length === 0);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
describe('contextual search', () => {
|
|
571
|
+
it('returns relevant entries for a context description', async () => {
|
|
572
|
+
await store.store('architecture', 'MVI Pattern', 'Uses MVI architecture with standalone reducer classes in Kotlin');
|
|
573
|
+
await store.store('conventions', 'Kotlin Style', 'Kotlin sealed interfaces for state management');
|
|
574
|
+
await store.store('gotchas', 'Build Issue', 'Clean build required after Kotlin module changes');
|
|
575
|
+
await store.store('conventions', 'Swift Patterns', 'Use protocol-oriented design in Swift');
|
|
576
|
+
const results = await store.contextSearch('Kotlin reducer state management');
|
|
577
|
+
assert.ok(results.length > 0, 'Should have results');
|
|
578
|
+
const mviResult = results.find(r => r.entry.title === 'MVI Pattern');
|
|
579
|
+
assert.ok(mviResult, 'Should include MVI Pattern entry');
|
|
580
|
+
const kotlinResults = results.filter(r => r.matchedKeywords.includes('kotlin'));
|
|
581
|
+
assert.ok(kotlinResults.length > 0, 'Should match Kotlin-related entries');
|
|
582
|
+
});
|
|
583
|
+
it('always includes user entries even without keyword match', async () => {
|
|
584
|
+
await store.store('user', 'Identity', 'Senior Android Engineer at Zillow', [], 'user');
|
|
585
|
+
await store.store('architecture', 'Pattern', 'MVI with reducers');
|
|
586
|
+
const results = await store.contextSearch('swift ui layout');
|
|
587
|
+
const userResult = results.find(r => r.entry.topic === 'user');
|
|
588
|
+
assert.ok(userResult, 'Should always include user entries');
|
|
589
|
+
});
|
|
590
|
+
it('boosts gotchas and preferences in scoring', async () => {
|
|
591
|
+
await store.store('architecture', 'Kotlin Build', 'Kotlin module build configuration');
|
|
592
|
+
await store.store('gotchas', 'Kotlin Gotcha', 'Kotlin module requires clean build after changes', [], 'user');
|
|
593
|
+
await store.store('preferences', 'Kotlin Pref', 'Always use Kotlin sealed interfaces', [], 'user');
|
|
594
|
+
const results = await store.contextSearch('kotlin module');
|
|
595
|
+
assert.ok(results.length >= 3);
|
|
596
|
+
const gotchaIdx = results.findIndex(r => r.entry.topic === 'gotchas');
|
|
597
|
+
const archIdx = results.findIndex(r => r.entry.topic === 'architecture');
|
|
598
|
+
assert.ok(gotchaIdx < archIdx, `Gotcha (idx ${gotchaIdx}) should rank before architecture (idx ${archIdx})`);
|
|
599
|
+
});
|
|
600
|
+
it('returns empty for no-match context', async () => {
|
|
601
|
+
await store.store('architecture', 'MVI', 'Uses MVI pattern');
|
|
602
|
+
const results = await store.contextSearch('quantum computing algorithms');
|
|
603
|
+
assert.strictEqual(results.length, 0);
|
|
604
|
+
});
|
|
605
|
+
it('respects minMatch threshold', async () => {
|
|
606
|
+
await store.store('architecture', 'MVI Architecture', 'Uses MVI pattern with standalone reducers and sealed interfaces for events');
|
|
607
|
+
const strict = await store.contextSearch('kotlin coroutines flow testing reducer', 10, undefined, 0.4);
|
|
608
|
+
assert.strictEqual(strict.length, 0, 'Should exclude low-match entries with high threshold');
|
|
609
|
+
const lenient = await store.contextSearch('kotlin coroutines flow testing reducer', 10, undefined, 0.1);
|
|
610
|
+
assert.ok(lenient.length > 0, 'Should include with low threshold');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
describe('smart filter syntax', () => {
|
|
614
|
+
beforeEach(async () => {
|
|
615
|
+
await store.store('architecture', 'MVI Architecture', 'Uses MVI pattern with standalone reducers and sealed interfaces for events');
|
|
616
|
+
await store.store('conventions', 'Kotlin Naming', 'Use Real prefix instead of Impl postfix for implementation classes');
|
|
617
|
+
await store.store('conventions', 'Error Handling', 'Always use Result type, never throw exceptions');
|
|
618
|
+
await store.store('gotchas', 'Deprecated API', 'The old messaging API is deprecated, use StreamCoordinator instead');
|
|
619
|
+
});
|
|
620
|
+
it('space-separated terms use AND logic', async () => {
|
|
621
|
+
const result = await store.query('*', 'brief', 'MVI reducer');
|
|
622
|
+
assert.strictEqual(result.entries.length, 1);
|
|
623
|
+
assert.ok(result.entries[0].title.includes('MVI'));
|
|
624
|
+
});
|
|
625
|
+
it('AND filter excludes partial matches', async () => {
|
|
626
|
+
const result = await store.query('*', 'brief', 'MVI kotlin');
|
|
627
|
+
assert.strictEqual(result.entries.length, 0);
|
|
628
|
+
});
|
|
629
|
+
it('pipe-separated terms use OR logic', async () => {
|
|
630
|
+
const result = await store.query('*', 'brief', 'MVI|Result');
|
|
631
|
+
assert.strictEqual(result.entries.length, 2);
|
|
632
|
+
});
|
|
633
|
+
it('minus prefix excludes entries', async () => {
|
|
634
|
+
const result = await store.query('conventions', 'brief', 'Real -exception');
|
|
635
|
+
assert.strictEqual(result.entries.length, 1);
|
|
636
|
+
assert.ok(result.entries[0].title.includes('Naming'));
|
|
637
|
+
});
|
|
638
|
+
it('combined AND/OR/NOT', async () => {
|
|
639
|
+
const result = await store.query('*', 'brief', 'MVI reducer|Real prefix');
|
|
640
|
+
assert.strictEqual(result.entries.length, 2);
|
|
641
|
+
});
|
|
642
|
+
it('stemmed matching: reducers matches reducer', async () => {
|
|
643
|
+
const result = await store.query('*', 'brief', 'reducers');
|
|
644
|
+
assert.ok(result.entries.length >= 1, 'Should match via stemming');
|
|
645
|
+
assert.ok(result.entries[0].title.includes('MVI'));
|
|
646
|
+
});
|
|
647
|
+
it('stemmed matching: exceptions matches exception', async () => {
|
|
648
|
+
const result = await store.query('*', 'brief', 'exceptions');
|
|
649
|
+
assert.ok(result.entries.length >= 1, 'Should match "exceptions" against "exceptions" via stemming');
|
|
650
|
+
});
|
|
651
|
+
it('empty filter returns all entries', async () => {
|
|
652
|
+
const result = await store.query('*', 'brief', '');
|
|
653
|
+
assert.strictEqual(result.entries.length, 4);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
describe('title-weighted scoring', () => {
|
|
657
|
+
beforeEach(async () => {
|
|
658
|
+
await store.store('architecture', 'MVI Architecture', 'This repo uses MVI pattern with standalone reducers');
|
|
659
|
+
await store.store('conventions', 'State Management', 'We follow MVI conventions for state');
|
|
660
|
+
});
|
|
661
|
+
it('title matches rank higher than content-only matches', async () => {
|
|
662
|
+
const result = await store.query('*', 'brief', 'MVI');
|
|
663
|
+
assert.ok(result.entries.length >= 2);
|
|
664
|
+
assert.ok(result.entries[0].title.includes('MVI'), 'Title match should rank first');
|
|
665
|
+
});
|
|
666
|
+
it('relevance score accounts for multiple matching terms', async () => {
|
|
667
|
+
await store.store('architecture', 'Standalone Reducers', 'Reducer classes use inject constructor pattern');
|
|
668
|
+
const result = await store.query('*', 'brief', 'reducer');
|
|
669
|
+
assert.ok(result.entries[0].title.includes('Reducer'), 'Title match should rank first');
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
describe('parseFilter', () => {
|
|
673
|
+
it('parses simple single term', () => {
|
|
674
|
+
const groups = parseFilter('reducer');
|
|
675
|
+
assert.strictEqual(groups.length, 1);
|
|
676
|
+
assert.ok(groups[0].must.has('reducer'));
|
|
677
|
+
assert.strictEqual(groups[0].mustNot.size, 0);
|
|
678
|
+
});
|
|
679
|
+
it('parses space-separated AND terms', () => {
|
|
680
|
+
const groups = parseFilter('reducer sealed');
|
|
681
|
+
assert.strictEqual(groups.length, 1);
|
|
682
|
+
assert.ok(groups[0].must.has('reducer'));
|
|
683
|
+
assert.ok(groups[0].must.has('seal')); // stemmed
|
|
684
|
+
});
|
|
685
|
+
it('parses pipe-separated OR groups', () => {
|
|
686
|
+
const groups = parseFilter('reducer|MVI');
|
|
687
|
+
assert.strictEqual(groups.length, 2);
|
|
688
|
+
assert.ok(groups[0].must.has('reducer'));
|
|
689
|
+
assert.ok(groups[1].must.has('mvi'));
|
|
690
|
+
});
|
|
691
|
+
it('parses minus-prefixed exclusions', () => {
|
|
692
|
+
const groups = parseFilter('reducer -deprecated');
|
|
693
|
+
assert.strictEqual(groups.length, 1);
|
|
694
|
+
assert.ok(groups[0].must.has('reducer'));
|
|
695
|
+
assert.ok(groups[0].mustNot.has('deprecat')); // stemmed: deprecated -> deprecat
|
|
696
|
+
});
|
|
697
|
+
it('parses complex combined expression', () => {
|
|
698
|
+
const groups = parseFilter('kotlin sealed|swift protocol -deprecated');
|
|
699
|
+
assert.strictEqual(groups.length, 2);
|
|
700
|
+
assert.ok(groups[0].must.has('kotlin'));
|
|
701
|
+
assert.ok(groups[0].must.has('seal'));
|
|
702
|
+
assert.ok(groups[1].must.has('swift'));
|
|
703
|
+
assert.ok(groups[1].must.has('protocol'));
|
|
704
|
+
assert.ok(groups[1].mustNot.has('deprecat'));
|
|
705
|
+
});
|
|
706
|
+
it('handles empty filter', () => {
|
|
707
|
+
const groups = parseFilter('');
|
|
708
|
+
assert.strictEqual(groups.length, 0);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
describe('corrupt file handling', () => {
|
|
712
|
+
it('tracks corrupt files in stats', async () => {
|
|
713
|
+
// Store a valid entry
|
|
714
|
+
await store.store('architecture', 'Valid Entry', 'This is valid content');
|
|
715
|
+
// Manually write a corrupt file
|
|
716
|
+
const corruptDir = path.join(tempDir, '.memory', 'architecture');
|
|
717
|
+
await fs.writeFile(path.join(corruptDir, 'corrupt-001.md'), 'not a valid entry format');
|
|
718
|
+
// Reload and check stats
|
|
719
|
+
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
720
|
+
await store2.init();
|
|
721
|
+
const stats = await store2.stats();
|
|
722
|
+
assert.strictEqual(stats.totalEntries, 1); // only the valid one
|
|
723
|
+
assert.strictEqual(stats.corruptFiles, 1); // the corrupt one tracked
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
describe('boundary validation', () => {
|
|
727
|
+
it('rejects entries with invalid topic on reload', async () => {
|
|
728
|
+
// Manually write a file with an invalid topic
|
|
729
|
+
const badDir = path.join(tempDir, '.memory', 'badtopic');
|
|
730
|
+
await fs.mkdir(badDir, { recursive: true });
|
|
731
|
+
await fs.writeFile(path.join(badDir, 'bad-001.md'), [
|
|
732
|
+
'# Bad Entry',
|
|
733
|
+
'- **id**: bad-001',
|
|
734
|
+
'- **topic**: banana',
|
|
735
|
+
'- **confidence**: 0.7',
|
|
736
|
+
'- **trust**: agent-inferred',
|
|
737
|
+
'- **created**: 2025-01-01T00:00:00.000Z',
|
|
738
|
+
'- **lastAccessed**: 2025-01-01T00:00:00.000Z',
|
|
739
|
+
'',
|
|
740
|
+
'This entry has an invalid topic.',
|
|
741
|
+
].join('\n'));
|
|
742
|
+
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
743
|
+
await store2.init();
|
|
744
|
+
const stats = await store2.stats();
|
|
745
|
+
// Invalid topic entry should be rejected as corrupt
|
|
746
|
+
assert.strictEqual(stats.corruptFiles, 1);
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
750
|
+
// BehaviorConfig: user-overridable thresholds
|
|
751
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
752
|
+
describe('BehaviorConfig overrides', () => {
|
|
753
|
+
it('staleDaysStandard overrides the default 30-day standard threshold', async () => {
|
|
754
|
+
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
|
755
|
+
const pastClock = { now: () => eightDaysAgo, isoNow: () => eightDaysAgo.toISOString() };
|
|
756
|
+
// Scenario A: default threshold (30 days) — 8-day-old entry should be fresh
|
|
757
|
+
const dirA = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-beh-a-'));
|
|
758
|
+
try {
|
|
759
|
+
const pastStoreA = new MarkdownMemoryStore({ repoRoot: dirA, memoryPath: path.join(dirA, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, clock: pastClock });
|
|
760
|
+
await pastStoreA.init();
|
|
761
|
+
await pastStoreA.store('architecture', 'Recent Pattern', 'An architecture pattern written 8 days ago');
|
|
762
|
+
const defaultStore = new MarkdownMemoryStore({ repoRoot: dirA, memoryPath: path.join(dirA, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES });
|
|
763
|
+
await defaultStore.init();
|
|
764
|
+
const resultA = await defaultStore.query('architecture', 'brief');
|
|
765
|
+
assert.ok(resultA.entries[0].fresh, 'Should be fresh under default 30-day threshold');
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
await fs.rm(dirA, { recursive: true, force: true }).catch(() => { });
|
|
769
|
+
}
|
|
770
|
+
// Scenario B: staleDaysStandard: 5 — 8-day-old entry should be stale
|
|
771
|
+
const dirB = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-beh-b-'));
|
|
772
|
+
try {
|
|
773
|
+
const pastStoreB = new MarkdownMemoryStore({ repoRoot: dirB, memoryPath: path.join(dirB, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, clock: pastClock });
|
|
774
|
+
await pastStoreB.init();
|
|
775
|
+
await pastStoreB.store('architecture', 'Recent Pattern', 'An architecture pattern written 8 days ago');
|
|
776
|
+
const strictStore = new MarkdownMemoryStore({ repoRoot: dirB, memoryPath: path.join(dirB, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, behavior: { staleDaysStandard: 5 } });
|
|
777
|
+
await strictStore.init();
|
|
778
|
+
const resultB = await strictStore.query('architecture', 'brief');
|
|
779
|
+
assert.ok(!resultB.entries[0].fresh, 'Should be stale under custom 5-day threshold');
|
|
780
|
+
}
|
|
781
|
+
finally {
|
|
782
|
+
await fs.rm(dirB, { recursive: true, force: true }).catch(() => { });
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
it('staleDaysPreferences overrides the default 90-day preferences threshold', async () => {
|
|
786
|
+
const fortyDaysAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000);
|
|
787
|
+
const pastClock = { now: () => fortyDaysAgo, isoNow: () => fortyDaysAgo.toISOString() };
|
|
788
|
+
// Scenario A: default (90 days) — 40-day-old preference should be fresh
|
|
789
|
+
const dirA = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-a-'));
|
|
790
|
+
try {
|
|
791
|
+
const pastA = new MarkdownMemoryStore({ repoRoot: dirA, memoryPath: path.join(dirA, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, clock: pastClock });
|
|
792
|
+
await pastA.init();
|
|
793
|
+
await pastA.store('preferences', 'Old Pref', 'Preference written 40 days ago');
|
|
794
|
+
const readA = new MarkdownMemoryStore({ repoRoot: dirA, memoryPath: path.join(dirA, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES });
|
|
795
|
+
await readA.init();
|
|
796
|
+
const resultA = await readA.query('preferences', 'brief');
|
|
797
|
+
assert.ok(resultA.entries[0].fresh, 'Should be fresh under default 90-day threshold');
|
|
798
|
+
}
|
|
799
|
+
finally {
|
|
800
|
+
await fs.rm(dirA, { recursive: true, force: true }).catch(() => { });
|
|
801
|
+
}
|
|
802
|
+
// Scenario B: staleDaysPreferences: 30 — 40-day-old preference should be stale
|
|
803
|
+
const dirB = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-b-'));
|
|
804
|
+
try {
|
|
805
|
+
const pastB = new MarkdownMemoryStore({ repoRoot: dirB, memoryPath: path.join(dirB, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, clock: pastClock });
|
|
806
|
+
await pastB.init();
|
|
807
|
+
await pastB.store('preferences', 'Old Pref', 'Preference written 40 days ago');
|
|
808
|
+
const strictB = new MarkdownMemoryStore({ repoRoot: dirB, memoryPath: path.join(dirB, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, behavior: { staleDaysPreferences: 30 } });
|
|
809
|
+
await strictB.init();
|
|
810
|
+
const resultB = await strictB.query('preferences', 'brief');
|
|
811
|
+
assert.ok(!resultB.entries[0].fresh, 'Should be stale under custom 30-day preferences threshold');
|
|
812
|
+
}
|
|
813
|
+
finally {
|
|
814
|
+
await fs.rm(dirB, { recursive: true, force: true }).catch(() => { });
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
it('maxStaleInBriefing caps the number of stale entries surfaced', async () => {
|
|
818
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
819
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
820
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
821
|
+
await pastStore.init();
|
|
822
|
+
for (let i = 0; i < 6; i++) {
|
|
823
|
+
await pastStore.store('architecture', `Pattern ${i}`, `Architecture content for entry number ${i}`);
|
|
824
|
+
}
|
|
825
|
+
// Default cap: 5
|
|
826
|
+
const defaultStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
827
|
+
await defaultStore.init();
|
|
828
|
+
const defaultBriefing = await defaultStore.briefing(5000);
|
|
829
|
+
assert.ok(defaultBriefing.staleDetails && defaultBriefing.staleDetails.length <= 5, `Default cap: should be ≤ 5, got ${defaultBriefing.staleDetails?.length}`);
|
|
830
|
+
// Custom cap: 2
|
|
831
|
+
const limitedStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), behavior: { maxStaleInBriefing: 2 } });
|
|
832
|
+
await limitedStore.init();
|
|
833
|
+
const limitedBriefing = await limitedStore.briefing(5000);
|
|
834
|
+
assert.ok(limitedBriefing.staleDetails && limitedBriefing.staleDetails.length <= 2, `Custom cap: should be ≤ 2, got ${limitedBriefing.staleDetails?.length}`);
|
|
835
|
+
});
|
|
836
|
+
it('maxDedupSuggestions limits dedup results at write time', async () => {
|
|
837
|
+
// Create 4 similar entries first
|
|
838
|
+
const sharedContent = 'MVI architecture uses standalone reducers with sealed interface events and state management';
|
|
839
|
+
for (let i = 0; i < 4; i++) {
|
|
840
|
+
await store.store('architecture', `Pattern Base ${i}`, `${sharedContent} variation ${i}`);
|
|
841
|
+
}
|
|
842
|
+
// Store a new similar entry with maxDedupSuggestions: 1
|
|
843
|
+
const limitedStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), behavior: { maxDedupSuggestions: 1 } });
|
|
844
|
+
await limitedStore.init();
|
|
845
|
+
const result = await limitedStore.store('architecture', 'New MVI Entry', `${sharedContent} new pattern`);
|
|
846
|
+
assert.ok(result.stored);
|
|
847
|
+
if (!result.stored)
|
|
848
|
+
return;
|
|
849
|
+
// Should surface at most 1 dedup suggestion even though 4 similar entries exist
|
|
850
|
+
assert.ok(!result.relatedEntries || result.relatedEntries.length <= 1, `Should have at most 1 dedup suggestion, got ${result.relatedEntries?.length}`);
|
|
851
|
+
});
|
|
852
|
+
it('maxConflictPairs limits conflicts per query response', async () => {
|
|
853
|
+
// Create 4 very similar entries across topics
|
|
854
|
+
const sharedContent = 'This codebase uses MVI architecture with standalone reducers sealed interfaces events and ViewModel orchestration following clean architecture principles with Kotlin coroutines';
|
|
855
|
+
const ids = [];
|
|
856
|
+
const topics = ['architecture', 'conventions', 'gotchas', 'recent-work'];
|
|
857
|
+
for (const topic of topics) {
|
|
858
|
+
const r = await store.store(topic, `MVI Overview ${topic}`, `${sharedContent} in ${topic}`);
|
|
859
|
+
assert.ok(r.stored);
|
|
860
|
+
if (r.stored)
|
|
861
|
+
ids.push(r.id);
|
|
862
|
+
}
|
|
863
|
+
// With maxConflictPairs: 1
|
|
864
|
+
const limitedStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), behavior: { maxConflictPairs: 1 } });
|
|
865
|
+
await limitedStore.init();
|
|
866
|
+
const entries = limitedStore.getEntriesByIds(ids);
|
|
867
|
+
const conflicts = limitedStore.detectConflicts(entries);
|
|
868
|
+
assert.ok(conflicts.length <= 1, `Should return at most 1 conflict pair, got ${conflicts.length}`);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
872
|
+
// Feature 1: references field
|
|
873
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
874
|
+
describe('references field', () => {
|
|
875
|
+
it('stores and persists references to disk', async () => {
|
|
876
|
+
const refs = ['features/messaging/impl/MessagingReducer.kt', 'features/messaging/impl/MessagingState.kt'];
|
|
877
|
+
const result = await store.store('architecture', 'Messaging Reducer', 'Standalone reducer for messaging state', [], 'agent-inferred', refs);
|
|
878
|
+
assert.ok(result.stored);
|
|
879
|
+
if (!result.stored)
|
|
880
|
+
return;
|
|
881
|
+
// Verify raw file contains references metadata line
|
|
882
|
+
const filePath = path.join(tempDir, '.memory', 'architecture', `${result.id}.md`);
|
|
883
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
884
|
+
assert.ok(raw.includes('- **references**: features/messaging/impl/MessagingReducer.kt, features/messaging/impl/MessagingState.kt'));
|
|
885
|
+
});
|
|
886
|
+
it('parses references back from disk on reload', async () => {
|
|
887
|
+
const refs = ['features/messaging/impl/MessagingReducer.kt'];
|
|
888
|
+
const storeResult = await store.store('architecture', 'Reducer Entry', 'Uses MVI reducer pattern', [], 'agent-inferred', refs);
|
|
889
|
+
assert.ok(storeResult.stored);
|
|
890
|
+
// Force reload from disk with a fresh store instance
|
|
891
|
+
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
892
|
+
await store2.init();
|
|
893
|
+
const query = await store2.query('architecture', 'full');
|
|
894
|
+
const entry = query.entries.find(e => e.title === 'Reducer Entry');
|
|
895
|
+
assert.ok(entry, 'Entry should exist after reload');
|
|
896
|
+
assert.deepStrictEqual(entry.references, ['features/messaging/impl/MessagingReducer.kt']);
|
|
897
|
+
});
|
|
898
|
+
it('surfaces references in full detail query', async () => {
|
|
899
|
+
const refs = ['src/Foo.kt', 'src/Bar.kt'];
|
|
900
|
+
await store.store('conventions', 'Foo Pattern', 'Uses Foo and Bar pattern', [], 'agent-inferred', refs);
|
|
901
|
+
const result = await store.query('conventions', 'full');
|
|
902
|
+
assert.ok(result.entries.length === 1);
|
|
903
|
+
assert.deepStrictEqual(result.entries[0].references, ['src/Foo.kt', 'src/Bar.kt']);
|
|
904
|
+
});
|
|
905
|
+
it('surfaces references in standard detail query', async () => {
|
|
906
|
+
const refs = ['src/Foo.kt'];
|
|
907
|
+
await store.store('conventions', 'Foo Pattern', 'Uses Foo pattern', [], 'agent-inferred', refs);
|
|
908
|
+
const result = await store.query('conventions', 'standard');
|
|
909
|
+
assert.deepStrictEqual(result.entries[0].references, ['src/Foo.kt']);
|
|
910
|
+
});
|
|
911
|
+
it('omits references in brief detail query', async () => {
|
|
912
|
+
const refs = ['src/Foo.kt'];
|
|
913
|
+
await store.store('conventions', 'Foo Pattern', 'Uses Foo pattern', [], 'agent-inferred', refs);
|
|
914
|
+
const result = await store.query('conventions', 'brief');
|
|
915
|
+
assert.ok(!result.entries[0].references, 'Brief detail should not include references');
|
|
916
|
+
});
|
|
917
|
+
it('omits references metadata line when empty', async () => {
|
|
918
|
+
const result = await store.store('architecture', 'No Refs Entry', 'Content without references');
|
|
919
|
+
assert.ok(result.stored);
|
|
920
|
+
if (!result.stored)
|
|
921
|
+
return;
|
|
922
|
+
const filePath = path.join(tempDir, '.memory', 'architecture', `${result.id}.md`);
|
|
923
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
924
|
+
assert.ok(!raw.includes('- **references**:'), 'Should not write references line when empty');
|
|
925
|
+
});
|
|
926
|
+
it('boosts context search score when reference path matches context keyword', async () => {
|
|
927
|
+
// Entry with reference to MessagingReducer should rank above a similar entry without it
|
|
928
|
+
const withRef = await store.store('architecture', 'State Machine', 'Handles state transitions for features', [], 'agent-inferred', ['features/messaging/impl/MessagingReducer.kt']);
|
|
929
|
+
const withoutRef = await store.store('architecture', 'State Handler', 'Handles state transitions for features using patterns', [], 'agent-inferred', []);
|
|
930
|
+
assert.ok(withRef.stored && withoutRef.stored);
|
|
931
|
+
const results = await store.contextSearch('MessagingReducer state transitions');
|
|
932
|
+
const refEntry = results.find(r => r.entry.title === 'State Machine');
|
|
933
|
+
const noRefEntry = results.find(r => r.entry.title === 'State Handler');
|
|
934
|
+
assert.ok(refEntry, 'Entry with reference should appear in results');
|
|
935
|
+
assert.ok(noRefEntry, 'Entry without reference should also appear');
|
|
936
|
+
assert.ok(refEntry.score > noRefEntry.score, `Entry with matching reference (score: ${refEntry.score}) should rank higher than entry without (score: ${noRefEntry.score})`);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
940
|
+
// Feature 2: Staleness — tiered isFresh() thresholds
|
|
941
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
942
|
+
describe('staleness and isFresh() thresholds', () => {
|
|
943
|
+
it('user topic entries are never stale regardless of age', async () => {
|
|
944
|
+
const pastClock = {
|
|
945
|
+
now: () => new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), // 90 days ago
|
|
946
|
+
isoNow: () => new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
947
|
+
};
|
|
948
|
+
const oldStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
949
|
+
await oldStore.init();
|
|
950
|
+
await oldStore.store('user', 'Identity', 'Etienne, Senior Engineer', [], 'user');
|
|
951
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
952
|
+
await currentStore.init();
|
|
953
|
+
const query = await currentStore.query('user', 'full');
|
|
954
|
+
assert.ok(query.entries[0].fresh, 'User topic entries should always be fresh');
|
|
955
|
+
});
|
|
956
|
+
it('preferences entries are stale after 90 days but fresh before', async () => {
|
|
957
|
+
const seventyDaysAgo = new Date(Date.now() - 70 * 24 * 60 * 60 * 1000);
|
|
958
|
+
const ninetyFiveDaysAgo = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000);
|
|
959
|
+
const clock70 = { now: () => seventyDaysAgo, isoNow: () => seventyDaysAgo.toISOString() };
|
|
960
|
+
const clock95 = { now: () => ninetyFiveDaysAgo, isoNow: () => ninetyFiveDaysAgo.toISOString() };
|
|
961
|
+
// Write with 70-days-ago clock, read with current clock → 70 days elapsed → fresh
|
|
962
|
+
const store70 = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: clock70 });
|
|
963
|
+
await store70.init();
|
|
964
|
+
await store70.store('preferences', 'Pref 70', 'Use MVI everywhere');
|
|
965
|
+
// Re-read with current clock to check freshness at current time
|
|
966
|
+
const currentStore70 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
967
|
+
await currentStore70.init();
|
|
968
|
+
const result70 = await currentStore70.query('preferences', 'brief');
|
|
969
|
+
assert.ok(result70.entries[0].fresh, '70-day-old preference should be fresh (within 90-day window)');
|
|
970
|
+
// New temp dir for the 95-day test — write with 95-days-ago clock, read with current clock
|
|
971
|
+
const dir95 = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-stale-'));
|
|
972
|
+
try {
|
|
973
|
+
const store95 = new MarkdownMemoryStore({ repoRoot: dir95, memoryPath: path.join(dir95, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES, clock: clock95 });
|
|
974
|
+
await store95.init();
|
|
975
|
+
await store95.store('preferences', 'Pref 95', 'Use MVI everywhere');
|
|
976
|
+
// Re-read with current clock to check freshness at current time
|
|
977
|
+
const currentStore95 = new MarkdownMemoryStore({ repoRoot: dir95, memoryPath: path.join(dir95, '.memory'), storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES });
|
|
978
|
+
await currentStore95.init();
|
|
979
|
+
const result95 = await currentStore95.query('preferences', 'brief');
|
|
980
|
+
assert.ok(!result95.entries[0].fresh, '95-day-old preference should be stale (exceeds 90-day window)');
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
await fs.rm(dir95, { recursive: true, force: true });
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
it('gotcha entries go stale after 30 days', async () => {
|
|
987
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
988
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
989
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
990
|
+
await pastStore.init();
|
|
991
|
+
await pastStore.store('gotchas', 'Build Gotcha', 'Run pod install first');
|
|
992
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
993
|
+
await currentStore.init();
|
|
994
|
+
const query = await currentStore.query('gotchas', 'brief');
|
|
995
|
+
assert.ok(!query.entries[0].fresh, 'Gotcha entry 35 days old should be stale');
|
|
996
|
+
});
|
|
997
|
+
it('user-trusted entries in non-user topics go stale normally', async () => {
|
|
998
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
999
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
1000
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
1001
|
+
await pastStore.init();
|
|
1002
|
+
// Explicitly user-trusted but in architecture topic
|
|
1003
|
+
await pastStore.store('architecture', 'Confirmed Pattern', 'Definitely uses MVI', [], 'user');
|
|
1004
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
1005
|
+
await currentStore.init();
|
|
1006
|
+
const query = await currentStore.query('architecture', 'brief');
|
|
1007
|
+
assert.ok(!query.entries[0].fresh, 'User-trusted architecture entry should go stale (trust != temporal validity)');
|
|
1008
|
+
});
|
|
1009
|
+
it('briefing includes staleDetails for entries past their threshold', async () => {
|
|
1010
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
1011
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
1012
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
1013
|
+
await pastStore.init();
|
|
1014
|
+
await pastStore.store('architecture', 'Old Pattern', 'This was written 35 days ago');
|
|
1015
|
+
await pastStore.store('gotchas', 'Old Gotcha', 'This gotcha is now stale');
|
|
1016
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
1017
|
+
await currentStore.init();
|
|
1018
|
+
const briefing = await currentStore.briefing(2000);
|
|
1019
|
+
assert.ok(briefing.staleDetails && briefing.staleDetails.length > 0, 'Should have staleDetails for old entries');
|
|
1020
|
+
assert.ok(briefing.staleDetails.every(e => e.daysSinceAccess >= 35), 'All stale entries should be 35+ days old');
|
|
1021
|
+
});
|
|
1022
|
+
it('briefing staleDetails caps at 5 entries', async () => {
|
|
1023
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
1024
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
1025
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
1026
|
+
await pastStore.init();
|
|
1027
|
+
for (let i = 0; i < 8; i++) {
|
|
1028
|
+
await pastStore.store('architecture', `Pattern ${i}`, `Content for pattern number ${i} in the architecture`);
|
|
1029
|
+
}
|
|
1030
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
1031
|
+
await currentStore.init();
|
|
1032
|
+
const briefing = await currentStore.briefing(2000);
|
|
1033
|
+
assert.ok(briefing.staleDetails && briefing.staleDetails.length <= 5, 'Should surface at most 5 stale entries');
|
|
1034
|
+
});
|
|
1035
|
+
it('briefing staleDetails prioritizes gotchas before architecture', async () => {
|
|
1036
|
+
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
1037
|
+
const pastClock = { now: () => thirtyFiveDaysAgo, isoNow: () => thirtyFiveDaysAgo.toISOString() };
|
|
1038
|
+
const pastStore = new MarkdownMemoryStore({ ...makeConfig(tempDir), clock: pastClock });
|
|
1039
|
+
await pastStore.init();
|
|
1040
|
+
await pastStore.store('architecture', 'Arch Pattern', 'Old architecture knowledge here');
|
|
1041
|
+
await pastStore.store('gotchas', 'Critical Gotcha', 'Old gotcha that should surface first');
|
|
1042
|
+
const currentStore = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
1043
|
+
await currentStore.init();
|
|
1044
|
+
const briefing = await currentStore.briefing(2000);
|
|
1045
|
+
assert.ok(briefing.staleDetails && briefing.staleDetails.length >= 2);
|
|
1046
|
+
assert.strictEqual(briefing.staleDetails[0].topic, 'gotchas', 'Gotchas should appear before architecture in stale list');
|
|
1047
|
+
});
|
|
1048
|
+
it('briefing staleDetails is undefined when no stale entries', async () => {
|
|
1049
|
+
// Store a fresh entry (using current time, not past clock)
|
|
1050
|
+
await store.store('architecture', 'Fresh Pattern', 'Just written right now');
|
|
1051
|
+
const briefing = await store.briefing(2000);
|
|
1052
|
+
assert.ok(!briefing.staleDetails || briefing.staleDetails.length === 0, 'Should have no staleDetails when all entries are fresh');
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1056
|
+
// Feature 3: Conflict detection
|
|
1057
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1058
|
+
describe('conflict detection', () => {
|
|
1059
|
+
it('detects high-similarity entries as conflicts', async () => {
|
|
1060
|
+
const a = await store.store('architecture', 'MVI Architecture', 'This codebase uses MVI architecture pattern with standalone reducers and sealed interfaces for state management events');
|
|
1061
|
+
const b = await store.store('conventions', 'Architecture Pattern', 'This codebase uses MVI architecture with standalone reducers sealed interfaces and state management');
|
|
1062
|
+
assert.ok(a.stored && b.stored);
|
|
1063
|
+
if (!a.stored || !b.stored)
|
|
1064
|
+
return;
|
|
1065
|
+
// Fetch raw entries and run detection
|
|
1066
|
+
const entries = store.getEntriesByIds([a.id, b.id]);
|
|
1067
|
+
const conflicts = store.detectConflicts(entries);
|
|
1068
|
+
assert.ok(conflicts.length > 0, 'Should detect conflict between highly similar entries');
|
|
1069
|
+
assert.ok(conflicts[0].similarity > 0.6, `Similarity ${conflicts[0].similarity} should exceed 0.6`);
|
|
1070
|
+
});
|
|
1071
|
+
it('does not flag dissimilar entries', async () => {
|
|
1072
|
+
const a = await store.store('architecture', 'Build System', 'Uses Gradle with Kotlin DSL for build configuration and dependency management');
|
|
1073
|
+
const b = await store.store('conventions', 'Networking', 'Retrofit with OkHttp for HTTP requests coroutines and suspend functions');
|
|
1074
|
+
assert.ok(a.stored && b.stored);
|
|
1075
|
+
if (!a.stored || !b.stored)
|
|
1076
|
+
return;
|
|
1077
|
+
const entries = store.getEntriesByIds([a.id, b.id]);
|
|
1078
|
+
const conflicts = store.detectConflicts(entries);
|
|
1079
|
+
assert.strictEqual(conflicts.length, 0, 'Dissimilar entries should not be flagged as conflicts');
|
|
1080
|
+
});
|
|
1081
|
+
it('does not flag entries with short content', async () => {
|
|
1082
|
+
// Short content (<=50 chars) — too noisy to be meaningful
|
|
1083
|
+
const a = await store.store('architecture', 'Short A', 'Use MVI');
|
|
1084
|
+
const b = await store.store('conventions', 'Short B', 'Use MVI pattern');
|
|
1085
|
+
assert.ok(a.stored && b.stored);
|
|
1086
|
+
if (!a.stored || !b.stored)
|
|
1087
|
+
return;
|
|
1088
|
+
const entries = store.getEntriesByIds([a.id, b.id]);
|
|
1089
|
+
const conflicts = store.detectConflicts(entries);
|
|
1090
|
+
assert.strictEqual(conflicts.length, 0, 'Short content entries should not be flagged as conflicts');
|
|
1091
|
+
});
|
|
1092
|
+
it('caps results at 2 conflict pairs', async () => {
|
|
1093
|
+
// Store 4 very similar entries
|
|
1094
|
+
const ids = [];
|
|
1095
|
+
const longContent = 'MVI architecture uses standalone reducers with sealed interfaces for state management and events in Kotlin';
|
|
1096
|
+
for (let i = 0; i < 4; i++) {
|
|
1097
|
+
const r = await store.store('architecture', `Pattern ${i}`, `${longContent} variation ${i}`);
|
|
1098
|
+
assert.ok(r.stored);
|
|
1099
|
+
if (r.stored)
|
|
1100
|
+
ids.push(r.id);
|
|
1101
|
+
}
|
|
1102
|
+
const entries = store.getEntriesByIds(ids);
|
|
1103
|
+
const conflicts = store.detectConflicts(entries);
|
|
1104
|
+
assert.ok(conflicts.length <= 2, `Should return at most 2 conflict pairs, got ${conflicts.length}`);
|
|
1105
|
+
});
|
|
1106
|
+
it('detects cross-topic conflicts', async () => {
|
|
1107
|
+
const similarContent = 'This codebase follows strict MVI architecture with standalone reducer classes sealed interface events and ViewModel orchestration';
|
|
1108
|
+
const a = await store.store('architecture', 'MVI Overview', similarContent);
|
|
1109
|
+
const b = await store.store('conventions', 'Architecture Convention', similarContent + ' following clean architecture principles');
|
|
1110
|
+
assert.ok(a.stored && b.stored);
|
|
1111
|
+
if (!a.stored || !b.stored)
|
|
1112
|
+
return;
|
|
1113
|
+
const entries = store.getEntriesByIds([a.id, b.id]);
|
|
1114
|
+
const conflicts = store.detectConflicts(entries);
|
|
1115
|
+
assert.ok(conflicts.length > 0, 'Should detect cross-topic conflicts');
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1119
|
+
// Ephemeral content detection at store time
|
|
1120
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
1121
|
+
describe('ephemeral warnings', () => {
|
|
1122
|
+
it('returns ephemeralWarning for content with temporal language', async () => {
|
|
1123
|
+
const result = await store.store('gotchas', 'Build Issue', 'The build is currently broken due to a Gradle sync issue that appeared today');
|
|
1124
|
+
assert.ok(result.stored, 'Should still store the entry');
|
|
1125
|
+
if (!result.stored)
|
|
1126
|
+
return;
|
|
1127
|
+
assert.ok(result.ephemeralWarning, 'Should include ephemeral warning');
|
|
1128
|
+
assert.ok(result.ephemeralWarning.includes('Temporal language'));
|
|
1129
|
+
});
|
|
1130
|
+
it('returns ephemeralWarning for fixed-bug content', async () => {
|
|
1131
|
+
const result = await store.store('gotchas', 'Resolved Crash', 'The crash bug in the messaging reducer has been fixed after we updated the coroutine scope handling');
|
|
1132
|
+
assert.ok(result.stored);
|
|
1133
|
+
if (!result.stored)
|
|
1134
|
+
return;
|
|
1135
|
+
assert.ok(result.ephemeralWarning, 'Should warn about resolved issues');
|
|
1136
|
+
assert.ok(result.ephemeralWarning.includes('Resolved issue'));
|
|
1137
|
+
});
|
|
1138
|
+
it('does not return ephemeralWarning for durable content', async () => {
|
|
1139
|
+
const result = await store.store('architecture', 'MVI Pattern', 'The messaging feature uses MVI with standalone reducer classes and sealed interface events for exhaustive handling');
|
|
1140
|
+
assert.ok(result.stored);
|
|
1141
|
+
if (!result.stored)
|
|
1142
|
+
return;
|
|
1143
|
+
assert.strictEqual(result.ephemeralWarning, undefined, 'Durable content should have no ephemeral warning');
|
|
1144
|
+
});
|
|
1145
|
+
it('skips ephemeral detection for recent-work topic', async () => {
|
|
1146
|
+
const result = await store.store('recent-work', 'Current Investigation', 'Currently debugging a crash that just happened today in the messaging reducer — investigating the root cause');
|
|
1147
|
+
assert.ok(result.stored);
|
|
1148
|
+
if (!result.stored)
|
|
1149
|
+
return;
|
|
1150
|
+
assert.strictEqual(result.ephemeralWarning, undefined, 'recent-work should bypass ephemeral detection');
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
});
|