/ src / lib / server / memory / memory-db.test.ts
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  })