@1mbrain/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1017 @@
1
+ /**
2
+ * Memory Engine Tests
3
+ *
4
+ * Integration tests using SQLite in-memory and a mock embedding provider.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { MemoryEngine } from '../src/engine.js';
9
+ import { SqliteDatabaseProvider } from '../src/db/sqlite-provider.js';
10
+ import { InMemoryEventBus } from '../src/events.js';
11
+ import type { EmbeddingProvider, MemoryEvent } from '../src/types.js';
12
+
13
+ // ─── Mock Embedding Provider ────────────────────────────
14
+
15
+ class MockEmbeddingProvider implements EmbeddingProvider {
16
+ readonly name = 'mock';
17
+ readonly model = 'mock-embed-v1';
18
+ readonly dimensions = 4;
19
+
20
+ // Simple hash-based embedding for deterministic testing
21
+ async embed(text: string): Promise<number[]> {
22
+ const hash = simpleHash(text);
23
+ return [Math.sin(hash), Math.cos(hash), Math.sin(hash * 2), Math.cos(hash * 2)];
24
+ }
25
+
26
+ async embedBatch(texts: string[]): Promise<number[][]> {
27
+ return Promise.all(texts.map((t) => this.embed(t)));
28
+ }
29
+ }
30
+
31
+ function simpleHash(str: string): number {
32
+ let hash = 0;
33
+ for (let i = 0; i < str.length; i++) {
34
+ hash = (hash << 5) - hash + str.charCodeAt(i);
35
+ hash |= 0;
36
+ }
37
+ return hash / 2147483647; // Normalize to [-1, 1]
38
+ }
39
+
40
+ class TokenEmbeddingProvider implements EmbeddingProvider {
41
+ readonly name = 'token-test';
42
+ readonly model = 'token-test-v1';
43
+ readonly dimensions = 32;
44
+
45
+ async embed(text: string): Promise<number[]> {
46
+ const vector = new Array(this.dimensions).fill(0);
47
+ const tokens = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
48
+
49
+ for (const token of tokens) {
50
+ const index = Math.abs(Math.floor(simpleHash(token) * 1_000_000)) % this.dimensions;
51
+ vector[index] += 1;
52
+ }
53
+
54
+ return vector;
55
+ }
56
+
57
+ async embedBatch(texts: string[]): Promise<number[][]> {
58
+ return Promise.all(texts.map((text) => this.embed(text)));
59
+ }
60
+ }
61
+
62
+ class ZeroEmbeddingProvider implements EmbeddingProvider {
63
+ readonly name = 'zero-test';
64
+ readonly model = 'zero-test-v1';
65
+ readonly dimensions = 4;
66
+
67
+ async embed(_text: string): Promise<number[]> {
68
+ return [0, 0, 0, 0];
69
+ }
70
+
71
+ async embedBatch(texts: string[]): Promise<number[][]> {
72
+ return Promise.all(texts.map((text) => this.embed(text)));
73
+ }
74
+ }
75
+
76
+ async function createIsolatedEngine(embedder: EmbeddingProvider): Promise<MemoryEngine> {
77
+ const isolatedDb = new SqliteDatabaseProvider(':memory:');
78
+ await isolatedDb.initialize();
79
+ return new MemoryEngine(isolatedDb, embedder, new InMemoryEventBus());
80
+ }
81
+
82
+ // ─── Tests ──────────────────────────────────────────────
83
+
84
+ describe('MemoryEngine', () => {
85
+ let engine: MemoryEngine;
86
+ let db: SqliteDatabaseProvider;
87
+ let eventBus: InMemoryEventBus;
88
+ let events: MemoryEvent[];
89
+
90
+ beforeEach(async () => {
91
+ db = new SqliteDatabaseProvider(':memory:');
92
+ await db.initialize();
93
+
94
+ eventBus = new InMemoryEventBus();
95
+ events = [];
96
+ eventBus.subscribe((event) => events.push(event));
97
+
98
+ const embedder = new MockEmbeddingProvider();
99
+ engine = new MemoryEngine(db, embedder, eventBus);
100
+ });
101
+
102
+ afterEach(async () => {
103
+ await engine.shutdown();
104
+ });
105
+
106
+ // ─── Remember ───────────────────────────────────────
107
+
108
+ describe('remember()', () => {
109
+ it('should create a memory and return it with an ID', async () => {
110
+ const memory = await engine.remember({
111
+ agentId: 'test-agent',
112
+ type: 'semantic',
113
+ content: 'The sky is blue',
114
+ importance: 0.8,
115
+ tags: ['fact', 'nature'],
116
+ });
117
+
118
+ expect(memory).toBeDefined();
119
+ expect(memory.id).toBeTruthy();
120
+ expect(memory.agentId).toBe('test-agent');
121
+ expect(memory.type).toBe('semantic');
122
+ expect(memory.content).toBe('The sky is blue');
123
+ expect(memory.importance).toBe(0.8);
124
+ expect(memory.tags).toEqual(['fact', 'nature']);
125
+ expect(memory.embedding).toBeTruthy();
126
+ expect(memory.decayScore).toBe(1.0);
127
+ });
128
+
129
+ it('should emit a memory:created event', async () => {
130
+ await engine.remember({
131
+ agentId: 'test-agent',
132
+ type: 'episodic',
133
+ content: 'User logged in at 10am',
134
+ });
135
+
136
+ expect(events).toHaveLength(1);
137
+ expect(events[0].type).toBe('memory:created');
138
+ expect(events[0].agentId).toBe('test-agent');
139
+ expect(events[0].memoryType).toBe('episodic');
140
+ });
141
+
142
+ it('should set default importance to 0.5', async () => {
143
+ const memory = await engine.remember({
144
+ agentId: 'test-agent',
145
+ type: 'semantic',
146
+ content: 'Default importance test',
147
+ });
148
+
149
+ expect(memory.importance).toBe(0.5);
150
+ });
151
+ });
152
+
153
+ // ─── Recall ─────────────────────────────────────────
154
+
155
+ describe('recall()', () => {
156
+ it('should find memories by vector similarity', async () => {
157
+ // Store some memories
158
+ await engine.remember({
159
+ agentId: 'test-agent',
160
+ type: 'semantic',
161
+ content: 'The sky is blue',
162
+ });
163
+ await engine.remember({
164
+ agentId: 'test-agent',
165
+ type: 'semantic',
166
+ content: 'Water is H2O',
167
+ });
168
+
169
+ const results = await engine.recall({
170
+ agentId: 'test-agent',
171
+ query: 'The sky is blue', // Same text = highest similarity
172
+ limit: 5,
173
+ threshold: 0.1,
174
+ useSpreadingActivation: false,
175
+ });
176
+
177
+ expect(results.length).toBeGreaterThan(0);
178
+ expect(results[0].memory.content).toBe('The sky is blue');
179
+ expect(results[0].score).toBeGreaterThan(0.5);
180
+ });
181
+
182
+ it('should respect agent namespace isolation', async () => {
183
+ await engine.remember({
184
+ agentId: 'agent-1',
185
+ type: 'semantic',
186
+ content: 'Secret of agent 1',
187
+ });
188
+ await engine.remember({
189
+ agentId: 'agent-2',
190
+ type: 'semantic',
191
+ content: 'Secret of agent 2',
192
+ });
193
+
194
+ const results = await engine.recall({
195
+ agentId: 'agent-1',
196
+ query: 'Secret',
197
+ limit: 10,
198
+ threshold: 0.0,
199
+ useSpreadingActivation: false,
200
+ });
201
+
202
+ // Only agent-1's memory should be returned
203
+ const agentIds = results.map((r) => r.memory.agentId);
204
+ expect(agentIds).not.toContain('agent-2');
205
+ });
206
+
207
+ it('should surface graph-associated memories through spreading activation', async () => {
208
+ const graphEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
209
+
210
+ try {
211
+ const seed = await graphEngine.remember({
212
+ agentId: 'test-agent',
213
+ type: 'semantic',
214
+ content: 'alpha seed memory',
215
+ });
216
+ const related = await graphEngine.remember({
217
+ agentId: 'test-agent',
218
+ type: 'procedural',
219
+ content: 'graph-only payload beta destination',
220
+ });
221
+
222
+ await graphEngine.associate({
223
+ sourceId: seed.id,
224
+ targetId: related.id,
225
+ agentId: 'test-agent',
226
+ strength: 1,
227
+ origin: 'explicit',
228
+ });
229
+
230
+ const vectorOnly = await graphEngine.recall({
231
+ agentId: 'test-agent',
232
+ query: 'alpha seed memory associated linked',
233
+ limit: 1,
234
+ threshold: 0.1,
235
+ useSpreadingActivation: false,
236
+ });
237
+ const activated = await graphEngine.recall({
238
+ agentId: 'test-agent',
239
+ query: 'alpha seed memory associated linked',
240
+ limit: 10,
241
+ threshold: 0.1,
242
+ useSpreadingActivation: true,
243
+ activationThreshold: 0.1,
244
+ blendWeight: 0.5,
245
+ });
246
+
247
+ expect(vectorOnly.some((result) => result.memory.id === related.id)).toBe(false);
248
+ expect(activated.some((result) => result.memory.id === related.id)).toBe(true);
249
+ expect(
250
+ activated.some(
251
+ (result) =>
252
+ result.memory.id === related.id &&
253
+ (result.source === 'association' || result.source === 'combined'),
254
+ ),
255
+ ).toBe(true);
256
+ } finally {
257
+ await graphEngine.shutdown();
258
+ }
259
+ });
260
+
261
+ it('should keep basic semantic recall conservative when graph mode is enabled', async () => {
262
+ const rankingEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
263
+
264
+ try {
265
+ const target = await rankingEngine.remember({
266
+ agentId: 'ranking-agent',
267
+ type: 'semantic',
268
+ content: 'The launch checklist uses the cobalt deployment window.',
269
+ });
270
+ const linkedButIrrelevant = await rankingEngine.remember({
271
+ agentId: 'ranking-agent',
272
+ type: 'semantic',
273
+ content: 'The catering checklist uses the amber seating plan.',
274
+ });
275
+ await rankingEngine.remember({
276
+ agentId: 'ranking-agent',
277
+ type: 'semantic',
278
+ content: 'The release notes mention browser support and installer paths.',
279
+ });
280
+
281
+ await rankingEngine.associate({
282
+ sourceId: target.id,
283
+ targetId: linkedButIrrelevant.id,
284
+ agentId: 'ranking-agent',
285
+ strength: 1,
286
+ origin: 'explicit',
287
+ });
288
+
289
+ const results = await rankingEngine.recall({
290
+ agentId: 'ranking-agent',
291
+ query: 'What deployment window does the launch checklist use?',
292
+ limit: 2,
293
+ threshold: -1,
294
+ useSpreadingActivation: true,
295
+ maxHops: 2,
296
+ activationThreshold: 0.05,
297
+ });
298
+
299
+ expect(results[0].memory.id).toBe(target.id);
300
+ expect(results.every((result) => result.source === 'vector')).toBe(true);
301
+ } finally {
302
+ await rankingEngine.shutdown();
303
+ }
304
+ });
305
+
306
+ it('should keep explicit multi-hop answer evidence in the top results', async () => {
307
+ const rankingEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
308
+
309
+ try {
310
+ const anchor = await rankingEngine.remember({
311
+ agentId: 'ranking-agent',
312
+ type: 'episodic',
313
+ content:
314
+ 'Devon said the approval ritual is internally called Silver File; outside notes describe it as the release approval meeting.',
315
+ metadata: { role: 'weak-overlap-anchor' },
316
+ });
317
+ const bridge = await rankingEngine.remember({
318
+ agentId: 'ranking-agent',
319
+ type: 'semantic',
320
+ content:
321
+ 'Silver File approval artifact workflow is owned by the ethics submission team, not by the team whose name resembles the project codename.',
322
+ metadata: { role: 'weak-overlap-bridge' },
323
+ });
324
+ const answer = await rankingEngine.remember({
325
+ agentId: 'ranking-agent',
326
+ type: 'procedural',
327
+ content: 'Before ethics submission team signs off, they require the redacted consent ledger.',
328
+ metadata: { role: 'weak-overlap-answer' },
329
+ });
330
+
331
+ await rankingEngine.remember({
332
+ agentId: 'ranking-agent',
333
+ type: 'semantic',
334
+ content:
335
+ 'Codename Blue Lantern refers to the rural asthma cohort, which is governed through the inhaler-adherence study.',
336
+ metadata: { role: 'bridge' },
337
+ });
338
+ await rankingEngine.remember({
339
+ agentId: 'ranking-agent',
340
+ type: 'semantic',
341
+ content: "For Devon's rural asthma cohort, the accountable owner is Priya Shah.",
342
+ metadata: { role: 'entity-answer' },
343
+ });
344
+ await rankingEngine.remember({
345
+ agentId: 'ranking-agent',
346
+ type: 'semantic',
347
+ content: 'The inhaler-adherence study depends on the FHIR observation export.',
348
+ metadata: { role: 'answer' },
349
+ });
350
+
351
+ await rankingEngine.associate({
352
+ sourceId: anchor.id,
353
+ targetId: bridge.id,
354
+ agentId: 'ranking-agent',
355
+ strength: 0.9,
356
+ origin: 'explicit',
357
+ });
358
+ await rankingEngine.associate({
359
+ sourceId: bridge.id,
360
+ targetId: answer.id,
361
+ agentId: 'ranking-agent',
362
+ strength: 0.9,
363
+ origin: 'explicit',
364
+ });
365
+
366
+ const results = await rankingEngine.recall({
367
+ agentId: 'ranking-agent',
368
+ query:
369
+ "Which artifact is needed before the release approval meeting can be signed off for Devon's work?",
370
+ limit: 5,
371
+ threshold: -1,
372
+ useSpreadingActivation: true,
373
+ maxHops: 2,
374
+ activationThreshold: 0.05,
375
+ });
376
+
377
+ const topIds = results.map((result) => result.memory.id);
378
+ expect(topIds).toContain(anchor.id);
379
+ expect(topIds).toContain(bridge.id);
380
+ expect(topIds).toContain(answer.id);
381
+ } finally {
382
+ await rankingEngine.shutdown();
383
+ }
384
+ });
385
+
386
+ it('should rank final resolved memory above stale and interim conflict states', async () => {
387
+ const rankingEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
388
+
389
+ try {
390
+ const stale = await rankingEngine.remember({
391
+ agentId: 'ranking-agent',
392
+ type: 'semantic',
393
+ content: 'Initial state for ninth-grade climate unit: poster board exhibition.',
394
+ metadata: { role: 'stale', benchTimestamp: '2026-05-01T09:00:00Z' },
395
+ });
396
+ const interim = await rankingEngine.remember({
397
+ agentId: 'ranking-agent',
398
+ type: 'episodic',
399
+ content: 'Interim update for ninth-grade climate unit: slide deck presentation.',
400
+ metadata: { role: 'interim', benchTimestamp: '2026-05-02T09:00:00Z' },
401
+ });
402
+ const final = await rankingEngine.remember({
403
+ agentId: 'ranking-agent',
404
+ type: 'semantic',
405
+ content:
406
+ 'Final resolved state for ninth-grade climate unit supersedes earlier plans: final project as a data story notebook.',
407
+ metadata: { role: 'final', benchTimestamp: '2026-05-03T09:00:00Z' },
408
+ });
409
+
410
+ await rankingEngine.associate({
411
+ sourceId: stale.id,
412
+ targetId: interim.id,
413
+ agentId: 'ranking-agent',
414
+ strength: 0.9,
415
+ origin: 'explicit',
416
+ });
417
+ await rankingEngine.associate({
418
+ sourceId: interim.id,
419
+ targetId: final.id,
420
+ agentId: 'ranking-agent',
421
+ strength: 0.9,
422
+ origin: 'explicit',
423
+ });
424
+
425
+ const results = await rankingEngine.recall({
426
+ agentId: 'ranking-agent',
427
+ query: 'What is the current resolved state for ninth-grade climate unit?',
428
+ limit: 3,
429
+ threshold: -1,
430
+ useSpreadingActivation: true,
431
+ maxHops: 2,
432
+ activationThreshold: 0.05,
433
+ });
434
+
435
+ expect(results[0].memory.id).toBe(final.id);
436
+ expect(results.findIndex((result) => result.memory.id === final.id)).toBeLessThan(
437
+ results.findIndex((result) => result.memory.id === interim.id),
438
+ );
439
+ const staleIndex = results.findIndex((result) => result.memory.id === stale.id);
440
+ if (staleIndex !== -1) {
441
+ expect(results.findIndex((result) => result.memory.id === final.id)).toBeLessThan(staleIndex);
442
+ }
443
+ } finally {
444
+ await rankingEngine.shutdown();
445
+ }
446
+ });
447
+
448
+ it('should mark superseded state memories stale at write time', async () => {
449
+ const invalidationDb = new SqliteDatabaseProvider(':memory:');
450
+ await invalidationDb.initialize();
451
+ const invalidationEngine = new MemoryEngine(
452
+ invalidationDb,
453
+ new TokenEmbeddingProvider(),
454
+ new InMemoryEventBus(),
455
+ );
456
+
457
+ try {
458
+ const initial = await invalidationEngine.remember({
459
+ agentId: 'invalidation-agent',
460
+ type: 'semantic',
461
+ content:
462
+ "FormFlow's initial pricing was $29/month per workspace, with no annual-plan discount.",
463
+ tags: ['formflow', 'pricing'],
464
+ metadata: { benchTimestamp: '2026-01-01T09:00:00Z' },
465
+ });
466
+ const current = await invalidationEngine.remember({
467
+ agentId: 'invalidation-agent',
468
+ type: 'semantic',
469
+ content:
470
+ 'In February 2026 Maya introduced an annual-plan discount: 20% off, bringing the annual equivalent to roughly $31/month.',
471
+ tags: ['formflow', 'pricing'],
472
+ metadata: { benchTimestamp: '2026-02-01T09:00:00Z' },
473
+ });
474
+
475
+ const stale = await invalidationDb.getMemoryById(initial.id, 'invalidation-agent');
476
+ expect(stale?.metadata?.['role']).toBe('stale');
477
+ expect(stale?.metadata?.['supersededBy']).toBe(current.id);
478
+ expect(stale?.decayScore).toBeLessThan(0.1);
479
+
480
+ const results = await invalidationEngine.recall({
481
+ agentId: 'invalidation-agent',
482
+ query: "What is FormFlow's current monthly price for a workspace?",
483
+ limit: 2,
484
+ threshold: -1,
485
+ useSpreadingActivation: true,
486
+ });
487
+
488
+ expect(results[0].memory.id).toBe(current.id);
489
+ expect(results.some((result) => result.memory.id === initial.id)).toBe(false);
490
+
491
+ const historicalResults = await invalidationEngine.recall({
492
+ agentId: 'invalidation-agent',
493
+ query: 'What was the original FormFlow monthly price before the change?',
494
+ limit: 2,
495
+ threshold: -1,
496
+ useSpreadingActivation: true,
497
+ });
498
+
499
+ expect(historicalResults.some((result) => result.memory.id === initial.id)).toBe(true);
500
+ } finally {
501
+ await invalidationEngine.shutdown();
502
+ }
503
+ });
504
+
505
+ it('should abstain when matching negative evidence is stronger than positive candidates', async () => {
506
+ const rankingEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
507
+
508
+ try {
509
+ await rankingEngine.remember({
510
+ agentId: 'ranking-agent',
511
+ type: 'warning',
512
+ content:
513
+ 'Tempting gap: Zeta Project release approval artifact is not stated in the record.',
514
+ metadata: { role: 'negative-evidence' },
515
+ });
516
+ await rankingEngine.remember({
517
+ agentId: 'ranking-agent',
518
+ type: 'procedural',
519
+ content: 'Before Beta Project release approval, the ethics team requires consent ledger.',
520
+ metadata: { role: 'similar-entity-answer' },
521
+ });
522
+ await rankingEngine.remember({
523
+ agentId: 'ranking-agent',
524
+ type: 'semantic',
525
+ content: 'Zeta Project has a launch owner and a budget note, but no artifact record.',
526
+ metadata: { role: 'partial-positive' },
527
+ });
528
+
529
+ const results = await rankingEngine.recall({
530
+ agentId: 'ranking-agent',
531
+ query: 'What artifact is needed for Zeta Project release approval?',
532
+ limit: 5,
533
+ threshold: -1,
534
+ useSpreadingActivation: true,
535
+ activationThreshold: 0.05,
536
+ });
537
+
538
+ expect(results).toHaveLength(0);
539
+ } finally {
540
+ await rankingEngine.shutdown();
541
+ }
542
+ });
543
+
544
+ it('should return explicit absence evidence for unknown future-state queries', async () => {
545
+ const rankingEngine = await createIsolatedEngine(new TokenEmbeddingProvider());
546
+
547
+ try {
548
+ const absenceEvidence = await rankingEngine.remember({
549
+ agentId: 'ranking-agent',
550
+ type: 'semantic',
551
+ content:
552
+ 'Priya has not announced a family-plan tier or any pricing changes; the product roadmap is focused on receipt scanning next.',
553
+ metadata: { role: 'absence-evidence' },
554
+ });
555
+ await rankingEngine.remember({
556
+ agentId: 'ranking-agent',
557
+ type: 'semantic',
558
+ content: 'Priya announced Android support and receipt scanning improvements.',
559
+ metadata: { role: 'nearby-positive' },
560
+ });
561
+
562
+ const results = await rankingEngine.recall({
563
+ agentId: 'ranking-agent',
564
+ query: 'Has Priya announced a family-plan pricing tier?',
565
+ limit: 5,
566
+ threshold: -1,
567
+ useSpreadingActivation: true,
568
+ activationThreshold: 0.05,
569
+ });
570
+
571
+ expect(results.length).toBeGreaterThan(0);
572
+ expect(results[0].memory.id).toBe(absenceEvidence.id);
573
+ } finally {
574
+ await rankingEngine.shutdown();
575
+ }
576
+ });
577
+
578
+ it('should use lexical evidence when vector similarity misses current-state title updates', async () => {
579
+ const rankingEngine = await createIsolatedEngine(new ZeroEmbeddingProvider());
580
+
581
+ try {
582
+ const original = await rankingEngine.remember({
583
+ agentId: 'ranking-agent',
584
+ type: 'semantic',
585
+ content:
586
+ "Hana Mori is writing her debut literary fiction novel, originally titled 'Saltwater Reckoning'.",
587
+ tags: ['hana', 'novel'],
588
+ metadata: { benchTimestamp: '2025-09-01T09:00:00Z' },
589
+ });
590
+ const current = await rankingEngine.remember({
591
+ agentId: 'ranking-agent',
592
+ type: 'episodic',
593
+ content:
594
+ "Hana changed the novel's title to 'The Weight of Salt' in January 2026, feeling the original title was too literal.",
595
+ tags: ['hana', 'title'],
596
+ metadata: { benchTimestamp: '2026-01-20T10:00:00Z' },
597
+ });
598
+
599
+ const results = await rankingEngine.recall({
600
+ agentId: 'ranking-agent',
601
+ query: "What is the current title of Hana's novel?",
602
+ limit: 3,
603
+ threshold: 0.5,
604
+ useSpreadingActivation: true,
605
+ activationThreshold: 0.05,
606
+ });
607
+
608
+ expect(results[0].memory.id).toBe(current.id);
609
+ expect(results.findIndex((result) => result.memory.id === current.id)).toBeLessThan(
610
+ results.findIndex((result) => result.memory.id === original.id),
611
+ );
612
+ expect(results[0].rankingTrace?.some((trace) => trace.startsWith('lexical_seed'))).toBe(true);
613
+ } finally {
614
+ await rankingEngine.shutdown();
615
+ }
616
+ });
617
+
618
+ it('should penalize near-entity lexical distractors', async () => {
619
+ const rankingEngine = await createIsolatedEngine(new ZeroEmbeddingProvider());
620
+
621
+ try {
622
+ const target = await rankingEngine.remember({
623
+ agentId: 'ranking-agent',
624
+ type: 'semantic',
625
+ content: 'Marco currently uses Vanguard as his primary brokerage.',
626
+ tags: ['marco', 'brokerage'],
627
+ });
628
+ const distractor = await rankingEngine.remember({
629
+ agentId: 'ranking-agent',
630
+ type: 'semantic',
631
+ content: 'Marcus currently uses Fidelity as his primary brokerage.',
632
+ tags: ['marcus', 'brokerage'],
633
+ });
634
+
635
+ const results = await rankingEngine.recall({
636
+ agentId: 'ranking-agent',
637
+ query: 'Which brokerage does Marco currently use?',
638
+ limit: 3,
639
+ threshold: 0.5,
640
+ useSpreadingActivation: true,
641
+ });
642
+
643
+ expect(results[0].memory.id).toBe(target.id);
644
+ const distractorIndex = results.findIndex((result) => result.memory.id === distractor.id);
645
+ if (distractorIndex !== -1) {
646
+ expect(results.findIndex((result) => result.memory.id === target.id)).toBeLessThan(
647
+ distractorIndex,
648
+ );
649
+ }
650
+ } finally {
651
+ await rankingEngine.shutdown();
652
+ }
653
+ });
654
+
655
+ it('should prefer updated values over old exact values in still-current questions', async () => {
656
+ const rankingEngine = await createIsolatedEngine(new ZeroEmbeddingProvider());
657
+
658
+ try {
659
+ const oldValue = await rankingEngine.remember({
660
+ agentId: 'ranking-agent',
661
+ type: 'semantic',
662
+ content: "Amara's 5K PB was 28:15 in March 2026.",
663
+ tags: ['amara', '5k'],
664
+ metadata: { benchTimestamp: '2026-03-01T09:00:00Z' },
665
+ });
666
+ const currentValue = await rankingEngine.remember({
667
+ agentId: 'ranking-agent',
668
+ type: 'episodic',
669
+ content: "Amara lowered her 5K PB to 26:40 in May 2026 after changing her training plan.",
670
+ tags: ['amara', '5k'],
671
+ metadata: { benchTimestamp: '2026-05-01T09:00:00Z' },
672
+ });
673
+
674
+ const results = await rankingEngine.recall({
675
+ agentId: 'ranking-agent',
676
+ query: "Is Amara's 5K PB still 28:15?",
677
+ limit: 3,
678
+ threshold: 0.5,
679
+ useSpreadingActivation: true,
680
+ });
681
+
682
+ expect(results[0].memory.id).toBe(currentValue.id);
683
+ expect(results.findIndex((result) => result.memory.id === currentValue.id)).toBeLessThan(
684
+ results.findIndex((result) => result.memory.id === oldValue.id),
685
+ );
686
+ } finally {
687
+ await rankingEngine.shutdown();
688
+ }
689
+ });
690
+ });
691
+
692
+ // ─── Forget ─────────────────────────────────────────
693
+
694
+ describe('forget()', () => {
695
+ it('should delete a memory by ID', async () => {
696
+ const memory = await engine.remember({
697
+ agentId: 'test-agent',
698
+ type: 'episodic',
699
+ content: 'Temporary memory',
700
+ });
701
+
702
+ const deleted = await engine.forget(memory.id, 'test-agent');
703
+ expect(deleted).toBe(true);
704
+
705
+ // Verify it's gone
706
+ const results = await engine.recall({
707
+ agentId: 'test-agent',
708
+ query: 'Temporary memory',
709
+ limit: 10,
710
+ threshold: 0.0,
711
+ useSpreadingActivation: false,
712
+ });
713
+
714
+ const found = results.find((r) => r.memory.id === memory.id);
715
+ expect(found).toBeUndefined();
716
+ });
717
+
718
+ it('should return false for non-existent memory', async () => {
719
+ const deleted = await engine.forget('non-existent-id', 'test-agent');
720
+ expect(deleted).toBe(false);
721
+ });
722
+
723
+ it('should emit memory:deleted event', async () => {
724
+ const memory = await engine.remember({
725
+ agentId: 'test-agent',
726
+ type: 'episodic',
727
+ content: 'To be deleted',
728
+ });
729
+
730
+ events.length = 0; // Clear previous events
731
+ await engine.forget(memory.id, 'test-agent');
732
+
733
+ expect(events.some((e) => e.type === 'memory:deleted')).toBe(true);
734
+ });
735
+ });
736
+
737
+ // ─── Associate ──────────────────────────────────────
738
+
739
+ describe('associate()', () => {
740
+ it('should create an explicit association between memories', async () => {
741
+ const m1 = await engine.remember({
742
+ agentId: 'test-agent',
743
+ type: 'semantic',
744
+ content: 'TypeScript is a language',
745
+ });
746
+ const m2 = await engine.remember({
747
+ agentId: 'test-agent',
748
+ type: 'semantic',
749
+ content: 'JavaScript is a language',
750
+ });
751
+
752
+ await engine.associate({
753
+ sourceId: m1.id,
754
+ targetId: m2.id,
755
+ strength: 0.9,
756
+ origin: 'explicit',
757
+ });
758
+
759
+ // Verify association exists
760
+ const associations = await db.getAssociations(m1.id);
761
+ expect(associations.length).toBeGreaterThan(0);
762
+
763
+ const explicit = associations.find(
764
+ (a) =>
765
+ (a.sourceId === m1.id && a.targetId === m2.id) ||
766
+ (a.sourceId === m2.id && a.targetId === m1.id),
767
+ );
768
+ expect(explicit).toBeDefined();
769
+ });
770
+
771
+ it('should reject associations across agent namespaces', async () => {
772
+ const source = await engine.remember({
773
+ agentId: 'agent-a',
774
+ type: 'semantic',
775
+ content: 'Agent A memory',
776
+ });
777
+ const target = await engine.remember({
778
+ agentId: 'agent-b',
779
+ type: 'semantic',
780
+ content: 'Agent B memory',
781
+ });
782
+
783
+ await expect(
784
+ engine.associate({
785
+ sourceId: source.id,
786
+ targetId: target.id,
787
+ agentId: 'agent-a',
788
+ strength: 0.9,
789
+ origin: 'explicit',
790
+ }),
791
+ ).rejects.toThrow(/same agent namespace/);
792
+ });
793
+
794
+ it('should decay non-explicit association strength', async () => {
795
+ const source = await engine.remember({
796
+ agentId: 'test-agent',
797
+ type: 'semantic',
798
+ content: 'Source memory',
799
+ });
800
+ const target = await engine.remember({
801
+ agentId: 'test-agent',
802
+ type: 'semantic',
803
+ content: 'Target memory',
804
+ });
805
+
806
+ await db.createAssociation({
807
+ sourceId: source.id,
808
+ targetId: target.id,
809
+ strength: 0.8,
810
+ origin: 'similarity',
811
+ relationType: 'relates_to',
812
+ });
813
+
814
+ const affected = await db.applyAssociationDecay(0.25, 0.1);
815
+ const [association] = await db.getAssociations(source.id);
816
+
817
+ expect(affected).toBeGreaterThan(0);
818
+ expect(association.strength).toBeCloseTo(0.6);
819
+ });
820
+ });
821
+
822
+ // ─── Export / Import ────────────────────────────────
823
+
824
+ describe('Memory Passport', () => {
825
+ it('should export all memories and associations', async () => {
826
+ await engine.remember({
827
+ agentId: 'hermes',
828
+ type: 'semantic',
829
+ content: 'User prefers dark mode',
830
+ importance: 0.9,
831
+ });
832
+ await engine.remember({
833
+ agentId: 'hermes',
834
+ type: 'episodic',
835
+ content: 'User asked about pricing',
836
+ });
837
+
838
+ const passport = await engine.exportPassport('hermes');
839
+
840
+ expect(passport.version).toBe('1.0.0');
841
+ expect(passport.sourceAgent).toBe('hermes');
842
+ expect(passport.memories).toHaveLength(2);
843
+ expect(passport.metadata.totalMemories).toBe(2);
844
+
845
+ // Embeddings should be stripped (they'll be regenerated on import)
846
+ for (const m of passport.memories) {
847
+ expect(m.embedding).toBeNull();
848
+ }
849
+ });
850
+
851
+ it('should import a passport into a different agent', async () => {
852
+ // Create memories for agent A
853
+ await engine.remember({
854
+ agentId: 'agent-a',
855
+ type: 'semantic',
856
+ content: 'Shared knowledge',
857
+ });
858
+
859
+ const passport = await engine.exportPassport('agent-a');
860
+
861
+ // Import into agent B
862
+ const result = await engine.importPassport(passport, 'agent-b', 'skip');
863
+
864
+ expect(result.imported).toBeGreaterThan(0);
865
+ expect(result.errors).toBe(0);
866
+
867
+ // Verify agent-b has the memory
868
+ const results = await engine.recall({
869
+ agentId: 'agent-b',
870
+ query: 'Shared knowledge',
871
+ limit: 5,
872
+ threshold: 0.0,
873
+ useSpreadingActivation: false,
874
+ });
875
+
876
+ expect(results.length).toBeGreaterThan(0);
877
+ });
878
+ });
879
+
880
+ // ─── Entity-Scoped Lexical Seeding ──────────────────
881
+
882
+ describe('recall() entity-scoped lexical seeding', () => {
883
+ it('should not include wrong-entity memories in lexical seed candidates', async () => {
884
+ const entityEngine = await createIsolatedEngine(new ZeroEmbeddingProvider());
885
+
886
+ try {
887
+ // Target entity A
888
+ const targetA = await entityEngine.remember({
889
+ agentId: 'entity-agent',
890
+ type: 'semantic',
891
+ content: 'Alice works at Acme Corp as a software engineer.',
892
+ tags: ['alice', 'career'],
893
+ });
894
+ // Different entity B with many shared tokens
895
+ await entityEngine.remember({
896
+ agentId: 'entity-agent',
897
+ type: 'semantic',
898
+ content: 'Bob works at Acme Corp as a software architect.',
899
+ tags: ['bob', 'career'],
900
+ });
901
+
902
+ const results = await entityEngine.recall({
903
+ agentId: 'entity-agent',
904
+ query: 'What does Alice do at Acme Corp?',
905
+ limit: 5,
906
+ threshold: 0.5,
907
+ useSpreadingActivation: true,
908
+ activationThreshold: 0.05,
909
+ });
910
+
911
+ // Alice memory should be present, Bob memory should not be ranked above it
912
+ if (results.length > 0) {
913
+ expect(results[0].memory.id).toBe(targetA.id);
914
+ }
915
+ } finally {
916
+ await entityEngine.shutdown();
917
+ }
918
+ });
919
+ });
920
+ describe('Phase 8: Typed Edges', () => {
921
+ it('stores and retrieves a SUPERSEDES typed edge via associate()', async () => {
922
+ const engine = await createIsolatedEngine(new TokenEmbeddingProvider());
923
+ try {
924
+ const old = await engine.remember({
925
+ agentId: 'agent-typed',
926
+ type: 'semantic',
927
+ content: 'Initial project budget is $50k',
928
+ importance: 0.5,
929
+ tags: ['budget'],
930
+ });
931
+
932
+ const fresh = await engine.remember({
933
+ agentId: 'agent-typed',
934
+ type: 'semantic',
935
+ content: 'Current project budget is now raised to $80k',
936
+ importance: 0.8,
937
+ tags: ['budget'],
938
+ });
939
+
940
+ // Manually create the SUPERSEDES typed edge
941
+ await engine.associate({
942
+ sourceId: fresh.id,
943
+ targetId: old.id,
944
+ agentId: 'agent-typed',
945
+ strength: 1.0,
946
+ relationType: 'supersedes',
947
+ });
948
+
949
+ const db = (engine as unknown as { db: { getAssociations: (id: string) => Promise<{ sourceId: string; targetId: string; relationType: string }[]> } }).db;
950
+ const assocs = await db.getAssociations(fresh.id);
951
+ const supersedesEdge = assocs.find(
952
+ (a) => a.relationType === 'supersedes' && a.targetId === old.id,
953
+ );
954
+
955
+ // The edge must exist with the correct relationType
956
+ expect(supersedesEdge).toBeDefined();
957
+ expect(supersedesEdge?.relationType).toBe('supersedes');
958
+ } finally {
959
+ await engine.shutdown();
960
+ }
961
+ });
962
+
963
+ it('spreading activation does NOT traverse SUPERSEDES edges for normal queries', async () => {
964
+ const engine = await createIsolatedEngine(new TokenEmbeddingProvider());
965
+ try {
966
+ // Create a stale memory that would be "reached" via a supersedes edge
967
+ const staleMemory = await engine.remember({
968
+ agentId: 'agent-traversal',
969
+ type: 'semantic',
970
+ content: 'Project budget was initially $50,000',
971
+ importance: 0.5,
972
+ tags: ['budget'],
973
+ metadata: { role: 'stale', supersededBy: 'placeholder' },
974
+ });
975
+
976
+ // Create the current memory
977
+ const currentMemory = await engine.remember({
978
+ agentId: 'agent-traversal',
979
+ type: 'semantic',
980
+ content: 'Project budget is now $80,000',
981
+ importance: 0.9,
982
+ tags: ['budget'],
983
+ });
984
+
985
+ // Manually create a SUPERSEDES typed edge from current to stale
986
+ await engine.associate({
987
+ sourceId: currentMemory.id,
988
+ targetId: staleMemory.id,
989
+ agentId: 'agent-traversal',
990
+ strength: 1.0,
991
+ relationType: 'supersedes',
992
+ });
993
+
994
+ // Normal query should not bubble up the stale memory via graph traversal
995
+ const results = await engine.recall({
996
+ agentId: 'agent-traversal',
997
+ query: 'What is the project budget',
998
+ useSpreadingActivation: true,
999
+ activationThreshold: 0.01,
1000
+ maxHops: 2,
1001
+ });
1002
+
1003
+ // If the stale memory appears, it should be ranked below the current one
1004
+ const staleIndex = results.findIndex((r) => r.memory.id === staleMemory.id);
1005
+ const currentIndex = results.findIndex((r) => r.memory.id === currentMemory.id);
1006
+
1007
+ if (staleIndex !== -1 && currentIndex !== -1) {
1008
+ // Current memory must rank above stale when both appear
1009
+ expect(currentIndex).toBeLessThan(staleIndex);
1010
+ }
1011
+ } finally {
1012
+ await engine.shutdown();
1013
+ }
1014
+ });
1015
+ });
1016
+ });
1017
+