memory-hosted.test.mjs
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