/ src / lib / server / memory / memory-integration.test.ts
memory-integration.test.ts
  1  import { after, before, describe, it } from 'node:test'
  2  import assert from 'node:assert/strict'
  3  import fs from 'node:fs'
  4  import os from 'node:os'
  5  import path from 'node:path'
  6  import type { Session } from '@/types'
  7  
  8  const originalEnv = {
  9    DATA_DIR: process.env.DATA_DIR,
 10    WORKSPACE_DIR: process.env.WORKSPACE_DIR,
 11    SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
 12  }
 13  
 14  let tempDir = ''
 15  let memDb: ReturnType<Awaited<typeof import('@/lib/server/memory/memory-db')>['getMemoryDb']>
 16  let executeMemoryAction: Awaited<typeof import('@/lib/server/session-tools/memory')>['executeMemoryAction']
 17  let memoryPolicy: typeof import('@/lib/server/memory/memory-policy')
 18  
 19  before(async () => {
 20    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-memory-int-'))
 21    process.env.DATA_DIR = path.join(tempDir, 'data')
 22    process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
 23    process.env.SWARMCLAW_BUILD_MODE = '1'
 24    fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
 25    fs.mkdirSync(process.env.WORKSPACE_DIR, { recursive: true })
 26  
 27    const memDbMod = await import('@/lib/server/memory/memory-db')
 28    memDb = memDbMod.getMemoryDb()
 29  
 30    const memoryMod = await import('@/lib/server/session-tools/memory')
 31    executeMemoryAction = memoryMod.executeMemoryAction
 32  
 33    memoryPolicy = await import('@/lib/server/memory/memory-policy')
 34  })
 35  
 36  after(() => {
 37    if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
 38    else process.env.DATA_DIR = originalEnv.DATA_DIR
 39    if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
 40    else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
 41    if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
 42    else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
 43    fs.rmSync(tempDir, { recursive: true, force: true })
 44  })
 45  
 46  // ─── Memory CRUD Lifecycle ──────────────────────────────────────────
 47  
 48  describe('Memory CRUD lifecycle via executeMemoryAction', () => {
 49    let storedId = ''
 50  
 51    it('stores a memory and returns confirmation', async () => {
 52      const result = await executeMemoryAction(
 53        { action: 'store', key: 'test-crud', value: 'CRUD content', category: 'note' },
 54        { agentId: 'agent-crud' },
 55      )
 56      assert.match(String(result), /Stored memory/)
 57      const idMatch = String(result).match(/\(id: ([^)]+)\)/)
 58      assert.ok(idMatch, 'result should contain an id')
 59      storedId = idMatch[1]
 60    })
 61  
 62    it('gets the stored memory by id', async () => {
 63      const result = await executeMemoryAction(
 64        { action: 'get', id: storedId },
 65        { agentId: 'agent-crud' },
 66      )
 67      assert.match(String(result), /test-crud/)
 68      assert.match(String(result), /CRUD content/)
 69    })
 70  
 71    it('searches for the memory by query', async () => {
 72      const result = await executeMemoryAction(
 73        { action: 'search', query: 'CRUD content' },
 74        { agentId: 'agent-crud' },
 75      )
 76      assert.match(String(result), /CRUD content/)
 77    })
 78  
 79    it('lists all memories and includes it', async () => {
 80      const result = await executeMemoryAction(
 81        { action: 'list' },
 82        { agentId: 'agent-crud' },
 83      )
 84      assert.match(String(result), /test-crud/)
 85    })
 86  
 87    it('updates title and content', async () => {
 88      const result = await executeMemoryAction(
 89        { action: 'update', id: storedId, title: 'updated-title', value: 'updated-content' },
 90        { agentId: 'agent-crud' },
 91      )
 92      assert.match(String(result), /Updated memory/)
 93      assert.match(String(result), /updated-title/)
 94    })
 95  
 96    it('deletes and confirms gone', async () => {
 97      const deleteResult = await executeMemoryAction(
 98        { action: 'delete', id: storedId },
 99        { agentId: 'agent-crud' },
100      )
101      assert.match(String(deleteResult), /Deleted/)
102      const getResult = await executeMemoryAction(
103        { action: 'get', id: storedId },
104        { agentId: 'agent-crud' },
105      )
106      assert.match(String(getResult), /not found|access denied/i)
107    })
108  
109    it('falls back to the latest user fact when store omits value', async () => {
110      const sessionContext: Partial<Session> = {
111        id: 'session-implicit',
112        name: 'Implicit store',
113        agentId: 'agent-crud',
114        messages: [
115          { role: 'user', text: 'Remember this exactly: Project Kodiak uses amber-fox and the freeze date is April 21, 2026.', time: Date.now() },
116        ],
117      }
118      const result = await executeMemoryAction(
119        { action: 'store', key: 'implicit-fact-store' },
120        sessionContext,
121      )
122      assert.match(String(result), /Stored memory/)
123  
124      const search = await executeMemoryAction(
125        { action: 'search', query: 'amber-fox April 21 2026' },
126        { agentId: 'agent-crud' },
127      )
128      assert.match(String(search), /April 21, 2026/)
129      assert.match(String(search), /amber-fox/)
130    })
131  })
132  
133  // ─── Memory Linking & Graph ─────────────────────────────────────────
134  
135  describe('Memory linking and graph', () => {
136    let idA = ''
137    let idB = ''
138    let idC = ''
139  
140    before(async () => {
141      const a = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node A', content: 'alpha unique content' })
142      const b = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node B', content: 'beta unique content' })
143      const c = memDb.add({ agentId: 'agent-link', category: 'note', title: 'Node C', content: 'gamma unique content' })
144      idA = a.id
145      idB = b.id
146      idC = c.id
147    })
148  
149    it('links A→B and B→C with bidirectional links', () => {
150      memDb.link(idA, [idB], true)
151      memDb.link(idB, [idC], true)
152  
153      const a = memDb.get(idA)!
154      const b = memDb.get(idB)!
155      const c = memDb.get(idC)!
156  
157      assert.ok(a.linkedMemoryIds?.includes(idB), 'A should link to B')
158      assert.ok(b.linkedMemoryIds?.includes(idA), 'B should link back to A')
159      assert.ok(b.linkedMemoryIds?.includes(idC), 'B should link to C')
160      assert.ok(c.linkedMemoryIds?.includes(idB), 'C should link back to B')
161    })
162  
163    it('unlinks A→B bidirectionally', () => {
164      memDb.unlink(idA, [idB], true)
165  
166      const a = memDb.get(idA)!
167      const b = memDb.get(idB)!
168  
169      const aLinks = a.linkedMemoryIds || []
170      const bLinks = b.linkedMemoryIds || []
171      assert.ok(!aLinks.includes(idB), 'A should no longer link to B')
172      assert.ok(!bLinks.includes(idA), 'B should no longer link to A')
173      // B↔C should still exist
174      assert.ok(bLinks.includes(idC), 'B should still link to C')
175    })
176  
177    it('deleting C cleans up B linkedMemoryIds', () => {
178      memDb.delete(idC)
179      const b = memDb.get(idB)!
180      const bLinks = b.linkedMemoryIds || []
181      assert.ok(!bLinks.includes(idC), 'B should no longer reference deleted C')
182    })
183  })
184  
185  // ─── Scope Filtering ────────────────────────────────────────────────
186  
187  describe('Scope filtering', () => {
188    before(() => {
189      memDb.add({ agentId: 'agent-a', category: 'note', title: 'A-only', content: 'scope test agent a' })
190      memDb.add({ agentId: 'agent-b', category: 'note', title: 'B-only', content: 'scope test agent b' })
191      memDb.add({ agentId: null, category: 'note', title: 'Shared global', content: 'scope test global' })
192      memDb.add({ agentId: 'agent-c', category: 'note', title: 'Shared with B', content: 'scope shared with b', sharedWith: ['agent-b'] })
193    })
194  
195    it('agent scope shows only that agent memories', async () => {
196      const result = await executeMemoryAction(
197        { action: 'list', scope: 'agent' },
198        { agentId: 'agent-a' },
199      )
200      assert.match(String(result), /A-only/)
201      assert.doesNotMatch(String(result), /B-only/)
202    })
203  
204    it('global scope shows only shared memories (no agentId)', async () => {
205      const result = await executeMemoryAction(
206        { action: 'list', scope: 'global' },
207        { agentId: 'agent-a' },
208      )
209      assert.match(String(result), /Shared global/)
210      assert.doesNotMatch(String(result), /A-only/)
211    })
212  
213    it('sharedWith memories visible to target agent in agent scope', async () => {
214      const result = await executeMemoryAction(
215        { action: 'list', scope: 'agent' },
216        { agentId: 'agent-b' },
217      )
218      assert.match(String(result), /Shared with B/)
219    })
220  })
221  
222  describe('Search source filtering', () => {
223    before(() => {
224      memDb.add({
225        agentId: 'agent-source-filter',
226        category: 'projects/decisions',
227        title: 'Kodiak durable fact',
228        content: 'Project Kodiak uses amber-fox and the freeze date is April 21, 2026.',
229      })
230      memDb.add({
231        agentId: 'agent-source-filter',
232        sessionId: 'archive-session-1',
233        category: 'session_archive',
234        title: 'Session archive: kodiak stale',
235        content: 'Transcript excerpt: Project Kodiak freeze date was April 18, 2026.',
236        metadata: { tier: 'archive' },
237      })
238      memDb.add({
239        agentId: 'agent-source-filter',
240        category: 'operations/execution',
241        title: 'Auto execution note',
242        content: 'assistant_outcome: during a previous run I mentioned April 18, 2026 while fixing Project Kodiak memory.',
243      })
244    })
245  
246    it('search defaults to durable memories', async () => {
247      const result = await executeMemoryAction(
248        { action: 'search', query: 'Project Kodiak amber-fox freeze date' },
249        { agentId: 'agent-source-filter', sessionId: 'agent-source-filter', messages: [] },
250      )
251      assert.match(String(result), /Kodiak durable fact/)
252      assert.doesNotMatch(String(result), /Session archive: kodiak stale/)
253      assert.doesNotMatch(String(result), /Auto execution note/)
254    })
255  
256    it('search can explicitly include archives and working memories', async () => {
257      const archiveResult = await executeMemoryAction(
258        { action: 'search', query: 'Project Kodiak freeze date', sources: ['durable', 'archive', 'working'] },
259        { agentId: 'agent-source-filter', sessionId: 'archive-session-1', messages: [] },
260      )
261      assert.match(String(archiveResult), /Kodiak durable fact/)
262      assert.match(String(archiveResult), /Session archive: kodiak stale/)
263  
264      const workingResult = await executeMemoryAction(
265        { action: 'search', query: 'assistant_outcome previous run April 18, 2026', sources: ['working'] },
266        { agentId: 'agent-source-filter', sessionId: 'archive-session-1', messages: [] },
267      )
268      assert.match(String(workingResult), /Auto execution note/)
269    })
270  })
271  
272  describe('Canonical memory correction', () => {
273    it('update without an explicit id resolves and corrects the canonical durable memory', async () => {
274      const stale = memDb.add({
275        agentId: 'agent-canonical',
276        category: 'projects/decisions',
277        title: 'Project Kodiak codename and freeze date',
278        content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 18, 2026.',
279      })
280      memDb.add({
281        agentId: 'agent-canonical',
282        category: 'note',
283        title: '[auto-consolidated] Project Kodiak note',
284        content: 'Stored earlier: Project Kodiak codename amber-fox freeze date April 18, 2026.',
285      })
286  
287      const result = await executeMemoryAction(
288        {
289          action: 'update',
290          title: 'Project Kodiak freeze date correction',
291          value: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 21, 2026.',
292        },
293        { agentId: 'agent-canonical', sessionId: 'agent-canonical', messages: [] },
294      )
295  
296      assert.match(String(result), /Updated memory/)
297      const corrected = memDb.get(stale.id)
298      assert.ok(corrected)
299      assert.match(String(corrected?.content), /April 21, 2026/)
300  
301      const recall = await executeMemoryAction(
302        { action: 'search', query: 'Project Kodiak amber-fox freeze date' },
303        { agentId: 'agent-canonical', sessionId: 'agent-canonical', messages: [] },
304      )
305      assert.match(String(recall), /April 21, 2026/)
306      assert.doesNotMatch(String(recall), /auto-consolidated/i)
307    })
308  
309    it('store merges into an existing canonical durable memory instead of appending a conflicting duplicate', async () => {
310      const base = memDb.add({
311        agentId: 'agent-canonical-store',
312        category: 'projects/context',
313        title: 'Project Kodiak details',
314        content: 'Project Kodiak: codename amber-fox, freeze date April 18 2026',
315      })
316  
317      const result = await executeMemoryAction(
318        {
319          action: 'store',
320          title: 'Project Kodiak details',
321          value: 'Project Kodiak: codename amber-fox, freeze date April 21 2026',
322          category: 'projects/context',
323        },
324        { agentId: 'agent-canonical-store', sessionId: 'agent-canonical-store', messages: [] },
325      )
326  
327      assert.match(String(result), /updating the canonical entry/i)
328      const updated = memDb.get(base.id)
329      assert.ok(updated)
330      assert.match(String(updated?.content), /April 21 2026/)
331  
332      const durableRows = memDb.list('agent-canonical-store', 20)
333        .filter((entry) => /Project Kodiak/.test(`${entry.title} ${entry.content}`))
334        .filter((entry) => entry.category !== 'session_archive')
335      assert.equal(durableRows.filter((entry) => entry.id === base.id).length, 1)
336    })
337  
338    it('parses structured JSON payloads that arrive inside query or value fields', async () => {
339      const base = memDb.add({
340        agentId: 'agent-structured-payload',
341        category: 'projects/decisions',
342        title: 'Project Kodiak codename and freeze date',
343        content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 18, 2026.',
344      })
345  
346      const result = await executeMemoryAction(
347        {
348          action: 'update',
349          query: JSON.stringify({
350            title: 'Project Kodiak codename and freeze date',
351            category: 'projects/decisions',
352            content: 'Project Kodiak uses the codename "amber-fox" and the freeze date is April 21, 2026.',
353          }),
354        },
355        { agentId: 'agent-structured-payload', sessionId: 'agent-structured-payload', messages: [] },
356      )
357  
358      assert.match(String(result), /Updated memory/)
359      const updated = memDb.get(base.id)
360      assert.ok(updated)
361      assert.match(String(updated?.content), /April 21, 2026/)
362      assert.doesNotMatch(String(updated?.content), /"title"/)
363    })
364  })
365  
366  // ─── Pinned Memories ────────────────────────────────────────────────
367  
368  describe('Pinned memories', () => {
369    before(() => {
370      memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 1', content: 'not pinned one', pinned: false })
371      memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 2', content: 'not pinned two', pinned: false })
372      memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Normal 3', content: 'not pinned three', pinned: false })
373      memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Pinned 1', content: 'pinned content one', pinned: true })
374      memDb.add({ agentId: 'agent-pin', category: 'note', title: 'Pinned 2', content: 'pinned content two', pinned: true })
375    })
376  
377    it('listPinned returns only pinned memories', () => {
378      const pinned = memDb.listPinned('agent-pin')
379      assert.ok(pinned.length >= 2, `expected at least 2 pinned, got ${pinned.length}`)
380      for (const entry of pinned) {
381        assert.ok(entry.pinned, `entry "${entry.title}" should be pinned`)
382      }
383    })
384  })
385  
386  // ─── Category Normalization ─────────────────────────────────────────
387  
388  describe('Category normalization (comprehensive)', () => {
389    const norm = (cat: string, title?: string, content?: string) =>
390      memoryPolicy.normalizeMemoryCategory(cat, title ?? null, content ?? null)
391  
392    it('maps flat categories to hierarchical', () => {
393      assert.equal(norm('preference'), 'identity/preferences')
394      assert.equal(norm('decision'), 'projects/decisions')
395      assert.equal(norm('error'), 'execution/errors')
396      assert.equal(norm('project'), 'projects/context')
397      assert.equal(norm('learning'), 'projects/learnings')
398      assert.equal(norm('breadcrumb'), 'operations/execution')
399      assert.equal(norm('fact'), 'knowledge/facts')
400      assert.equal(norm('working'), 'working/scratch')
401    })
402  
403    it('falls through to knowledge/facts when explicit is "note"', () => {
404      // normalizeMemoryCategory no longer content-sniffs — "note" maps to the default
405      assert.equal(norm('note', 'user prefers dark mode', ''), 'knowledge/facts')
406      assert.equal(norm('note', 'decided to ship Docker', ''), 'knowledge/facts')
407      assert.equal(norm('note', 'root cause was a null pointer', ''), 'knowledge/facts')
408    })
409  
410    it('passes through already-hierarchical categories', () => {
411      assert.equal(norm('identity/profile'), 'identity/profile')
412      assert.equal(norm('custom/bucket'), 'custom/bucket')
413    })
414  })
415  
416  // ─── Memory Doctor Report ───────────────────────────────────────────
417  
418  describe('Memory doctor report', () => {
419    it('builds report with correct counts', () => {
420      const entries = [
421        { id: '1', agentId: 'a', category: 'identity/preferences', title: '', content: '', pinned: true, linkedMemoryIds: ['2'], createdAt: 0, updatedAt: 0 },
422        { id: '2', agentId: 'a', category: 'projects/decisions', title: '', content: '', pinned: false, linkedMemoryIds: ['1'], sharedWith: ['b'], createdAt: 0, updatedAt: 0 },
423        { id: '3', agentId: 'a', category: 'knowledge/facts', title: '', content: '', pinned: true, createdAt: 0, updatedAt: 0 },
424        { id: '4', agentId: null, category: 'operations/execution', title: '', content: '', pinned: false, sharedWith: ['a'], createdAt: 0, updatedAt: 0 },
425      ] as unknown as import('@/types').MemoryEntry[]
426  
427      const report = memoryPolicy.buildMemoryDoctorReport(entries, 'a')
428      assert.match(report, /Visible memories: 4/)
429      assert.match(report, /Pinned: 2/)
430      assert.match(report, /Linked: 2/)
431      assert.match(report, /Shared: 2/)
432      assert.match(report, /identity/)
433      assert.match(report, /projects/)
434      assert.match(report, /knowledge/)
435      assert.match(report, /operations/)
436    })
437  })
438  
439  // ─── Auto-capture Policy ────────────────────────────────────────────
440  
441  describe('Auto-capture policy', () => {
442    it('shouldInjectMemoryContext: short ack → false', () => {
443      assert.equal(memoryPolicy.shouldInjectMemoryContext('ok'), false)
444    })
445  
446    it('shouldInjectMemoryContext: greeting → false', () => {
447      assert.equal(memoryPolicy.shouldInjectMemoryContext('hello'), false)
448    })
449  
450    it('shouldInjectMemoryContext: short memory meta → false', () => {
451      assert.equal(memoryPolicy.shouldInjectMemoryContext('remember this'), false)
452    })
453  
454    it('shouldInjectMemoryContext: substantive message → true', () => {
455      assert.equal(
456        memoryPolicy.shouldInjectMemoryContext('Compare the current deployment plan with what we decided yesterday'),
457        true,
458      )
459    })
460  
461    it('shouldAutoCaptureMemoryTurn: short messages → false', () => {
462      assert.equal(memoryPolicy.shouldAutoCaptureMemoryTurn('hi', 'hello!'), false)
463    })
464  
465    it('shouldAutoCaptureMemoryTurn: ack + response → false', () => {
466      assert.equal(
467        memoryPolicy.shouldAutoCaptureMemoryTurn('thanks', 'You are welcome, happy to help with that!'),
468        false,
469      )
470    })
471  
472    it('shouldAutoCaptureMemoryTurn: error response → false', () => {
473      assert.equal(
474        memoryPolicy.shouldAutoCaptureMemoryTurn(
475          'Please deploy the production environment now with all the settings',
476          "sorry, I can't do that because I don't have the credentials needed.",
477        ),
478        false,
479      )
480    })
481  
482    it('shouldAutoCaptureMemoryTurn: substantive exchange → true', () => {
483      assert.equal(
484        memoryPolicy.shouldAutoCaptureMemoryTurn(
485          'We decided to use the shared staging environment and keep the worker count at 2 for now.',
486          'Decision captured: shared staging, worker count 2, and we will revisit after load testing next week.',
487        ),
488        true,
489      )
490    })
491  
492    it('shouldAutoCaptureMemoryTurn: HEARTBEAT_OK response → false', () => {
493      assert.equal(
494        memoryPolicy.shouldAutoCaptureMemoryTurn(
495          'This is a real substantive question about the project and architecture',
496          'HEARTBEAT_OK all systems nominal',
497        ),
498        false,
499      )
500    })
501  })
502  
503  // ─── inferAutomaticMemoryCategory ───────────────────────────────────
504  
505  describe('inferAutomaticMemoryCategory', () => {
506    it('returns knowledge/facts since content-sniffing was removed', () => {
507      // inferAutomaticMemoryCategory delegates to normalizeMemoryCategory('note', ...),
508      // which no longer infers category from content — agents pick categories explicitly
509      assert.equal(
510        memoryPolicy.inferAutomaticMemoryCategory('user prefers dark mode', 'noted'),
511        'knowledge/facts',
512      )
513      assert.equal(
514        memoryPolicy.inferAutomaticMemoryCategory('decided to ship Docker first', 'locked in'),
515        'knowledge/facts',
516      )
517      assert.equal(
518        memoryPolicy.inferAutomaticMemoryCategory('root cause was a null pointer bug', 'fixed now'),
519        'knowledge/facts',
520      )
521    })
522  })
523  
524  // ─── Memory Deduplication ───────────────────────────────────────────
525  
526  describe('Memory deduplication via contentHash', () => {
527    it('storing same content twice reinforces instead of duplicating', () => {
528      const first = memDb.add({ agentId: 'agent-dedup', category: 'note', title: 'Dup test', content: 'exact duplicate content for dedup test' })
529      const second = memDb.add({ agentId: 'agent-dedup', category: 'note', title: 'Dup test', content: 'exact duplicate content for dedup test' })
530      assert.equal(first.id, second.id, 'second add should return same id')
531      assert.ok((second.reinforcementCount ?? 0) >= 1, 'reinforcement count should be bumped')
532    })
533  })
534  
535  // ─── Unknown Action ─────────────────────────────────────────────────
536  
537  describe('Unknown action', () => {
538    it('returns unknown action message', async () => {
539      const result = await executeMemoryAction({ action: 'invalid' }, null)
540      assert.match(String(result), /Unknown action/)
541    })
542  })
543  
544  // ─── Edge Cases ─────────────────────────────────────────────────────
545  
546  describe('Edge cases', () => {
547    it('store with empty value is rejected when no fallback fact exists', async () => {
548      const result = await executeMemoryAction(
549        { action: 'store', key: 'empty-val', value: '', category: 'note' },
550        { agentId: 'agent-edge' },
551      )
552      assert.match(String(result), /requires a non-empty value/i)
553    })
554  
555    it('store with missing key defaults title to Untitled', async () => {
556      const result = await executeMemoryAction(
557        { action: 'store', value: 'some content without key', category: 'note' },
558        { agentId: 'agent-edge' },
559      )
560      assert.match(String(result), /Stored memory/)
561      assert.match(String(result), /Untitled/)
562    })
563  
564    it('store with null context still works', async () => {
565      const result = await executeMemoryAction(
566        { action: 'store', key: 'null-ctx', value: 'null context test', category: 'note' },
567        null,
568      )
569      assert.match(String(result), /Stored memory/)
570    })
571  
572    it('store with imagePath that does not exist still stores', async () => {
573      const result = await executeMemoryAction(
574        { action: 'store', key: 'no-image', value: 'image missing', category: 'note', imagePath: '/tmp/nonexistent-image.png' },
575        { agentId: 'agent-edge' },
576      )
577      assert.match(String(result), /Stored memory/)
578    })
579  
580    it('update non-existent memory → not found', async () => {
581      const result = await executeMemoryAction(
582        { action: 'update', id: 'nonexistent-id-xyz', value: 'updated' },
583        { agentId: 'agent-edge' },
584      )
585      assert.match(String(result), /not found/i)
586    })
587  
588    it('get with non-existent id → not found', async () => {
589      const result = await executeMemoryAction(
590        { action: 'get', id: 'missing-id-abc' },
591        { agentId: 'agent-edge' },
592      )
593      assert.match(String(result), /not found/i)
594    })
595  
596    it('link requires targetIds', async () => {
597      const entry = memDb.add({ agentId: 'agent-edge', category: 'note', title: 'Link test', content: 'link target test' })
598      const result = await executeMemoryAction(
599        { action: 'link', id: entry.id },
600        { agentId: 'agent-edge' },
601      )
602      assert.match(String(result), /requires targetIds/i)
603    })
604  
605    it('unlink requires targetIds', async () => {
606      const entry = memDb.add({ agentId: 'agent-edge', category: 'note', title: 'Unlink test', content: 'unlink target test' })
607      const result = await executeMemoryAction(
608        { action: 'unlink', id: entry.id },
609        { agentId: 'agent-edge' },
610      )
611      assert.match(String(result), /requires targetIds/i)
612    })
613  
614    it('delete non-existent memory → not found', async () => {
615      const result = await executeMemoryAction(
616        { action: 'delete', id: 'phantom-id-999' },
617        { agentId: 'agent-edge' },
618      )
619      assert.match(String(result), /not found/i)
620    })
621  })
622  
623  // ─── Doctor via executeMemoryAction ─────────────────────────────────
624  
625  describe('Doctor action via executeMemoryAction', () => {
626    it('returns a doctor report', async () => {
627      const result = await executeMemoryAction(
628        { action: 'doctor' },
629        { agentId: 'agent-crud' },
630      )
631      assert.match(String(result), /Memory Doctor/)
632      assert.match(String(result), /Visible memories/)
633    })
634  })
635  
636  // ─── Direct memDb CRUD ──────────────────────────────────────────────
637  
638  describe('Direct memDb CRUD', () => {
639    it('add, get, update, delete cycle', () => {
640      const entry = memDb.add({ agentId: 'direct-agent', category: 'note', title: 'Direct test', content: 'direct content' })
641      assert.ok(entry.id)
642      assert.equal(entry.title, 'Direct test')
643  
644      const fetched = memDb.get(entry.id)
645      assert.ok(fetched)
646      assert.equal(fetched.content, 'direct content')
647  
648      const updated = memDb.update(entry.id, { title: 'Updated direct', content: 'updated direct content' })
649      assert.ok(updated)
650      assert.equal(updated.title, 'Updated direct')
651  
652      memDb.delete(entry.id)
653      const gone = memDb.get(entry.id)
654      assert.equal(gone, null)
655    })
656  
657    it('list returns entries and respects updatedAt ordering', () => {
658      const a = memDb.add({ agentId: 'list-agent', category: 'note', title: 'First', content: 'first entry for list test' })
659      const b = memDb.add({ agentId: 'list-agent', category: 'note', title: 'Second', content: 'second entry for list test' })
660      const entries = memDb.list('list-agent', 10)
661      assert.ok(entries.length >= 2, 'should list at least 2 entries')
662      assert.ok(entries.some((e) => e.id === a.id), 'should include entry a')
663      assert.ok(entries.some((e) => e.id === b.id), 'should include entry b')
664      // Verify entries are sorted by updatedAt descending (ties allowed)
665      for (let i = 1; i < entries.length; i++) {
666        assert.ok(entries[i - 1].updatedAt >= entries[i].updatedAt, 'list should be ordered by updatedAt desc')
667      }
668    })
669  
670    it('search via FTS finds matching entries', () => {
671      memDb.add({ agentId: 'search-agent', category: 'note', title: 'Kubernetes deployment', content: 'helm chart configuration for kubernetes cluster' })
672      const results = memDb.search('kubernetes helm chart', 'search-agent')
673      assert.ok(results.length >= 1, 'FTS should find the kubernetes entry')
674      assert.ok(results.some((r) => r.title === 'Kubernetes deployment'))
675    })
676  
677    it('update returns null for non-existent id', () => {
678      const result = memDb.update('missing-xyz', { title: 'no' })
679      assert.equal(result, null)
680    })
681  })
682  
683  // ─── Link and Unlink via executeMemoryAction ────────────────────────
684  
685  describe('Link and unlink via executeMemoryAction', () => {
686    let id1 = ''
687    let id2 = ''
688  
689    before(() => {
690      const entry1 = memDb.add({ agentId: 'agent-act-link', category: 'note', title: 'Link A', content: 'link action A' })
691      const entry2 = memDb.add({ agentId: 'agent-act-link', category: 'note', title: 'Link B', content: 'link action B' })
692      id1 = entry1.id
693      id2 = entry2.id
694    })
695  
696    it('links memories via action', async () => {
697      const result = await executeMemoryAction(
698        { action: 'link', id: id1, targetIds: [id2] },
699        { agentId: 'agent-act-link' },
700      )
701      assert.match(String(result), /Linked/)
702      const entry = memDb.get(id1)!
703      assert.ok(entry.linkedMemoryIds?.includes(id2))
704    })
705  
706    it('unlinks memories via action', async () => {
707      const result = await executeMemoryAction(
708        { action: 'unlink', id: id1, targetIds: [id2] },
709        { agentId: 'agent-act-link' },
710      )
711      assert.match(String(result), /Unlinked/)
712      const entry = memDb.get(id1)!
713      const links = entry.linkedMemoryIds || []
714      assert.ok(!links.includes(id2))
715    })
716  })