@exaudeus/memory-mcp 0.1.0 → 1.0.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/package.json +2 -2
- package/dist/__tests__/clock-and-validators.test.d.ts +0 -1
- package/dist/__tests__/clock-and-validators.test.js +0 -237
- package/dist/__tests__/config-manager.test.d.ts +0 -1
- package/dist/__tests__/config-manager.test.js +0 -142
- package/dist/__tests__/config.test.d.ts +0 -1
- package/dist/__tests__/config.test.js +0 -236
- package/dist/__tests__/crash-journal.test.d.ts +0 -1
- package/dist/__tests__/crash-journal.test.js +0 -203
- package/dist/__tests__/e2e.test.d.ts +0 -1
- package/dist/__tests__/e2e.test.js +0 -788
- package/dist/__tests__/ephemeral-benchmark.test.d.ts +0 -1
- package/dist/__tests__/ephemeral-benchmark.test.js +0 -651
- package/dist/__tests__/ephemeral.test.d.ts +0 -1
- package/dist/__tests__/ephemeral.test.js +0 -435
- package/dist/__tests__/git-service.test.d.ts +0 -1
- package/dist/__tests__/git-service.test.js +0 -43
- package/dist/__tests__/normalize.test.d.ts +0 -1
- package/dist/__tests__/normalize.test.js +0 -161
- package/dist/__tests__/store.test.d.ts +0 -1
- package/dist/__tests__/store.test.js +0 -1153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exaudeus/memory-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Codebase memory MCP server - persistent, evolving knowledge for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
18
|
"dev": "tsx src/index.ts",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
|
-
"test": "node --import tsx --test src/__tests__
|
|
20
|
+
"test": "sh -c 'node --import tsx --test src/__tests__/*.test.ts'",
|
|
21
21
|
"prepublishOnly": "npm run build",
|
|
22
22
|
"semantic-release": "semantic-release"
|
|
23
23
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach } 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, parseTopicScope, parseTrustLevel } from '../types.js';
|
|
8
|
-
// --- Fake clock: deterministic time for testing staleness ---
|
|
9
|
-
function fakeClock(isoDate) {
|
|
10
|
-
const date = new Date(isoDate);
|
|
11
|
-
return {
|
|
12
|
-
now: () => new Date(date.getTime()),
|
|
13
|
-
isoNow: () => date.toISOString(),
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
function makeConfig(repoRoot, clock) {
|
|
17
|
-
return {
|
|
18
|
-
repoRoot,
|
|
19
|
-
memoryPath: path.join(repoRoot, '.memory'),
|
|
20
|
-
storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
|
|
21
|
-
clock,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
async function createTempDir() {
|
|
25
|
-
return await fs.mkdtemp(path.join(os.tmpdir(), 'memory-mcp-clock-test-'));
|
|
26
|
-
}
|
|
27
|
-
// --- parseTopicScope tests ---
|
|
28
|
-
describe('parseTopicScope', () => {
|
|
29
|
-
it('accepts all fixed topics', () => {
|
|
30
|
-
const fixed = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'];
|
|
31
|
-
for (const topic of fixed) {
|
|
32
|
-
assert.strictEqual(parseTopicScope(topic), topic, `Should accept "${topic}"`);
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
it('accepts modules/ prefixed topics', () => {
|
|
36
|
-
assert.strictEqual(parseTopicScope('modules/messaging'), 'modules/messaging');
|
|
37
|
-
assert.strictEqual(parseTopicScope('modules/auth'), 'modules/auth');
|
|
38
|
-
assert.strictEqual(parseTopicScope('modules/deep/nested'), 'modules/deep/nested');
|
|
39
|
-
});
|
|
40
|
-
it('rejects empty string', () => {
|
|
41
|
-
assert.strictEqual(parseTopicScope(''), null);
|
|
42
|
-
});
|
|
43
|
-
it('rejects arbitrary strings', () => {
|
|
44
|
-
assert.strictEqual(parseTopicScope('banana'), null);
|
|
45
|
-
assert.strictEqual(parseTopicScope('foobar'), null);
|
|
46
|
-
assert.strictEqual(parseTopicScope('arch'), null);
|
|
47
|
-
});
|
|
48
|
-
it('rejects "modules/" without a name', () => {
|
|
49
|
-
assert.strictEqual(parseTopicScope('modules/'), null);
|
|
50
|
-
});
|
|
51
|
-
it('is case-sensitive', () => {
|
|
52
|
-
assert.strictEqual(parseTopicScope('Architecture'), null);
|
|
53
|
-
assert.strictEqual(parseTopicScope('USER'), null);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
// --- parseTrustLevel tests ---
|
|
57
|
-
describe('parseTrustLevel', () => {
|
|
58
|
-
it('accepts all valid trust levels', () => {
|
|
59
|
-
assert.strictEqual(parseTrustLevel('user'), 'user');
|
|
60
|
-
assert.strictEqual(parseTrustLevel('agent-confirmed'), 'agent-confirmed');
|
|
61
|
-
assert.strictEqual(parseTrustLevel('agent-inferred'), 'agent-inferred');
|
|
62
|
-
});
|
|
63
|
-
it('rejects arbitrary strings', () => {
|
|
64
|
-
assert.strictEqual(parseTrustLevel('admin'), null);
|
|
65
|
-
assert.strictEqual(parseTrustLevel(''), null);
|
|
66
|
-
assert.strictEqual(parseTrustLevel('USER'), null);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
// --- Fake clock: staleness and freshness ---
|
|
70
|
-
describe('Clock injection for freshness', () => {
|
|
71
|
-
let tempDir;
|
|
72
|
-
beforeEach(async () => {
|
|
73
|
-
tempDir = await createTempDir();
|
|
74
|
-
});
|
|
75
|
-
it('entries older than 30 days are stale with fake clock', async () => {
|
|
76
|
-
// Store entries with a clock set to Jan 1, 2025
|
|
77
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
78
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
79
|
-
await store1.init();
|
|
80
|
-
await store1.store('architecture', 'Old Pattern', 'This was stored long ago', [], 'agent-inferred');
|
|
81
|
-
// Query with a clock set to Mar 1, 2025 (59 days later — well past 30)
|
|
82
|
-
const mar1 = fakeClock('2025-03-01T00:00:00.000Z');
|
|
83
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, mar1));
|
|
84
|
-
await store2.init();
|
|
85
|
-
const result = await store2.query('architecture', 'brief');
|
|
86
|
-
assert.strictEqual(result.entries.length, 1);
|
|
87
|
-
assert.strictEqual(result.entries[0].fresh, false, 'Entry should be stale after 59 days');
|
|
88
|
-
});
|
|
89
|
-
it('entries within 30 days are fresh with fake clock', async () => {
|
|
90
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
91
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
92
|
-
await store1.init();
|
|
93
|
-
await store1.store('architecture', 'Recent Pattern', 'Just stored', [], 'agent-inferred');
|
|
94
|
-
// Query 15 days later — still within 30
|
|
95
|
-
const jan16 = fakeClock('2025-01-16T00:00:00.000Z');
|
|
96
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, jan16));
|
|
97
|
-
await store2.init();
|
|
98
|
-
const result = await store2.query('architecture', 'brief');
|
|
99
|
-
assert.strictEqual(result.entries.length, 1);
|
|
100
|
-
assert.strictEqual(result.entries[0].fresh, true, 'Entry should be fresh within 30 days');
|
|
101
|
-
});
|
|
102
|
-
it('user topic entries are always fresh regardless of age', async () => {
|
|
103
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
104
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
105
|
-
await store1.init();
|
|
106
|
-
await store1.store('user', 'Identity', 'An engineer', [], 'user');
|
|
107
|
-
// Query 1 year later — user topic is exempt
|
|
108
|
-
const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
|
|
109
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
|
|
110
|
-
await store2.init();
|
|
111
|
-
const result = await store2.query('user', 'brief');
|
|
112
|
-
assert.strictEqual(result.entries[0].fresh, true, 'User topic should always be fresh');
|
|
113
|
-
});
|
|
114
|
-
it('preferences entries are stale after 90 days, fresh before', async () => {
|
|
115
|
-
// Use separate temp dirs to prevent query() from refreshing lastAccessed between scenarios
|
|
116
|
-
const dir60 = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-60-'));
|
|
117
|
-
const dir100 = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-100-'));
|
|
118
|
-
try {
|
|
119
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
120
|
-
// Scenario 1: written Jan 1, queried Mar 2 (60 days) — should be fresh
|
|
121
|
-
const write60 = new MarkdownMemoryStore(makeConfig(dir60, jan1));
|
|
122
|
-
await write60.init();
|
|
123
|
-
await write60.store('preferences', 'Style', 'Functional first', [], 'user');
|
|
124
|
-
const mar2 = fakeClock('2025-03-02T00:00:00.000Z'); // ~60 days later
|
|
125
|
-
const read60 = new MarkdownMemoryStore(makeConfig(dir60, mar2));
|
|
126
|
-
await read60.init();
|
|
127
|
-
const result60 = await read60.query('preferences', 'brief');
|
|
128
|
-
assert.strictEqual(result60.entries[0].fresh, true, 'Preference at 60 days should still be fresh (within 90-day window)');
|
|
129
|
-
// Scenario 2: written Jan 1, queried Apr 11 (100 days) — should be stale
|
|
130
|
-
const write100 = new MarkdownMemoryStore(makeConfig(dir100, jan1));
|
|
131
|
-
await write100.init();
|
|
132
|
-
await write100.store('preferences', 'Style', 'Functional first', [], 'user');
|
|
133
|
-
const apr11 = fakeClock('2025-04-11T00:00:00.000Z'); // ~100 days later
|
|
134
|
-
const read100 = new MarkdownMemoryStore(makeConfig(dir100, apr11));
|
|
135
|
-
await read100.init();
|
|
136
|
-
const result100 = await read100.query('preferences', 'brief');
|
|
137
|
-
assert.strictEqual(result100.entries[0].fresh, false, 'Preference at 100 days should be stale (exceeds 90-day window)');
|
|
138
|
-
}
|
|
139
|
-
finally {
|
|
140
|
-
await fs.rm(dir60, { recursive: true, force: true }).catch(() => { });
|
|
141
|
-
await fs.rm(dir100, { recursive: true, force: true }).catch(() => { });
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
it('gotchas go stale after 30 days (not always-fresh)', async () => {
|
|
145
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
146
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
147
|
-
await store1.init();
|
|
148
|
-
await store1.store('gotchas', 'Build Gotcha', 'Always clean build', [], 'agent-inferred');
|
|
149
|
-
// Query 1 year later — gotchas are NOT exempt from staleness
|
|
150
|
-
const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
|
|
151
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
|
|
152
|
-
await store2.init();
|
|
153
|
-
const result = await store2.query('gotchas', 'brief');
|
|
154
|
-
assert.strictEqual(result.entries[0].fresh, false, 'Gotchas should go stale after 30 days — code changes make them the most dangerous when outdated');
|
|
155
|
-
});
|
|
156
|
-
it('user-trusted entries in non-user topics go stale normally (trust != temporal validity)', async () => {
|
|
157
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
158
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
159
|
-
await store1.init();
|
|
160
|
-
await store1.store('conventions', 'User Convention', 'User confirmed this', [], 'user');
|
|
161
|
-
// Query 1 year later — trust level does not grant freshness exemption
|
|
162
|
-
const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
|
|
163
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
|
|
164
|
-
await store2.init();
|
|
165
|
-
const result = await store2.query('conventions', 'brief');
|
|
166
|
-
assert.strictEqual(result.entries[0].fresh, false, 'Trust level does not grant freshness exemption — a user-confirmed entry can still be outdated');
|
|
167
|
-
});
|
|
168
|
-
it('stale count in briefing reflects tiered thresholds', async () => {
|
|
169
|
-
const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
|
|
170
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
|
|
171
|
-
await store1.init();
|
|
172
|
-
await store1.store('architecture', 'Old Arch', 'content', [], 'agent-inferred');
|
|
173
|
-
await store1.store('conventions', 'Old Conv', 'content', [], 'agent-confirmed');
|
|
174
|
-
await store1.store('gotchas', 'Gotcha', 'content', [], 'agent-inferred'); // stale after 30 days
|
|
175
|
-
// Briefing 60 days later
|
|
176
|
-
const mar2 = fakeClock('2025-03-02T00:00:00.000Z');
|
|
177
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir, mar2));
|
|
178
|
-
await store2.init();
|
|
179
|
-
const briefing = await store2.briefing(2000);
|
|
180
|
-
// arch + conv + gotcha are all stale at 60 days (all use 30-day threshold)
|
|
181
|
-
assert.strictEqual(briefing.staleEntries, 3, 'arch, conv, and gotcha should all be stale at 60 days');
|
|
182
|
-
});
|
|
183
|
-
it('clock is used for created/lastAccessed timestamps', async () => {
|
|
184
|
-
const fixedTime = fakeClock('2025-06-15T12:30:00.000Z');
|
|
185
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir, fixedTime));
|
|
186
|
-
await store1.init();
|
|
187
|
-
await store1.store('architecture', 'Test Entry', 'content');
|
|
188
|
-
const result = await store1.query('architecture', 'full');
|
|
189
|
-
assert.strictEqual(result.entries[0].created, '2025-06-15T12:30:00.000Z');
|
|
190
|
-
assert.strictEqual(result.entries[0].lastAccessed, '2025-06-15T12:30:00.000Z');
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
// --- Confidence range validation ---
|
|
194
|
-
describe('confidence clamping on disk read', () => {
|
|
195
|
-
let tempDir;
|
|
196
|
-
beforeEach(async () => {
|
|
197
|
-
tempDir = await createTempDir();
|
|
198
|
-
});
|
|
199
|
-
it('clamps out-of-range confidence to valid range on reload', async () => {
|
|
200
|
-
const store1 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
201
|
-
await store1.init();
|
|
202
|
-
// Manually write a file with confidence: 999
|
|
203
|
-
const dir = path.join(tempDir, '.memory', 'architecture');
|
|
204
|
-
await fs.mkdir(dir, { recursive: true });
|
|
205
|
-
await fs.writeFile(path.join(dir, 'arch-bad1.md'), [
|
|
206
|
-
'# High Confidence',
|
|
207
|
-
'- **id**: arch-bad1',
|
|
208
|
-
'- **topic**: architecture',
|
|
209
|
-
'- **confidence**: 999',
|
|
210
|
-
'- **trust**: agent-inferred',
|
|
211
|
-
'- **created**: 2025-01-01T00:00:00.000Z',
|
|
212
|
-
'- **lastAccessed**: 2025-01-01T00:00:00.000Z',
|
|
213
|
-
'',
|
|
214
|
-
'This entry has absurd confidence.',
|
|
215
|
-
].join('\n'));
|
|
216
|
-
// Manually write a file with confidence: -5
|
|
217
|
-
await fs.writeFile(path.join(dir, 'arch-bad2.md'), [
|
|
218
|
-
'# Negative Confidence',
|
|
219
|
-
'- **id**: arch-bad2',
|
|
220
|
-
'- **topic**: architecture',
|
|
221
|
-
'- **confidence**: -5',
|
|
222
|
-
'- **trust**: agent-inferred',
|
|
223
|
-
'- **created**: 2025-01-01T00:00:00.000Z',
|
|
224
|
-
'- **lastAccessed**: 2025-01-01T00:00:00.000Z',
|
|
225
|
-
'',
|
|
226
|
-
'This entry has negative confidence.',
|
|
227
|
-
].join('\n'));
|
|
228
|
-
const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
|
|
229
|
-
await store2.init();
|
|
230
|
-
const result = await store2.query('architecture', 'full');
|
|
231
|
-
assert.strictEqual(result.entries.length, 2);
|
|
232
|
-
for (const entry of result.entries) {
|
|
233
|
-
assert.ok(entry.confidence >= 0.0, `Confidence ${entry.confidence} should be >= 0.0`);
|
|
234
|
-
assert.ok(entry.confidence <= 1.0, `Confidence ${entry.confidence} should be <= 1.0`);
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { ConfigManager } from '../config-manager.js';
|
|
4
|
-
// Test helper: ConfigManager subclass with injectable stat function
|
|
5
|
-
class TestableConfigManager extends ConfigManager {
|
|
6
|
-
constructor() {
|
|
7
|
-
super(...arguments);
|
|
8
|
-
this.statImplementation = async () => {
|
|
9
|
-
return { mtimeMs: Date.now() - 1000 };
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
statFile(path) {
|
|
13
|
-
return this.statImplementation(path);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
describe('ConfigManager', () => {
|
|
17
|
-
let initialConfig;
|
|
18
|
-
let initialStores;
|
|
19
|
-
let initialHealth;
|
|
20
|
-
let configPath;
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
configPath = '/fake/memory-config.json';
|
|
23
|
-
// Mock initial config
|
|
24
|
-
const mockConfig = {
|
|
25
|
-
repoRoot: '/fake/repo',
|
|
26
|
-
memoryPath: '/fake/memory',
|
|
27
|
-
storageBudgetBytes: 2 * 1024 * 1024,
|
|
28
|
-
};
|
|
29
|
-
initialConfig = {
|
|
30
|
-
configs: new Map([['test-lobe', mockConfig]]),
|
|
31
|
-
origin: { source: 'file', path: configPath },
|
|
32
|
-
};
|
|
33
|
-
// Mock initial stores
|
|
34
|
-
const mockStore = {};
|
|
35
|
-
initialStores = new Map([['test-lobe', mockStore]]);
|
|
36
|
-
initialHealth = new Map([['test-lobe', { status: 'healthy' }]]);
|
|
37
|
-
});
|
|
38
|
-
describe('ensureFresh', () => {
|
|
39
|
-
it('does not reload when mtime unchanged', async () => {
|
|
40
|
-
const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
41
|
-
let statCallCount = 0;
|
|
42
|
-
manager.statImplementation = async () => {
|
|
43
|
-
statCallCount++;
|
|
44
|
-
return { mtimeMs: Date.now() - 1000 }; // mtime in the past (no change)
|
|
45
|
-
};
|
|
46
|
-
await manager.ensureFresh();
|
|
47
|
-
await manager.ensureFresh();
|
|
48
|
-
// Stat should be called, but reload should not happen
|
|
49
|
-
assert.equal(statCallCount, 2, 'stat should be called twice');
|
|
50
|
-
assert.equal(manager.getLobeNames().length, 1, 'Should still have 1 lobe');
|
|
51
|
-
});
|
|
52
|
-
it('gracefully handles stat ENOENT error (file deleted)', async () => {
|
|
53
|
-
const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
54
|
-
manager.statImplementation = async () => {
|
|
55
|
-
const error = new Error('ENOENT: no such file');
|
|
56
|
-
error.code = 'ENOENT';
|
|
57
|
-
throw error;
|
|
58
|
-
};
|
|
59
|
-
// Should not throw, should keep old config
|
|
60
|
-
await manager.ensureFresh();
|
|
61
|
-
assert.equal(manager.getLobeNames().length, 1, 'Should keep old config on ENOENT');
|
|
62
|
-
});
|
|
63
|
-
it('gracefully handles stat EACCES error (permission denied)', async () => {
|
|
64
|
-
const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
65
|
-
manager.statImplementation = async () => {
|
|
66
|
-
const error = new Error('EACCES: permission denied');
|
|
67
|
-
error.code = 'EACCES';
|
|
68
|
-
throw error;
|
|
69
|
-
};
|
|
70
|
-
// Should not throw, should keep old config
|
|
71
|
-
await manager.ensureFresh();
|
|
72
|
-
assert.equal(manager.getLobeNames().length, 1, 'Should keep old config on EACCES');
|
|
73
|
-
});
|
|
74
|
-
it('skips reload for env-var-based configs', async () => {
|
|
75
|
-
const envConfig = {
|
|
76
|
-
configs: new Map([['test-lobe', initialConfig.configs.get('test-lobe')]]),
|
|
77
|
-
origin: { source: 'env' },
|
|
78
|
-
};
|
|
79
|
-
let statCalled = false;
|
|
80
|
-
const manager = new TestableConfigManager(configPath, envConfig, initialStores, initialHealth);
|
|
81
|
-
manager.statImplementation = async () => {
|
|
82
|
-
statCalled = true;
|
|
83
|
-
return { mtimeMs: Date.now() };
|
|
84
|
-
};
|
|
85
|
-
await manager.ensureFresh();
|
|
86
|
-
// stat should NOT be called for env-based configs
|
|
87
|
-
assert.equal(statCalled, false, 'Should not stat for env-based configs');
|
|
88
|
-
});
|
|
89
|
-
it('skips reload for default-based configs', async () => {
|
|
90
|
-
const defaultConfig = {
|
|
91
|
-
configs: new Map([['default', initialConfig.configs.get('test-lobe')]]),
|
|
92
|
-
origin: { source: 'default' },
|
|
93
|
-
};
|
|
94
|
-
let statCalled = false;
|
|
95
|
-
const manager = new TestableConfigManager(configPath, defaultConfig, initialStores, initialHealth);
|
|
96
|
-
manager.statImplementation = async () => {
|
|
97
|
-
statCalled = true;
|
|
98
|
-
return { mtimeMs: Date.now() };
|
|
99
|
-
};
|
|
100
|
-
await manager.ensureFresh();
|
|
101
|
-
// stat should NOT be called for default configs
|
|
102
|
-
assert.equal(statCalled, false, 'Should not stat for default configs');
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
describe('accessors', () => {
|
|
106
|
-
it('getStore returns store for existing lobe', () => {
|
|
107
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
108
|
-
const store = manager.getStore('test-lobe');
|
|
109
|
-
assert.ok(store !== undefined, 'Should return store for existing lobe');
|
|
110
|
-
});
|
|
111
|
-
it('getStore returns undefined for non-existent lobe', () => {
|
|
112
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
113
|
-
const store = manager.getStore('nonexistent');
|
|
114
|
-
assert.equal(store, undefined, 'Should return undefined for missing lobe');
|
|
115
|
-
});
|
|
116
|
-
it('getLobeNames returns array of lobe names', () => {
|
|
117
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
118
|
-
const names = manager.getLobeNames();
|
|
119
|
-
assert.deepEqual(names, ['test-lobe']);
|
|
120
|
-
});
|
|
121
|
-
it('getConfigOrigin returns current origin', () => {
|
|
122
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
123
|
-
const origin = manager.getConfigOrigin();
|
|
124
|
-
assert.equal(origin.source, 'file');
|
|
125
|
-
if (origin.source === 'file') {
|
|
126
|
-
assert.equal(origin.path, configPath);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
it('getLobeHealth returns health for existing lobe', () => {
|
|
130
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
131
|
-
const health = manager.getLobeHealth('test-lobe');
|
|
132
|
-
assert.ok(health !== undefined);
|
|
133
|
-
assert.equal(health.status, 'healthy');
|
|
134
|
-
});
|
|
135
|
-
it('getLobeConfig returns config for existing lobe', () => {
|
|
136
|
-
const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
|
|
137
|
-
const config = manager.getLobeConfig('test-lobe');
|
|
138
|
-
assert.ok(config !== undefined);
|
|
139
|
-
assert.equal(config.repoRoot, '/fake/repo');
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
// Tests for config.ts — configuration loading with 3-tier fallback.
|
|
2
|
-
// Tests the public API: getLobeConfigs() with different env/file setups.
|
|
3
|
-
//
|
|
4
|
-
// Strategy: We can't easily test file-based config (it reads relative to
|
|
5
|
-
// the module), but we CAN test env-based and default-based config.
|
|
6
|
-
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
7
|
-
import assert from 'node:assert';
|
|
8
|
-
import { promises as fs } from 'fs';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import os from 'os';
|
|
11
|
-
import { getLobeConfigs, parseBehaviorConfig } from '../config.js';
|
|
12
|
-
// Save original env vars so we can restore after each test
|
|
13
|
-
const originalEnv = {};
|
|
14
|
-
const envKeys = [
|
|
15
|
-
'MEMORY_MCP_WORKSPACES',
|
|
16
|
-
'MEMORY_MCP_DIR',
|
|
17
|
-
'MEMORY_MCP_BUDGET',
|
|
18
|
-
'MEMORY_MCP_REPO_ROOT',
|
|
19
|
-
];
|
|
20
|
-
function saveEnv() {
|
|
21
|
-
for (const key of envKeys) {
|
|
22
|
-
originalEnv[key] = process.env[key];
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
function restoreEnv() {
|
|
26
|
-
for (const key of envKeys) {
|
|
27
|
-
if (originalEnv[key] === undefined) {
|
|
28
|
-
delete process.env[key];
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
process.env[key] = originalEnv[key];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function clearConfigEnv() {
|
|
36
|
-
for (const key of envKeys) {
|
|
37
|
-
delete process.env[key];
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
describe('getLobeConfigs', () => {
|
|
41
|
-
let tempDir;
|
|
42
|
-
beforeEach(async () => {
|
|
43
|
-
saveEnv();
|
|
44
|
-
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-mcp-config-test-'));
|
|
45
|
-
});
|
|
46
|
-
afterEach(async () => {
|
|
47
|
-
restoreEnv();
|
|
48
|
-
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
49
|
-
});
|
|
50
|
-
describe('env var config (tier 2)', () => {
|
|
51
|
-
it('loads from MEMORY_MCP_WORKSPACES json', () => {
|
|
52
|
-
clearConfigEnv();
|
|
53
|
-
process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
|
|
54
|
-
'my-repo': tempDir,
|
|
55
|
-
});
|
|
56
|
-
process.env.MEMORY_MCP_DIR = '.test-memory';
|
|
57
|
-
const { configs, origin } = getLobeConfigs();
|
|
58
|
-
// May load file config first if memory-config.json exists
|
|
59
|
-
// But if we're here, it should have at least one lobe
|
|
60
|
-
assert.ok(configs.size >= 1, 'Should have at least one lobe');
|
|
61
|
-
if (origin.source === 'env') {
|
|
62
|
-
const config = configs.get('my-repo');
|
|
63
|
-
assert.ok(config, 'Should have my-repo lobe');
|
|
64
|
-
assert.strictEqual(config.repoRoot, tempDir);
|
|
65
|
-
assert.ok(config.memoryPath.includes('.test-memory'), `Memory path should use MEMORY_MCP_DIR: ${config.memoryPath}`);
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
it('loads multiple lobes from env', () => {
|
|
69
|
-
clearConfigEnv();
|
|
70
|
-
const dir2 = tempDir + '-2';
|
|
71
|
-
process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
|
|
72
|
-
'repo-a': tempDir,
|
|
73
|
-
'repo-b': dir2,
|
|
74
|
-
});
|
|
75
|
-
const { configs, origin } = getLobeConfigs();
|
|
76
|
-
if (origin.source === 'env') {
|
|
77
|
-
assert.strictEqual(configs.size, 2, 'Should have 2 lobes');
|
|
78
|
-
assert.ok(configs.has('repo-a'));
|
|
79
|
-
assert.ok(configs.has('repo-b'));
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
it('respects custom budget from env', () => {
|
|
83
|
-
clearConfigEnv();
|
|
84
|
-
process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
|
|
85
|
-
'my-repo': tempDir,
|
|
86
|
-
});
|
|
87
|
-
process.env.MEMORY_MCP_BUDGET = String(5 * 1024 * 1024); // 5MB
|
|
88
|
-
const { configs, origin } = getLobeConfigs();
|
|
89
|
-
if (origin.source === 'env') {
|
|
90
|
-
const config = configs.get('my-repo');
|
|
91
|
-
assert.strictEqual(config.storageBudgetBytes, 5 * 1024 * 1024);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
it('falls through on invalid MEMORY_MCP_WORKSPACES JSON', () => {
|
|
95
|
-
clearConfigEnv();
|
|
96
|
-
process.env.MEMORY_MCP_WORKSPACES = 'not valid json';
|
|
97
|
-
process.env.MEMORY_MCP_REPO_ROOT = tempDir;
|
|
98
|
-
// Should not throw — falls through to default
|
|
99
|
-
const { configs } = getLobeConfigs();
|
|
100
|
-
assert.ok(configs.size >= 1, 'Should fall through to at least one lobe');
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
describe('default fallback config (tier 3)', () => {
|
|
104
|
-
it('uses MEMORY_MCP_REPO_ROOT when no config file or env workspaces', () => {
|
|
105
|
-
clearConfigEnv();
|
|
106
|
-
process.env.MEMORY_MCP_REPO_ROOT = tempDir;
|
|
107
|
-
const { configs, origin } = getLobeConfigs();
|
|
108
|
-
// If there's no memory-config.json and no MEMORY_MCP_WORKSPACES,
|
|
109
|
-
// it should fall back to default
|
|
110
|
-
if (origin.source === 'default') {
|
|
111
|
-
assert.ok(configs.has('default'), 'Should have a default lobe');
|
|
112
|
-
assert.strictEqual(configs.get('default').repoRoot, tempDir);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
it('uses cwd when no env vars at all', () => {
|
|
116
|
-
clearConfigEnv();
|
|
117
|
-
// No env vars set — should use process.cwd()
|
|
118
|
-
const { configs } = getLobeConfigs();
|
|
119
|
-
assert.ok(configs.size >= 1, 'Should always return at least one lobe');
|
|
120
|
-
});
|
|
121
|
-
it('uses explicit MEMORY_MCP_DIR for default lobe', () => {
|
|
122
|
-
clearConfigEnv();
|
|
123
|
-
process.env.MEMORY_MCP_REPO_ROOT = tempDir;
|
|
124
|
-
process.env.MEMORY_MCP_DIR = '.custom-memory';
|
|
125
|
-
const { configs, origin } = getLobeConfigs();
|
|
126
|
-
if (origin.source === 'default') {
|
|
127
|
-
const config = configs.get('default');
|
|
128
|
-
assert.ok(config.memoryPath.includes('.custom-memory'), `Should use custom dir: ${config.memoryPath}`);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
describe('behavior config parsing', () => {
|
|
133
|
-
it('returns empty object when no behavior block provided', () => {
|
|
134
|
-
const result = parseBehaviorConfig(undefined);
|
|
135
|
-
// All fields should be absent (caller falls back to defaults from thresholds.ts)
|
|
136
|
-
assert.strictEqual(Object.keys(result).length, 0);
|
|
137
|
-
});
|
|
138
|
-
it('passes through valid values within allowed ranges', () => {
|
|
139
|
-
const result = parseBehaviorConfig({
|
|
140
|
-
staleDaysStandard: 14,
|
|
141
|
-
staleDaysPreferences: 60,
|
|
142
|
-
maxStaleInBriefing: 3,
|
|
143
|
-
maxDedupSuggestions: 5,
|
|
144
|
-
maxConflictPairs: 2,
|
|
145
|
-
});
|
|
146
|
-
assert.strictEqual(result.staleDaysStandard, 14);
|
|
147
|
-
assert.strictEqual(result.staleDaysPreferences, 60);
|
|
148
|
-
assert.strictEqual(result.maxStaleInBriefing, 3);
|
|
149
|
-
assert.strictEqual(result.maxDedupSuggestions, 5);
|
|
150
|
-
assert.strictEqual(result.maxConflictPairs, 2);
|
|
151
|
-
});
|
|
152
|
-
it('falls back to default for out-of-range staleDaysStandard', () => {
|
|
153
|
-
const tooLow = parseBehaviorConfig({ staleDaysStandard: 0 });
|
|
154
|
-
assert.strictEqual(tooLow.staleDaysStandard, 30, 'Should use default 30 for value below minimum (1)');
|
|
155
|
-
const tooHigh = parseBehaviorConfig({ staleDaysStandard: 500 });
|
|
156
|
-
assert.strictEqual(tooHigh.staleDaysStandard, 30, 'Should use default 30 for value above maximum (365)');
|
|
157
|
-
});
|
|
158
|
-
it('falls back to default for out-of-range staleDaysPreferences', () => {
|
|
159
|
-
const tooHigh = parseBehaviorConfig({ staleDaysPreferences: 800 });
|
|
160
|
-
assert.strictEqual(tooHigh.staleDaysPreferences, 90, 'Should use default 90 for value above maximum (730)');
|
|
161
|
-
});
|
|
162
|
-
it('falls back to default for out-of-range maxStaleInBriefing', () => {
|
|
163
|
-
const tooHigh = parseBehaviorConfig({ maxStaleInBriefing: 50 });
|
|
164
|
-
assert.strictEqual(tooHigh.maxStaleInBriefing, 5, 'Should use default 5 for value above maximum (20)');
|
|
165
|
-
});
|
|
166
|
-
it('falls back to default for out-of-range maxConflictPairs', () => {
|
|
167
|
-
const tooHigh = parseBehaviorConfig({ maxConflictPairs: 10 });
|
|
168
|
-
assert.strictEqual(tooHigh.maxConflictPairs, 2, 'Should use default 2 for value above maximum (5)');
|
|
169
|
-
});
|
|
170
|
-
it('rounds fractional values to integers', () => {
|
|
171
|
-
const result = parseBehaviorConfig({ staleDaysStandard: 14.7 });
|
|
172
|
-
assert.strictEqual(result.staleDaysStandard, 15, 'Should round to nearest integer');
|
|
173
|
-
});
|
|
174
|
-
it('handles partial behavior config — omitted fields do not appear', () => {
|
|
175
|
-
const result = parseBehaviorConfig({ staleDaysStandard: 14 });
|
|
176
|
-
assert.strictEqual(result.staleDaysStandard, 14);
|
|
177
|
-
// Omitted fields should not be set (callers use thresholds.ts defaults)
|
|
178
|
-
assert.strictEqual(result.staleDaysPreferences, 90, 'Non-omitted field uses default');
|
|
179
|
-
});
|
|
180
|
-
it('writes a stderr warning for unknown behavior config keys (typo detection)', () => {
|
|
181
|
-
const stderrWrites = [];
|
|
182
|
-
const origWrite = process.stderr.write.bind(process.stderr);
|
|
183
|
-
// Capture stderr
|
|
184
|
-
process.stderr.write = ((chunk, ...rest) => {
|
|
185
|
-
if (typeof chunk === 'string')
|
|
186
|
-
stderrWrites.push(chunk);
|
|
187
|
-
return origWrite(chunk, ...rest);
|
|
188
|
-
});
|
|
189
|
-
try {
|
|
190
|
-
// "staleDaysStanderd" is a typo of "staleDaysStandard"
|
|
191
|
-
parseBehaviorConfig({ staleDaysStandard: 14, staleDaysStanderd: 7 });
|
|
192
|
-
}
|
|
193
|
-
finally {
|
|
194
|
-
process.stderr.write = origWrite;
|
|
195
|
-
}
|
|
196
|
-
const warnings = stderrWrites.filter(s => s.includes('Unknown behavior config key'));
|
|
197
|
-
assert.ok(warnings.length > 0, 'Should warn about unknown key "staleDaysStanderd"');
|
|
198
|
-
assert.ok(warnings[0].includes('staleDaysStanderd'), 'Warning should name the offending key');
|
|
199
|
-
});
|
|
200
|
-
it('does not warn for valid behavior config keys', () => {
|
|
201
|
-
const stderrWrites = [];
|
|
202
|
-
const origWrite = process.stderr.write.bind(process.stderr);
|
|
203
|
-
process.stderr.write = ((chunk, ...rest) => {
|
|
204
|
-
if (typeof chunk === 'string')
|
|
205
|
-
stderrWrites.push(chunk);
|
|
206
|
-
return origWrite(chunk, ...rest);
|
|
207
|
-
});
|
|
208
|
-
try {
|
|
209
|
-
parseBehaviorConfig({ staleDaysStandard: 14, maxStaleInBriefing: 3 });
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
process.stderr.write = origWrite;
|
|
213
|
-
}
|
|
214
|
-
const unknownWarnings = stderrWrites.filter(s => s.includes('Unknown behavior config key'));
|
|
215
|
-
assert.strictEqual(unknownWarnings.length, 0, 'Should not warn for valid keys');
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
describe('config structure', () => {
|
|
219
|
-
it('all configs have required fields', () => {
|
|
220
|
-
// Use whatever config is currently active
|
|
221
|
-
const { configs } = getLobeConfigs();
|
|
222
|
-
for (const [name, config] of configs) {
|
|
223
|
-
assert.ok(config.repoRoot, `${name}: repoRoot should be set`);
|
|
224
|
-
assert.ok(config.memoryPath, `${name}: memoryPath should be set`);
|
|
225
|
-
assert.ok(config.storageBudgetBytes > 0, `${name}: storageBudgetBytes should be positive`);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
it('origin is always a valid discriminated union', () => {
|
|
229
|
-
const { origin } = getLobeConfigs();
|
|
230
|
-
assert.ok(['file', 'env', 'default'].includes(origin.source), `Origin source should be file, env, or default: ${origin.source}`);
|
|
231
|
-
if (origin.source === 'file') {
|
|
232
|
-
assert.ok('path' in origin && typeof origin.path === 'string', 'File origin should have a path');
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|