memory-db.test.ts
1 import assert from 'node:assert/strict' 2 import fs from 'node:fs' 3 import os from 'node:os' 4 import path from 'node:path' 5 import { after, before, describe, it } from 'node:test' 6 7 const originalEnv = { 8 DATA_DIR: process.env.DATA_DIR, 9 WORKSPACE_DIR: process.env.WORKSPACE_DIR, 10 SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE, 11 } 12 13 let tempDir = '' 14 let memDb: typeof import('@/lib/server/memory/memory-db') 15 16 before(async () => { 17 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-memory-db-')) 18 process.env.DATA_DIR = path.join(tempDir, 'data') 19 process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace') 20 process.env.SWARMCLAW_BUILD_MODE = '1' 21 memDb = await import('@/lib/server/memory/memory-db') 22 }) 23 24 after(() => { 25 if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR 26 else process.env.DATA_DIR = originalEnv.DATA_DIR 27 if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR 28 else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR 29 if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE 30 else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE 31 fs.rmSync(tempDir, { recursive: true, force: true }) 32 }) 33 34 describe('memory-db', () => { 35 // --- Basic CRUD --- 36 37 describe('add and get', () => { 38 it('stores a memory and retrieves it by ID', () => { 39 const db = memDb.getMemoryDb() 40 const entry = db.add({ 41 agentId: 'agent-1', 42 sessionId: 'session-1', 43 category: 'note', 44 title: 'Test Memory', 45 content: 'This is a test memory entry.', 46 }) 47 assert.ok(entry.id) 48 assert.equal(entry.title, 'Test Memory') 49 assert.equal(entry.content, 'This is a test memory entry.') 50 assert.equal(entry.category, 'note') 51 assert.equal(entry.agentId, 'agent-1') 52 53 const retrieved = db.get(entry.id) 54 assert.ok(retrieved) 55 assert.equal(retrieved!.id, entry.id) 56 assert.equal(retrieved!.title, 'Test Memory') 57 }) 58 59 it('generates unique IDs for each entry', () => { 60 const db = memDb.getMemoryDb() 61 const e1 = db.add({ agentId: null, category: 'note', title: 'A', content: 'a-content' }) 62 const e2 = db.add({ agentId: null, category: 'note', title: 'B', content: 'b-content' }) 63 assert.notEqual(e1.id, e2.id) 64 }) 65 66 it('returns null for non-existent ID', () => { 67 const db = memDb.getMemoryDb() 68 assert.equal(db.get('nonexistent-id-xyz'), null) 69 }) 70 }) 71 72 // --- Update --- 73 74 describe('update', () => { 75 it('updates a memory entry', () => { 76 const db = memDb.getMemoryDb() 77 const entry = db.add({ 78 agentId: 'agent-up', 79 category: 'note', 80 title: 'Original Title', 81 content: 'Original content.', 82 }) 83 const updated = db.update(entry.id, { title: 'Updated Title', content: 'Updated content.' }) 84 assert.ok(updated) 85 assert.equal(updated!.title, 'Updated Title') 86 assert.equal(updated!.content, 'Updated content.') 87 assert.equal(updated!.agentId, 'agent-up') 88 }) 89 90 it('returns null when updating non-existent entry', () => { 91 const db = memDb.getMemoryDb() 92 assert.equal(db.update('nonexistent-id', { title: 'Nope' }), null) 93 }) 94 }) 95 96 // --- Delete --- 97 98 describe('delete', () => { 99 it('removes a memory entry', () => { 100 const db = memDb.getMemoryDb() 101 const entry = db.add({ 102 agentId: 'agent-del', 103 category: 'note', 104 title: 'To Delete', 105 content: 'This will be deleted.', 106 }) 107 assert.ok(db.get(entry.id)) 108 db.delete(entry.id) 109 assert.equal(db.get(entry.id), null) 110 }) 111 }) 112 113 // --- List --- 114 115 describe('list', () => { 116 it('lists memories for an agent', () => { 117 const db = memDb.getMemoryDb() 118 const agentId = `agent-list-${Date.now()}` 119 db.add({ agentId, category: 'note', title: 'List 1', content: 'Content 1' }) 120 db.add({ agentId, category: 'note', title: 'List 2', content: 'Content 2' }) 121 db.add({ agentId: 'other-agent', category: 'note', title: 'Other', content: 'Other content' }) 122 123 const agentMemories = db.list(agentId) 124 assert.ok(agentMemories.length >= 2, `Expected at least 2 agent memories, got ${agentMemories.length}`) 125 const titles = agentMemories.map((m) => m.title) 126 assert.ok(titles.includes('List 1')) 127 assert.ok(titles.includes('List 2')) 128 }) 129 130 it('respects limit parameter', () => { 131 const db = memDb.getMemoryDb() 132 const agentId = `agent-limit-${Date.now()}` 133 for (let i = 0; i < 10; i++) { 134 db.add({ agentId, category: 'note', title: `Mem ${i}`, content: `Content ${i}` }) 135 } 136 const limited = db.list(agentId, 3) 137 assert.equal(limited.length, 3) 138 }) 139 }) 140 141 // --- FTS5 Search --- 142 143 describe('search (FTS5)', () => { 144 it('finds memories by content keyword', () => { 145 const db = memDb.getMemoryDb() 146 const agentId = `agent-fts-${Date.now()}` 147 db.add({ 148 agentId, 149 category: 'note', 150 title: 'Kubernetes Deployment', 151 content: 'Deployed the application to a Kubernetes cluster using Helm charts.', 152 }) 153 db.add({ 154 agentId, 155 category: 'note', 156 title: 'Database Migration', 157 content: 'Ran the PostgreSQL migration scripts successfully.', 158 }) 159 160 const results = db.search('kubernetes deployment helm', agentId) 161 assert.ok(results.length >= 1, `Expected FTS results for kubernetes, got ${results.length}`) 162 const titles = results.map((r) => r.title) 163 assert.ok(titles.includes('Kubernetes Deployment')) 164 }) 165 166 it('returns empty for skip-query patterns', () => { 167 const db = memDb.getMemoryDb() 168 assert.deepEqual(db.search(''), []) 169 assert.deepEqual(db.search('swarm_heartbeat_check'), []) 170 }) 171 172 it('returns empty for very long queries', () => { 173 const db = memDb.getMemoryDb() 174 const longQuery = 'x'.repeat(1300) 175 assert.deepEqual(db.search(longQuery), []) 176 }) 177 }) 178 179 // --- buildFtsQuery --- 180 181 describe('buildFtsQuery', () => { 182 it('removes stop words', () => { 183 const query = memDb.buildFtsQuery('what is the purpose of this') 184 // 'what', 'is', 'the', 'of', 'this' are stop words; 'purpose' should remain 185 assert.ok(query.includes('purpose')) 186 assert.ok(!query.includes('"the"')) 187 }) 188 189 it('returns empty for all stop words', () => { 190 const query = memDb.buildFtsQuery('the is a an') 191 assert.equal(query, '') 192 }) 193 194 it('limits to MAX_FTS_QUERY_TERMS', () => { 195 const query = memDb.buildFtsQuery('alpha bravo charlie delta echo foxtrot golf hotel india juliet') 196 // Should have at most 4 terms (slice 0..4) 197 const termCount = (query.match(/AND/g) || []).length + 1 198 assert.ok(termCount <= 4, `Expected at most 4 terms, got ${termCount}`) 199 }) 200 201 it('handles empty input', () => { 202 assert.equal(memDb.buildFtsQuery(''), '') 203 }) 204 205 it('deduplicates terms', () => { 206 const query = memDb.buildFtsQuery('kubernetes kubernetes kubernetes') 207 // Should only have one kubernetes 208 const occurrences = (query.match(/kubernetes/g) || []).length 209 assert.equal(occurrences, 1) 210 }) 211 212 it('skips very short terms', () => { 213 const query = memDb.buildFtsQuery('go is ok no') 214 // All terms are <3 chars or stop words 215 assert.equal(query, '') 216 }) 217 218 it('returns a single-term FTS query for short (3-4 char) words', () => { 219 // Single words like "cats", "blue", "dog" must produce a non-empty FTS 220 // query so the memory lookup UI works for short meaningful terms. 221 assert.equal(memDb.buildFtsQuery('cats'), '"cats"') 222 assert.equal(memDb.buildFtsQuery('blue'), '"blue"') 223 assert.equal(memDb.buildFtsQuery('dog'), '"dog"') 224 }) 225 }) 226 227 // --- Content hash dedup --- 228 229 describe('content hash dedup', () => { 230 it('reinforces instead of duplicating same content for same agent', () => { 231 const db = memDb.getMemoryDb() 232 const agentId = `agent-dedup-${Date.now()}` 233 const first = db.add({ 234 agentId, 235 category: 'fact', 236 title: 'Dedup Test', 237 content: 'Identical content for dedup testing.', 238 }) 239 const second = db.add({ 240 agentId, 241 category: 'fact', 242 title: 'Dedup Test Different Title', 243 content: 'Identical content for dedup testing.', 244 }) 245 // Should return the same ID (reinforced, not duplicated) 246 assert.equal(second.id, first.id) 247 assert.ok((second.reinforcementCount || 0) >= 1, 'Expected reinforcement count to increase') 248 }) 249 }) 250 251 // --- Memory linking --- 252 253 describe('link and unlink', () => { 254 it('links two memories bidirectionally', () => { 255 const db = memDb.getMemoryDb() 256 const a = db.add({ agentId: 'agent-link', category: 'note', title: 'Memory A', content: 'Content A' }) 257 const b = db.add({ agentId: 'agent-link', category: 'note', title: 'Memory B', content: 'Content B' }) 258 259 db.link(a.id, [b.id]) 260 261 const aAfter = db.get(a.id) 262 const bAfter = db.get(b.id) 263 assert.ok(aAfter!.linkedMemoryIds?.includes(b.id), 'A should link to B') 264 assert.ok(bAfter!.linkedMemoryIds?.includes(a.id), 'B should link back to A') 265 }) 266 267 it('unlinks memories bidirectionally', () => { 268 const db = memDb.getMemoryDb() 269 const a = db.add({ agentId: 'agent-unlink', category: 'note', title: 'Unlink A', content: 'Unlink Content A' }) 270 const b = db.add({ agentId: 'agent-unlink', category: 'note', title: 'Unlink B', content: 'Unlink Content B' }) 271 272 db.link(a.id, [b.id]) 273 db.unlink(a.id, [b.id]) 274 275 const aAfter = db.get(a.id) 276 const bAfter = db.get(b.id) 277 const aLinks = aAfter?.linkedMemoryIds || [] 278 const bLinks = bAfter?.linkedMemoryIds || [] 279 assert.ok(!aLinks.includes(b.id), 'A should no longer link to B') 280 assert.ok(!bLinks.includes(a.id), 'B should no longer link to A') 281 }) 282 283 it('link returns null for non-existent source', () => { 284 const db = memDb.getMemoryDb() 285 assert.equal(db.link('nonexistent', ['also-nonexistent']), null) 286 }) 287 }) 288 289 // --- Pinned memories --- 290 291 describe('pinned memories', () => { 292 it('lists pinned memories for an agent', () => { 293 const db = memDb.getMemoryDb() 294 const agentId = `agent-pinned-${Date.now()}` 295 db.add({ agentId, category: 'note', title: 'Regular', content: 'Not pinned' }) 296 db.add({ agentId, category: 'note', title: 'Pinned One', content: 'This is pinned', pinned: true }) 297 298 const pinned = db.listPinned(agentId) 299 assert.ok(pinned.length >= 1) 300 assert.ok(pinned.some((m) => m.title === 'Pinned One')) 301 assert.ok(pinned.every((m) => m.pinned === true)) 302 }) 303 }) 304 305 // --- Scope filtering --- 306 307 describe('filterMemoriesByScope', () => { 308 it('returns all entries with mode=all', () => { 309 const entries = [ 310 { id: '1', agentId: 'a1', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 311 { id: '2', agentId: null, category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 312 ] 313 const result = memDb.filterMemoriesByScope(entries, { mode: 'all' }) 314 assert.equal(result.length, 2) 315 }) 316 317 it('filters to global-only with mode=global', () => { 318 const entries = [ 319 { id: '1', agentId: 'a1', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 320 { id: '2', agentId: null, category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 321 ] 322 const result = memDb.filterMemoriesByScope(entries, { mode: 'global' }) 323 assert.equal(result.length, 1) 324 assert.equal(result[0].id, '2') 325 }) 326 327 it('filters by agent with mode=agent', () => { 328 const entries = [ 329 { id: '1', agentId: 'a1', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 330 { id: '2', agentId: 'a2', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 331 ] 332 const result = memDb.filterMemoriesByScope(entries, { mode: 'agent', agentId: 'a1' }) 333 assert.equal(result.length, 1) 334 assert.equal(result[0].agentId, 'a1') 335 }) 336 337 it('includes shared-with entries in agent mode', () => { 338 const entries = [ 339 { id: '1', agentId: 'a2', sharedWith: ['a1'], category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 340 ] 341 const result = memDb.filterMemoriesByScope(entries, { mode: 'agent', agentId: 'a1' }) 342 assert.equal(result.length, 1) 343 }) 344 345 it('returns empty for agent mode without agentId', () => { 346 const entries = [ 347 { id: '1', agentId: 'a1', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 348 ] 349 const result = memDb.filterMemoriesByScope(entries, { mode: 'agent' }) 350 assert.equal(result.length, 0) 351 }) 352 353 it('filters by session with mode=session', () => { 354 const entries = [ 355 { id: '1', agentId: 'a1', sessionId: 's1', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 356 { id: '2', agentId: 'a1', sessionId: 's2', category: 'note', title: 'x', content: 'y', createdAt: 0, updatedAt: 0 }, 357 ] 358 const result = memDb.filterMemoriesByScope(entries, { mode: 'session', sessionId: 's1' }) 359 assert.equal(result.length, 1) 360 assert.equal(result[0].sessionId, 's1') 361 }) 362 }) 363 364 // --- normalizeMemoryScopeMode --- 365 366 describe('normalizeMemoryScopeMode', () => { 367 it('normalizes known modes', () => { 368 assert.equal(memDb.normalizeMemoryScopeMode('all'), 'all') 369 assert.equal(memDb.normalizeMemoryScopeMode('global'), 'global') 370 assert.equal(memDb.normalizeMemoryScopeMode('agent'), 'agent') 371 assert.equal(memDb.normalizeMemoryScopeMode('session'), 'session') 372 assert.equal(memDb.normalizeMemoryScopeMode('project'), 'project') 373 }) 374 375 it('maps shared to global', () => { 376 assert.equal(memDb.normalizeMemoryScopeMode('shared'), 'global') 377 }) 378 379 it('defaults to auto for unknown', () => { 380 assert.equal(memDb.normalizeMemoryScopeMode('invalid'), 'auto') 381 assert.equal(memDb.normalizeMemoryScopeMode(''), 'auto') 382 assert.equal(memDb.normalizeMemoryScopeMode(null), 'auto') 383 assert.equal(memDb.normalizeMemoryScopeMode(undefined), 'auto') 384 }) 385 }) 386 387 // --- getLatestBySessionCategory --- 388 389 describe('getLatestBySessionCategory', () => { 390 it('returns a memory for a valid session+category', () => { 391 const db = memDb.getMemoryDb() 392 const sessionId = `sess-latest-${Date.now()}` 393 db.add({ agentId: 'a', sessionId, category: 'working/context', title: 'Entry A', content: 'content alpha unique' }) 394 db.add({ agentId: 'a', sessionId, category: 'working/context', title: 'Entry B', content: 'content beta unique' }) 395 396 const latest = db.getLatestBySessionCategory(sessionId, 'working/context') 397 assert.ok(latest, 'Should return a memory entry') 398 assert.equal(latest!.sessionId, sessionId) 399 assert.equal(latest!.category, 'working/context') 400 }) 401 402 it('returns null for non-matching category', () => { 403 const db = memDb.getMemoryDb() 404 const sessionId = `sess-nomatch-${Date.now()}` 405 db.add({ agentId: 'a', sessionId, category: 'note', title: 'X', content: 'x content unique nomatch' }) 406 assert.equal(db.getLatestBySessionCategory(sessionId, 'working/context'), null) 407 }) 408 409 it('returns null for empty session/category', () => { 410 const db = memDb.getMemoryDb() 411 assert.equal(db.getLatestBySessionCategory('', 'note'), null) 412 assert.equal(db.getLatestBySessionCategory('valid', ''), null) 413 }) 414 }) 415 416 // --- countsByAgent --- 417 418 describe('countsByAgent', () => { 419 it('returns counts grouped by agent', () => { 420 const db = memDb.getMemoryDb() 421 // Data already exists from previous tests — just verify the shape 422 const counts = db.countsByAgent() 423 assert.equal(typeof counts, 'object') 424 // Should have at least one key 425 assert.ok(Object.keys(counts).length >= 1) 426 for (const [, val] of Object.entries(counts)) { 427 assert.equal(typeof val, 'number') 428 assert.ok(val > 0) 429 } 430 }) 431 }) 432 433 // --- Delete cleans up links --- 434 435 describe('delete cleans up linked references', () => { 436 it('removes deleted ID from other memories linkedMemoryIds', () => { 437 const db = memDb.getMemoryDb() 438 const a = db.add({ agentId: 'agent-cleanup', category: 'note', title: 'Cleanup A', content: 'Cleanup A content' }) 439 const b = db.add({ agentId: 'agent-cleanup', category: 'note', title: 'Cleanup B', content: 'Cleanup B content' }) 440 const c = db.add({ agentId: 'agent-cleanup', category: 'note', title: 'Cleanup C', content: 'Cleanup C content' }) 441 442 db.link(a.id, [b.id, c.id]) 443 444 // Verify links exist 445 const bBefore = db.get(b.id) 446 assert.ok(bBefore?.linkedMemoryIds?.includes(a.id)) 447 448 // Delete A 449 db.delete(a.id) 450 451 // B and C should no longer reference A 452 const bAfter = db.get(b.id) 453 const cAfter = db.get(c.id) 454 const bLinks = bAfter?.linkedMemoryIds || [] 455 const cLinks = cAfter?.linkedMemoryIds || [] 456 assert.ok(!bLinks.includes(a.id), 'B should not reference deleted A') 457 assert.ok(!cLinks.includes(a.id), 'C should not reference deleted A') 458 }) 459 }) 460 461 // --- addKnowledge --- 462 463 describe('addKnowledge', () => { 464 it('creates a global knowledge entry', () => { 465 const entry = memDb.addKnowledge({ 466 title: 'API Rate Limits', 467 content: 'The API has a rate limit of 100 requests per minute.', 468 tags: ['api', 'limits'], 469 }) 470 assert.ok(entry.id) 471 assert.equal(entry.category, 'knowledge') 472 assert.equal(entry.agentId, null) 473 assert.equal(entry.title, 'API Rate Limits') 474 }) 475 }) 476 477 // --- searchKnowledge --- 478 479 describe('searchKnowledge', () => { 480 it('finds knowledge entries by query', () => { 481 // Add a knowledge entry with a unique term 482 memDb.addKnowledge({ 483 title: 'Photosynthesis Process', 484 content: 'Chlorophyll absorbs sunlight to convert carbon dioxide into glucose.', 485 tags: ['biology', 'science'], 486 }) 487 488 const results = memDb.searchKnowledge('chlorophyll photosynthesis glucose') 489 assert.ok(results.length >= 1, `Expected at least 1 result, got ${results.length}`) 490 assert.ok(results.every((r) => r.category === 'knowledge')) 491 }) 492 }) 493 })