@totalreclaw/totalreclaw 1.0.5 → 1.2.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,595 @@
1
+ /**
2
+ * Unit tests for import adapters (Task 9).
3
+ *
4
+ * Run with: npx tsx import-adapters/import-adapters.test.ts
5
+ *
6
+ * Uses TAP-style output (no test framework dependency).
7
+ */
8
+
9
+ import { Mem0Adapter } from './mem0-adapter.js';
10
+ import { MCPMemoryAdapter } from './mcp-memory-adapter.js';
11
+ import { BaseImportAdapter } from './base-adapter.js';
12
+ import { getAdapter } from './index.js';
13
+ import type {
14
+ NormalizedFact,
15
+ AdapterParseResult,
16
+ ImportSource,
17
+ ProgressCallback,
18
+ } from './types.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // TAP Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ let testNum = 0;
27
+
28
+ function assert(condition: boolean, message: string): void {
29
+ testNum++;
30
+ if (condition) {
31
+ passed++;
32
+ console.log(`ok ${testNum} - ${message}`);
33
+ } else {
34
+ failed++;
35
+ console.log(`not ok ${testNum} - ${message}`);
36
+ }
37
+ }
38
+
39
+ function assertThrows(fn: () => void, message: string): void {
40
+ try {
41
+ fn();
42
+ failed++;
43
+ testNum++;
44
+ console.log(`not ok ${testNum} - ${message} (did not throw)`);
45
+ } catch {
46
+ passed++;
47
+ testNum++;
48
+ console.log(`ok ${testNum} - ${message}`);
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Concrete subclass to test protected BaseImportAdapter.validateFact()
54
+ // ---------------------------------------------------------------------------
55
+
56
+ class TestAdapter extends BaseImportAdapter {
57
+ readonly source: ImportSource = 'mem0';
58
+ readonly displayName = 'Test Adapter';
59
+
60
+ async parse(): Promise<AdapterParseResult> {
61
+ return { facts: [], warnings: [], errors: [] };
62
+ }
63
+
64
+ // Expose protected methods for testing
65
+ public testValidateFact(fact: Partial<NormalizedFact>): NormalizedFact | null {
66
+ return this.validateFact(fact);
67
+ }
68
+
69
+ public testValidateFacts(
70
+ rawFacts: Partial<NormalizedFact>[],
71
+ ): { facts: NormalizedFact[]; invalidCount: number } {
72
+ return this.validateFacts(rawFacts);
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Tests
78
+ // ---------------------------------------------------------------------------
79
+
80
+ async function runTests(): Promise<void> {
81
+ // =========================================================================
82
+ // Mem0Adapter
83
+ // =========================================================================
84
+
85
+ console.log('# Mem0Adapter');
86
+
87
+ // --- parses API response format ---
88
+ {
89
+ const content = JSON.stringify({
90
+ results: [
91
+ { id: 'mem-1', memory: 'User prefers dark mode', categories: ['preference'] },
92
+ { id: 'mem-2', memory: 'User works at Acme Corp', categories: ['fact'] },
93
+ ],
94
+ });
95
+
96
+ const adapter = new Mem0Adapter();
97
+ const result = await adapter.parse({ content });
98
+
99
+ assert(result.facts.length === 2, 'Mem0: API response format yields 2 facts');
100
+ assert(result.facts[0].text === 'User prefers dark mode', 'Mem0: first fact text correct');
101
+ assert(result.facts[0].type === 'preference', 'Mem0: preference category mapped');
102
+ assert(result.facts[0].source === 'mem0', 'Mem0: source is mem0');
103
+ assert(result.facts[1].type === 'fact', 'Mem0: fact category mapped');
104
+ assert(result.facts[1].sourceId === 'mem-2', 'Mem0: sourceId preserved');
105
+ }
106
+
107
+ // --- parses export file format ---
108
+ {
109
+ const content = JSON.stringify({
110
+ export_date: '2026-03-10',
111
+ memories: [
112
+ { id: 'mem-1', memory: 'User likes TypeScript' },
113
+ ],
114
+ });
115
+
116
+ const adapter = new Mem0Adapter();
117
+ const result = await adapter.parse({ content });
118
+
119
+ assert(result.facts.length === 1, 'Mem0: export file format yields 1 fact');
120
+ assert(result.facts[0].text === 'User likes TypeScript', 'Mem0: export fact text correct');
121
+ }
122
+
123
+ // --- parses bare array format ---
124
+ {
125
+ const content = JSON.stringify([
126
+ { id: 'mem-1', memory: 'User prefers Python' },
127
+ { id: 'mem-2', memory: 'User dislikes Java' },
128
+ ]);
129
+
130
+ const adapter = new Mem0Adapter();
131
+ const result = await adapter.parse({ content });
132
+
133
+ assert(result.facts.length === 2, 'Mem0: bare array yields 2 facts');
134
+ assert(result.facts[0].text === 'User prefers Python', 'Mem0: bare array first fact correct');
135
+ }
136
+
137
+ // --- skips empty/short memories ---
138
+ {
139
+ const content = JSON.stringify({
140
+ results: [
141
+ { id: 'mem-1', memory: '' },
142
+ { id: 'mem-2', memory: 'Valid fact here' },
143
+ { id: 'mem-3', memory: 'ab' }, // too short (< 3 chars)
144
+ ],
145
+ });
146
+
147
+ const adapter = new Mem0Adapter();
148
+ const result = await adapter.parse({ content });
149
+
150
+ assert(result.facts.length === 1, 'Mem0: skips empty/short memories, keeps 1 valid');
151
+ assert(result.facts[0].text === 'Valid fact here', 'Mem0: only valid fact kept');
152
+ const hasWarning = result.warnings.some((w) => w.includes('2 memories had invalid'));
153
+ assert(hasWarning, 'Mem0: warning about 2 invalid memories');
154
+ }
155
+
156
+ // --- returns errors on invalid JSON ---
157
+ {
158
+ const adapter = new Mem0Adapter();
159
+ const result = await adapter.parse({ content: 'not json {{{' });
160
+
161
+ assert(result.facts.length === 0, 'Mem0: invalid JSON yields 0 facts');
162
+ assert(result.errors.length > 0, 'Mem0: invalid JSON produces error');
163
+ assert(
164
+ result.errors[0].includes('Failed to parse Mem0 JSON'),
165
+ 'Mem0: error message mentions JSON parse failure',
166
+ );
167
+ }
168
+
169
+ // --- returns error when no content or api_key ---
170
+ {
171
+ const adapter = new Mem0Adapter();
172
+ const result = await adapter.parse({});
173
+
174
+ assert(result.facts.length === 0, 'Mem0: no input yields 0 facts');
175
+ assert(result.errors.length > 0, 'Mem0: no input produces error');
176
+ assert(
177
+ result.errors[0].includes('requires either content'),
178
+ 'Mem0: error message mentions required input',
179
+ );
180
+ }
181
+
182
+ // --- category mapping ---
183
+ {
184
+ const content = JSON.stringify({
185
+ results: [
186
+ { id: '1', memory: 'User likes hiking', categories: ['like'] },
187
+ { id: '2', memory: 'User dislikes rain', categories: ['dislike'] },
188
+ { id: '3', memory: 'Graduated in 2020', categories: ['biographical'] },
189
+ { id: '4', memory: 'Wants to learn Rust', categories: ['objective'] },
190
+ { id: '5', memory: 'Visited Paris in 2023', categories: ['event'] },
191
+ { id: '6', memory: 'Chose React over Vue', categories: ['decision'] },
192
+ { id: '7', memory: 'Some unknown category', categories: ['zzz_unknown'] },
193
+ ],
194
+ });
195
+
196
+ const adapter = new Mem0Adapter();
197
+ const result = await adapter.parse({ content });
198
+
199
+ assert(result.facts[0].type === 'preference', 'Mem0: "like" -> preference');
200
+ assert(result.facts[1].type === 'preference', 'Mem0: "dislike" -> preference');
201
+ assert(result.facts[2].type === 'fact', 'Mem0: "biographical" -> fact');
202
+ assert(result.facts[3].type === 'goal', 'Mem0: "objective" -> goal');
203
+ assert(result.facts[4].type === 'episodic', 'Mem0: "event" -> episodic');
204
+ assert(result.facts[5].type === 'decision', 'Mem0: "decision" -> decision');
205
+ assert(result.facts[6].type === 'fact', 'Mem0: unknown category defaults to fact');
206
+ }
207
+
208
+ // --- importance defaults to 6 ---
209
+ {
210
+ const content = JSON.stringify({
211
+ results: [
212
+ { id: 'mem-1', memory: 'User prefers dark mode' },
213
+ ],
214
+ });
215
+
216
+ const adapter = new Mem0Adapter();
217
+ const result = await adapter.parse({ content });
218
+
219
+ assert(result.facts[0].importance === 6, 'Mem0: default importance is 6');
220
+ }
221
+
222
+ // --- handles unrecognized format ---
223
+ {
224
+ const content = JSON.stringify({ some_key: 'some_value' });
225
+
226
+ const adapter = new Mem0Adapter();
227
+ const result = await adapter.parse({ content });
228
+
229
+ assert(result.facts.length === 0, 'Mem0: unrecognized format yields 0 facts');
230
+ assert(
231
+ result.errors.some((e) => e.includes('Unrecognized Mem0 format')),
232
+ 'Mem0: unrecognized format error message',
233
+ );
234
+ }
235
+
236
+ // =========================================================================
237
+ // MCPMemoryAdapter
238
+ // =========================================================================
239
+
240
+ console.log('# MCPMemoryAdapter');
241
+
242
+ // --- parses entities with observations ---
243
+ {
244
+ const content = [
245
+ JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Works at Acme Corp', 'Prefers TypeScript'] }),
246
+ JSON.stringify({ type: 'entity', name: 'Project Alpha', entityType: 'project', observations: ['Uses React'] }),
247
+ ].join('\n');
248
+
249
+ const adapter = new MCPMemoryAdapter();
250
+ const result = await adapter.parse({ content });
251
+
252
+ assert(result.facts.length === 3, 'MCP: 2 entities with 3 total observations -> 3 facts');
253
+ // "Works at Acme Corp" starts with uppercase verb -> prefixed: "John: Works at Acme Corp"
254
+ // Actually the adapter checks if it starts lowercase (verb) or uppercase (standalone sentence)
255
+ // "Works" starts uppercase -> "John: Works at Acme Corp"
256
+ assert(result.facts[0].text.includes('John'), 'MCP: first fact includes entity name');
257
+ assert(result.facts[0].text.includes('Acme Corp'), 'MCP: first fact includes observation');
258
+ assert(result.facts[1].text.includes('TypeScript'), 'MCP: second fact includes TypeScript');
259
+ assert(result.facts[0].source === 'mcp-memory', 'MCP: source is mcp-memory');
260
+ }
261
+
262
+ // --- contextualizes observations correctly ---
263
+ {
264
+ const content = [
265
+ JSON.stringify({
266
+ type: 'entity', name: 'John', entityType: 'person',
267
+ observations: [
268
+ 'works at Acme Corp', // lowercase verb -> "John works at Acme Corp"
269
+ 'John likes TypeScript', // already starts with name -> unchanged
270
+ 'He prefers React', // pronoun -> replaced: "John prefers React"
271
+ 'An avid hiker', // uppercase start -> "John: An avid hiker"
272
+ ],
273
+ }),
274
+ ].join('\n');
275
+
276
+ const adapter = new MCPMemoryAdapter();
277
+ const result = await adapter.parse({ content });
278
+
279
+ assert(result.facts.length === 4, 'MCP: 4 observations -> 4 facts');
280
+ assert(result.facts[0].text === 'John works at Acme Corp', 'MCP: lowercase verb prefixed with entity name');
281
+ assert(result.facts[1].text === 'John likes TypeScript', 'MCP: already has entity name, unchanged');
282
+ assert(result.facts[2].text === 'John prefers React', 'MCP: pronoun "He" replaced with entity name');
283
+ assert(result.facts[3].text === 'John: An avid hiker', 'MCP: uppercase standalone sentence prefixed with colon');
284
+ }
285
+
286
+ // --- parses relations ---
287
+ {
288
+ const content = [
289
+ JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Developer'] }),
290
+ JSON.stringify({ type: 'entity', name: 'Project Alpha', entityType: 'project', observations: ['Deadline March'] }),
291
+ JSON.stringify({ type: 'relation', from: 'John', to: 'Project Alpha', relationType: 'works_on' }),
292
+ ].join('\n');
293
+
294
+ const adapter = new MCPMemoryAdapter();
295
+ const result = await adapter.parse({ content });
296
+
297
+ // 2 observations + 1 relation = 3 facts
298
+ assert(result.facts.length === 3, 'MCP: 2 observations + 1 relation = 3 facts');
299
+ assert(
300
+ result.facts[2].text === 'John works on Project Alpha',
301
+ 'MCP: relation converted to human-readable text',
302
+ );
303
+ assert(result.facts[2].type === 'fact', 'MCP: relation type defaults to fact');
304
+ }
305
+
306
+ // --- later entity overrides earlier (append-only semantics) ---
307
+ {
308
+ const content = [
309
+ JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Old observation'] }),
310
+ JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['New observation'] }),
311
+ ].join('\n');
312
+
313
+ const adapter = new MCPMemoryAdapter();
314
+ const result = await adapter.parse({ content });
315
+
316
+ // Only the later entity should be used (Map overwrites)
317
+ assert(result.facts.length === 1, 'MCP: duplicate entity keeps only latest');
318
+ assert(
319
+ result.facts[0].text.includes('New observation'),
320
+ 'MCP: latest entity observations used',
321
+ );
322
+ }
323
+
324
+ // --- warns on orphaned relations ---
325
+ {
326
+ const content = [
327
+ JSON.stringify({ type: 'relation', from: 'Unknown', to: 'Also Unknown', relationType: 'knows' }),
328
+ ].join('\n');
329
+
330
+ const adapter = new MCPMemoryAdapter();
331
+ const result = await adapter.parse({ content });
332
+
333
+ const hasWarning = result.warnings.some((w) => w.includes('unknown entity'));
334
+ assert(hasWarning, 'MCP: warns on orphaned relation with unknown entities');
335
+ // Orphaned relation should not produce a fact
336
+ assert(result.facts.length === 0, 'MCP: orphaned relation produces no facts');
337
+ }
338
+
339
+ // --- handles empty/blank lines in JSONL ---
340
+ {
341
+ const content = [
342
+ '',
343
+ JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['Likes coffee'] }),
344
+ ' ',
345
+ JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: ['Likes tea'] }),
346
+ '',
347
+ ].join('\n');
348
+
349
+ const adapter = new MCPMemoryAdapter();
350
+ const result = await adapter.parse({ content });
351
+
352
+ assert(result.facts.length === 2, 'MCP: blank lines in JSONL are skipped');
353
+ assert(result.errors.length === 0, 'MCP: blank lines do not produce errors');
354
+ }
355
+
356
+ // --- invalid JSON lines produce errors ---
357
+ {
358
+ const content = [
359
+ JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['Valid'] }),
360
+ 'this is not json',
361
+ JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: ['Also valid'] }),
362
+ ].join('\n');
363
+
364
+ const adapter = new MCPMemoryAdapter();
365
+ const result = await adapter.parse({ content });
366
+
367
+ assert(result.facts.length === 2, 'MCP: valid facts parsed despite invalid line');
368
+ assert(result.errors.length === 1, 'MCP: one error for the invalid JSON line');
369
+ assert(result.errors[0].includes('Line 2'), 'MCP: error references correct line number');
370
+ }
371
+
372
+ // --- entity type mapping ---
373
+ {
374
+ const content = [
375
+ JSON.stringify({ type: 'entity', name: 'React', entityType: 'tool', observations: ['Frontend framework'] }),
376
+ JSON.stringify({ type: 'entity', name: 'Launch', entityType: 'goal', observations: ['Ship by March'] }),
377
+ JSON.stringify({ type: 'entity', name: 'Meeting', entityType: 'event', observations: ['Quarterly review'] }),
378
+ JSON.stringify({ type: 'entity', name: 'PickedAWS', entityType: 'decision', observations: ['Over GCP'] }),
379
+ ].join('\n');
380
+
381
+ const adapter = new MCPMemoryAdapter();
382
+ const result = await adapter.parse({ content });
383
+
384
+ assert(result.facts[0].type === 'preference', 'MCP: "tool" entityType -> preference');
385
+ assert(result.facts[1].type === 'goal', 'MCP: "goal" entityType -> goal');
386
+ assert(result.facts[2].type === 'episodic', 'MCP: "event" entityType -> episodic');
387
+ assert(result.facts[3].type === 'decision', 'MCP: "decision" entityType -> decision');
388
+ }
389
+
390
+ // --- no content and no file_path (and no default file) ---
391
+ {
392
+ const adapter = new MCPMemoryAdapter();
393
+ // Pass a non-existent file_path to trigger the file read error path
394
+ const result = await adapter.parse({ file_path: '/tmp/definitely-does-not-exist-totalreclaw-test.jsonl' });
395
+
396
+ assert(result.facts.length === 0, 'MCP: missing file yields 0 facts');
397
+ assert(result.errors.length > 0, 'MCP: missing file produces error');
398
+ }
399
+
400
+ // --- relation type humanization ---
401
+ {
402
+ const content = [
403
+ JSON.stringify({ type: 'entity', name: 'A', entityType: 'person', observations: ['exists'] }),
404
+ JSON.stringify({ type: 'entity', name: 'B', entityType: 'project', observations: ['exists'] }),
405
+ JSON.stringify({ type: 'relation', from: 'A', to: 'B', relationType: 'MEMBER_OF' }),
406
+ ].join('\n');
407
+
408
+ const adapter = new MCPMemoryAdapter();
409
+ const result = await adapter.parse({ content });
410
+
411
+ const relFact = result.facts.find((f) => f.tags?.includes('relation'));
412
+ assert(
413
+ relFact?.text === 'A is a member of B',
414
+ `MCP: MEMBER_OF humanized to "is a member of" (got: ${relFact?.text})`,
415
+ );
416
+ }
417
+
418
+ // =========================================================================
419
+ // BaseImportAdapter (via TestAdapter)
420
+ // =========================================================================
421
+
422
+ console.log('# BaseImportAdapter');
423
+
424
+ const testAdapter = new TestAdapter();
425
+
426
+ // --- text validation: minimum 3 chars ---
427
+ {
428
+ assert(testAdapter.testValidateFact({ text: '' }) === null, 'Base: empty text returns null');
429
+ assert(testAdapter.testValidateFact({ text: 'ab' }) === null, 'Base: 2-char text returns null');
430
+ assert(testAdapter.testValidateFact({ text: ' ' }) === null, 'Base: whitespace-only text returns null');
431
+ assert(testAdapter.testValidateFact({ text: 'a ' }) === null, 'Base: 1-char trimmed text returns null');
432
+
433
+ const result = testAdapter.testValidateFact({ text: 'abc' });
434
+ assert(result !== null, 'Base: 3-char text is valid');
435
+ assert(result?.text === 'abc', 'Base: 3-char text preserved');
436
+ }
437
+
438
+ // --- text truncation at 512 chars ---
439
+ {
440
+ const longText = 'x'.repeat(600);
441
+ const result = testAdapter.testValidateFact({ text: longText });
442
+
443
+ assert(result !== null, 'Base: long text is valid');
444
+ assert(result!.text.length === 512, `Base: text truncated to 512 chars (got ${result!.text.length})`);
445
+ }
446
+
447
+ // --- text is trimmed ---
448
+ {
449
+ const result = testAdapter.testValidateFact({ text: ' hello world ' });
450
+ assert(result !== null, 'Base: padded text is valid');
451
+ assert(result!.text === 'hello world', 'Base: text is trimmed');
452
+ }
453
+
454
+ // --- type normalization ---
455
+ {
456
+ const validTypes = ['fact', 'preference', 'decision', 'episodic', 'goal'] as const;
457
+
458
+ for (const t of validTypes) {
459
+ const result = testAdapter.testValidateFact({ text: 'test fact', type: t });
460
+ assert(result?.type === t, `Base: type "${t}" preserved`);
461
+ }
462
+
463
+ const invalidResult = testAdapter.testValidateFact({ text: 'test fact', type: 'bogus' as any });
464
+ assert(invalidResult?.type === 'fact', 'Base: invalid type defaults to "fact"');
465
+
466
+ const missingResult = testAdapter.testValidateFact({ text: 'test fact' });
467
+ assert(missingResult?.type === 'fact', 'Base: missing type defaults to "fact"');
468
+ }
469
+
470
+ // --- importance scale conversion (0-1 -> 1-10) ---
471
+ {
472
+ // 0-1 scale conversion
473
+ const r0 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.0 });
474
+ assert(r0?.importance === 1, `Base: importance 0.0 -> 1 (got ${r0?.importance})`);
475
+
476
+ const r05 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.5 });
477
+ assert(r05?.importance === 5, `Base: importance 0.5 -> 5 (got ${r05?.importance})`);
478
+
479
+ const r1 = testAdapter.testValidateFact({ text: 'test fact', importance: 1.0 });
480
+ assert(r1?.importance === 10, `Base: importance 1.0 -> 10 (got ${r1?.importance})`);
481
+
482
+ const r03 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.3 });
483
+ assert(r03?.importance === 3, `Base: importance 0.3 -> 3 (got ${r03?.importance})`);
484
+
485
+ const r08 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.8 });
486
+ assert(r08?.importance === 8, `Base: importance 0.8 -> 8 (got ${r08?.importance})`);
487
+ }
488
+
489
+ // --- importance already on 1-10 scale ---
490
+ {
491
+ const r5 = testAdapter.testValidateFact({ text: 'test fact', importance: 5 });
492
+ assert(r5?.importance === 5, `Base: importance 5 stays 5 (got ${r5?.importance})`);
493
+
494
+ const r10 = testAdapter.testValidateFact({ text: 'test fact', importance: 10 });
495
+ assert(r10?.importance === 10, `Base: importance 10 stays 10 (got ${r10?.importance})`);
496
+
497
+ const r7 = testAdapter.testValidateFact({ text: 'test fact', importance: 7 });
498
+ assert(r7?.importance === 7, `Base: importance 7 stays 7 (got ${r7?.importance})`);
499
+ }
500
+
501
+ // --- importance clamping ---
502
+ {
503
+ const rNeg = testAdapter.testValidateFact({ text: 'test fact', importance: -5 });
504
+ assert(rNeg?.importance === 1, `Base: importance -5 clamped to 1 (got ${rNeg?.importance})`);
505
+
506
+ const rHuge = testAdapter.testValidateFact({ text: 'test fact', importance: 100 });
507
+ assert(rHuge?.importance === 10, `Base: importance 100 clamped to 10 (got ${rHuge?.importance})`);
508
+ }
509
+
510
+ // --- importance default ---
511
+ {
512
+ const rDefault = testAdapter.testValidateFact({ text: 'test fact' });
513
+ assert(rDefault?.importance === 5, `Base: missing importance defaults to 5 (got ${rDefault?.importance})`);
514
+ }
515
+
516
+ // --- source defaults to adapter's source ---
517
+ {
518
+ const result = testAdapter.testValidateFact({ text: 'test fact' });
519
+ assert(result?.source === 'mem0', 'Base: source defaults to adapter source');
520
+
521
+ const withSource = testAdapter.testValidateFact({ text: 'test fact', source: 'mcp-memory' });
522
+ assert(withSource?.source === 'mcp-memory', 'Base: explicit source preserved');
523
+ }
524
+
525
+ // --- validateFacts batch ---
526
+ {
527
+ const rawFacts: Partial<NormalizedFact>[] = [
528
+ { text: 'Valid fact one' },
529
+ { text: '' }, // invalid
530
+ { text: 'Valid fact two' },
531
+ { text: 'xy' }, // too short
532
+ { text: 'Valid fact three' },
533
+ ];
534
+
535
+ const { facts, invalidCount } = testAdapter.testValidateFacts(rawFacts);
536
+ assert(facts.length === 3, `Base: validateFacts keeps 3 valid out of 5 (got ${facts.length})`);
537
+ assert(invalidCount === 2, `Base: validateFacts counts 2 invalid (got ${invalidCount})`);
538
+ }
539
+
540
+ // =========================================================================
541
+ // getAdapter factory
542
+ // =========================================================================
543
+
544
+ console.log('# getAdapter factory');
545
+
546
+ // --- valid sources ---
547
+ {
548
+ const mem0 = getAdapter('mem0');
549
+ assert(mem0 instanceof Mem0Adapter, 'getAdapter("mem0") returns Mem0Adapter');
550
+
551
+ const mcp = getAdapter('mcp-memory');
552
+ assert(mcp instanceof MCPMemoryAdapter, 'getAdapter("mcp-memory") returns MCPMemoryAdapter');
553
+ }
554
+
555
+ // --- unknown source throws ---
556
+ {
557
+ assertThrows(
558
+ () => getAdapter('nonexistent' as ImportSource),
559
+ 'getAdapter throws on unknown source',
560
+ );
561
+ }
562
+
563
+ // --- error message lists valid sources ---
564
+ {
565
+ try {
566
+ getAdapter('bogus' as ImportSource);
567
+ assert(false, 'getAdapter should throw for bogus source');
568
+ } catch (e: unknown) {
569
+ const msg = (e as Error).message;
570
+ assert(msg.includes('mem0'), 'getAdapter error lists "mem0" as valid source');
571
+ assert(msg.includes('mcp-memory'), 'getAdapter error lists "mcp-memory" as valid source');
572
+ }
573
+ }
574
+
575
+ // =========================================================================
576
+ // Summary
577
+ // =========================================================================
578
+
579
+ console.log(`\n1..${testNum}`);
580
+ console.log(`# pass: ${passed}`);
581
+ console.log(`# fail: ${failed}`);
582
+
583
+ if (failed > 0) {
584
+ console.log('\nFAILED');
585
+ process.exit(1);
586
+ } else {
587
+ console.log('\nALL TESTS PASSED');
588
+ process.exit(0);
589
+ }
590
+ }
591
+
592
+ runTests().catch((err) => {
593
+ console.error('Test runner failed:', err);
594
+ process.exit(1);
595
+ });
@@ -0,0 +1,22 @@
1
+ export { BaseImportAdapter } from './base-adapter.js';
2
+ export * from './types.js';
3
+ export { Mem0Adapter } from './mem0-adapter.js';
4
+ export { MCPMemoryAdapter } from './mcp-memory-adapter.js';
5
+
6
+ import type { ImportSource } from './types.js';
7
+ import { Mem0Adapter } from './mem0-adapter.js';
8
+ import { MCPMemoryAdapter } from './mcp-memory-adapter.js';
9
+ import type { BaseImportAdapter } from './base-adapter.js';
10
+
11
+ const ADAPTERS: Partial<Record<ImportSource, () => BaseImportAdapter>> = {
12
+ 'mem0': () => new Mem0Adapter(),
13
+ 'mcp-memory': () => new MCPMemoryAdapter(),
14
+ };
15
+
16
+ export function getAdapter(source: ImportSource): BaseImportAdapter {
17
+ const factory = ADAPTERS[source];
18
+ if (!factory) {
19
+ throw new Error(`Unknown import source: ${source}. Valid sources: ${Object.keys(ADAPTERS).join(', ')}`);
20
+ }
21
+ return factory();
22
+ }