memory-hosted.test.mjs
229 lines 8.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Hosted memory tests — verifies the per-user/vault partitioning and memory operations
3 * that the bridge endpoints use, without importing the full bridge server.
4 *
5 * The bridge memory endpoints use FileMemoryProvider + MemoryManager directly.
6 * This test validates the same code path with the same directory structure.
7 */
8 import { describe, it, before, after, beforeEach } from 'node:test';
9 import assert from 'node:assert';
10 import fs from 'fs';
11 import path from 'path';
12 import os from 'os';
13
14 import { FileMemoryProvider } from '../lib/memory-provider-file.mjs';
15 import { MemoryManager } from '../lib/memory.mjs';
16 import { createMemoryEvent } from '../lib/memory-event.mjs';
17
18 let tmpDir;
19
20 before(() => {
21 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-hosted-mem-'));
22 });
23
24 after(() => {
25 fs.rmSync(tmpDir, { recursive: true, force: true });
26 });
27
28 function sanitizeUserId(uid) {
29 return String(uid).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128) || 'default';
30 }
31
32 function sanitizeVaultId(vaultId) {
33 return String(vaultId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default';
34 }
35
36 function bridgeMemoryDir(dataDir, uid, vaultId) {
37 return path.join(dataDir, 'memory', sanitizeUserId(uid), sanitizeVaultId(vaultId));
38 }
39
40 describe('Hosted memory: per-user/vault isolation', () => {
41 it('isolates memory between users', () => {
42 const dataDir = path.join(tmpDir, 'iso-users-' + Date.now());
43 const dir1 = bridgeMemoryDir(dataDir, 'alice', 'default');
44 const dir2 = bridgeMemoryDir(dataDir, 'bob', 'default');
45 const p1 = new FileMemoryProvider(dir1);
46 const p2 = new FileMemoryProvider(dir2);
47 const mm1 = new MemoryManager(p1);
48 const mm2 = new MemoryManager(p2);
49
50 mm1.store('search', { query: 'alice query' });
51 mm2.store('search', { query: 'bob query' });
52
53 const a = mm1.getLatest('search');
54 const b = mm2.getLatest('search');
55 assert.strictEqual(a.data.query, 'alice query');
56 assert.strictEqual(b.data.query, 'bob query');
57
58 assert.strictEqual(mm1.stats().total, 1);
59 assert.strictEqual(mm2.stats().total, 1);
60 });
61
62 it('isolates memory between vaults for same user', () => {
63 const dataDir = path.join(tmpDir, 'iso-vaults-' + Date.now());
64 const dir1 = bridgeMemoryDir(dataDir, 'alice', 'vault-a');
65 const dir2 = bridgeMemoryDir(dataDir, 'alice', 'vault-b');
66 const mm1 = new MemoryManager(new FileMemoryProvider(dir1));
67 const mm2 = new MemoryManager(new FileMemoryProvider(dir2));
68
69 mm1.store('search', { query: 'in vault-a' });
70 mm2.store('export', { format: 'md' });
71
72 assert.strictEqual(mm1.getLatest('search').data.query, 'in vault-a');
73 assert.strictEqual(mm1.getLatest('export'), null);
74 assert.strictEqual(mm2.getLatest('search'), null);
75 assert.notStrictEqual(mm2.getLatest('export'), null);
76 });
77
78 it('sanitizeUserId handles special characters', () => {
79 assert.strictEqual(sanitizeUserId('[email protected]'), 'user_email_com');
80 assert.strictEqual(sanitizeUserId('uid-123_test'), 'uid-123_test');
81 assert.strictEqual(sanitizeUserId(''), 'default');
82 });
83
84 it('directory structure matches bridge convention', () => {
85 const dataDir = path.join(tmpDir, 'dir-struct-' + Date.now());
86 const dir = bridgeMemoryDir(dataDir, 'user_1', 'my-vault');
87 const expected = path.join(dataDir, 'memory', 'user_1', 'my-vault');
88 assert.strictEqual(dir, expected);
89 });
90 });
91
92 describe('Hosted memory: bridge-like endpoint behavior', () => {
93 let dataDir;
94
95 beforeEach(() => {
96 dataDir = path.join(tmpDir, 'bridge-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6));
97 });
98
99 it('GET memory/:key returns null when empty', () => {
100 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
101 const mm = new MemoryManager(new FileMemoryProvider(dir));
102 const event = mm.getLatest('search');
103 assert.strictEqual(event, null);
104 });
105
106 it('POST memory/store + GET memory/:key round-trip', () => {
107 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
108 const mm = new MemoryManager(new FileMemoryProvider(dir));
109
110 const result = mm.store('user', { key: 'test_key', note: 'hello' });
111 assert.match(result.id, /^mem_/);
112
113 const latest = mm.getLatest('user');
114 assert.strictEqual(latest.data.note, 'hello');
115 });
116
117 it('GET memory (list) returns filtered events', () => {
118 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
119 const mm = new MemoryManager(new FileMemoryProvider(dir));
120 mm.store('search', { query: 'a' });
121 mm.store('export', { format: 'md' });
122 mm.store('search', { query: 'b' });
123
124 const all = mm.list({ limit: 20 });
125 assert.strictEqual(all.length, 3);
126
127 const searches = mm.list({ type: 'search', limit: 20 });
128 assert.strictEqual(searches.length, 2);
129 });
130
131 it('GET memory-stats returns stats', () => {
132 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
133 const mm = new MemoryManager(new FileMemoryProvider(dir));
134 mm.store('search', { query: 'a' });
135 mm.store('search', { query: 'b' });
136
137 const stats = mm.stats();
138 assert.strictEqual(stats.total, 2);
139 assert.strictEqual(stats.counts_by_type.search, 2);
140 });
141
142 it('DELETE memory/clear clears all for user', () => {
143 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
144 const mm = new MemoryManager(new FileMemoryProvider(dir));
145 mm.store('search', { query: 'a' });
146 mm.store('export', { format: 'md' });
147
148 const result = mm.clear();
149 assert.strictEqual(result.cleared, 2);
150 assert.strictEqual(mm.stats().total, 0);
151 });
152
153 it('DELETE memory/clear by type only clears that type', () => {
154 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
155 const mm = new MemoryManager(new FileMemoryProvider(dir));
156 mm.store('search', { query: 'a' });
157 mm.store('export', { format: 'md' });
158
159 const result = mm.clear({ type: 'search' });
160 assert.strictEqual(result.cleared, 1);
161 assert.strictEqual(mm.stats().total, 1);
162 assert.notStrictEqual(mm.getLatest('export'), null);
163 });
164
165 it('store rejects sensitive data', () => {
166 const dir = bridgeMemoryDir(dataDir, 'user1', 'default');
167 const mm = new MemoryManager(new FileMemoryProvider(dir));
168 assert.throws(
169 () => mm.store('user', { api_key: 'sk-secret' }),
170 /sensitive key patterns/
171 );
172 });
173 });
174
175 describe('auto-capture: shouldCapture and DEFAULT_CAPTURE_TYPES', () => {
176 let dataDir;
177 before(() => { dataDir = fs.mkdtempSync(path.join(tmpDir, 'autocap-')); });
178
179 it('shouldCapture returns true for search (in DEFAULT_CAPTURE_TYPES)', () => {
180 const dir = bridgeMemoryDir(dataDir, 'user_ac', 'default');
181 const mm = new MemoryManager(new FileMemoryProvider(dir));
182 assert.ok(mm.shouldCapture('search'));
183 assert.ok(mm.shouldCapture('write'));
184 assert.ok(mm.shouldCapture('index'));
185 assert.ok(mm.shouldCapture('import'));
186 });
187
188 it('shouldCapture returns false for consolidation (not in DEFAULT_CAPTURE_TYPES)', () => {
189 const dir = bridgeMemoryDir(dataDir, 'user_ac2', 'default');
190 const mm = new MemoryManager(new FileMemoryProvider(dir));
191 assert.ok(!mm.shouldCapture('consolidation'));
192 assert.ok(!mm.shouldCapture('consolidation_pass'));
193 assert.ok(!mm.shouldCapture('maintenance'));
194 });
195
196 it('captures search event and makes it available for consolidation', () => {
197 const dir = bridgeMemoryDir(dataDir, 'user_ac3', 'default');
198 const mm = new MemoryManager(new FileMemoryProvider(dir));
199 if (mm.shouldCapture('search')) mm.store('search', { query: 'memory consolidation', mode: 'semantic', result_count: 3 });
200 if (mm.shouldCapture('search')) mm.store('search', { query: 'vault notes', mode: 'keyword', result_count: 5 });
201 const events = mm.list({ type: 'search' });
202 assert.strictEqual(events.length, 2);
203 // Both queries must be present (order is undefined when stored in the same millisecond)
204 const queries = events.map((e) => e.data.query);
205 assert.ok(queries.includes('memory consolidation'), 'first query must be present');
206 assert.ok(queries.includes('vault notes'), 'second query must be present');
207 assert.ok(events.every((e) => e.ts), 'every captured event must have a ts field');
208 });
209
210 it('captures write event with path and action', () => {
211 const dir = bridgeMemoryDir(dataDir, 'user_ac4', 'default');
212 const mm = new MemoryManager(new FileMemoryProvider(dir));
213 if (mm.shouldCapture('write')) mm.store('write', { path: 'projects/my-project/note.md', action: 'write' });
214 const ev = mm.getLatest('write');
215 assert.ok(ev);
216 assert.strictEqual(ev.data.path, 'projects/my-project/note.md');
217 assert.ok(ev.ts);
218 });
219
220 it('captures index event with note_count', () => {
221 const dir = bridgeMemoryDir(dataDir, 'user_ac5', 'default');
222 const mm = new MemoryManager(new FileMemoryProvider(dir));
223 if (mm.shouldCapture('index')) mm.store('index', { note_count: 20, chunk_count: 45 });
224 const ev = mm.getLatest('index');
225 assert.ok(ev);
226 assert.strictEqual(ev.data.note_count, 20);
227 assert.ok(ev.ts);
228 });
229 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago