@aitytech/agentkits-memory 1.0.0 → 2.0.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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -149
  3. package/assets/agentkits-memory-add-memory.png +0 -0
  4. package/assets/agentkits-memory-memory-detail.png +0 -0
  5. package/assets/agentkits-memory-memory-list.png +0 -0
  6. package/assets/logo.svg +24 -0
  7. package/dist/better-sqlite3-backend.d.ts +192 -0
  8. package/dist/better-sqlite3-backend.d.ts.map +1 -0
  9. package/dist/better-sqlite3-backend.js +801 -0
  10. package/dist/better-sqlite3-backend.js.map +1 -0
  11. package/dist/cli/save.js +0 -0
  12. package/dist/cli/setup.d.ts +6 -2
  13. package/dist/cli/setup.d.ts.map +1 -1
  14. package/dist/cli/setup.js +289 -42
  15. package/dist/cli/setup.js.map +1 -1
  16. package/dist/cli/viewer.js +25 -56
  17. package/dist/cli/viewer.js.map +1 -1
  18. package/dist/cli/web-viewer.d.ts +14 -0
  19. package/dist/cli/web-viewer.d.ts.map +1 -0
  20. package/dist/cli/web-viewer.js +1769 -0
  21. package/dist/cli/web-viewer.js.map +1 -0
  22. package/dist/embeddings/embedding-cache.d.ts +131 -0
  23. package/dist/embeddings/embedding-cache.d.ts.map +1 -0
  24. package/dist/embeddings/embedding-cache.js +217 -0
  25. package/dist/embeddings/embedding-cache.js.map +1 -0
  26. package/dist/embeddings/index.d.ts +11 -0
  27. package/dist/embeddings/index.d.ts.map +1 -0
  28. package/dist/embeddings/index.js +11 -0
  29. package/dist/embeddings/index.js.map +1 -0
  30. package/dist/embeddings/local-embeddings.d.ts +140 -0
  31. package/dist/embeddings/local-embeddings.d.ts.map +1 -0
  32. package/dist/embeddings/local-embeddings.js +293 -0
  33. package/dist/embeddings/local-embeddings.js.map +1 -0
  34. package/dist/hooks/context.d.ts +6 -1
  35. package/dist/hooks/context.d.ts.map +1 -1
  36. package/dist/hooks/context.js +12 -2
  37. package/dist/hooks/context.js.map +1 -1
  38. package/dist/hooks/observation.d.ts +6 -1
  39. package/dist/hooks/observation.d.ts.map +1 -1
  40. package/dist/hooks/observation.js +12 -2
  41. package/dist/hooks/observation.js.map +1 -1
  42. package/dist/hooks/service.d.ts +1 -6
  43. package/dist/hooks/service.d.ts.map +1 -1
  44. package/dist/hooks/service.js +33 -85
  45. package/dist/hooks/service.js.map +1 -1
  46. package/dist/hooks/session-init.d.ts +6 -1
  47. package/dist/hooks/session-init.d.ts.map +1 -1
  48. package/dist/hooks/session-init.js +12 -2
  49. package/dist/hooks/session-init.js.map +1 -1
  50. package/dist/hooks/summarize.d.ts +6 -1
  51. package/dist/hooks/summarize.d.ts.map +1 -1
  52. package/dist/hooks/summarize.js +12 -2
  53. package/dist/hooks/summarize.js.map +1 -1
  54. package/dist/index.d.ts +10 -17
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +172 -94
  57. package/dist/index.js.map +1 -1
  58. package/dist/mcp/server.js +17 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/migration.js +3 -3
  61. package/dist/migration.js.map +1 -1
  62. package/dist/search/hybrid-search.d.ts +262 -0
  63. package/dist/search/hybrid-search.d.ts.map +1 -0
  64. package/dist/search/hybrid-search.js +688 -0
  65. package/dist/search/hybrid-search.js.map +1 -0
  66. package/dist/search/index.d.ts +13 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/index.js +13 -0
  69. package/dist/search/index.js.map +1 -0
  70. package/dist/search/token-economics.d.ts +161 -0
  71. package/dist/search/token-economics.d.ts.map +1 -0
  72. package/dist/search/token-economics.js +239 -0
  73. package/dist/search/token-economics.js.map +1 -0
  74. package/dist/types.d.ts +0 -68
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js.map +1 -1
  77. package/package.json +23 -8
  78. package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
  79. package/src/__tests__/cache-manager.test.ts +499 -0
  80. package/src/__tests__/embedding-integration.test.ts +481 -0
  81. package/src/__tests__/hnsw-index.test.ts +727 -0
  82. package/src/__tests__/index.test.ts +432 -0
  83. package/src/better-sqlite3-backend.ts +1000 -0
  84. package/src/cli/setup.ts +358 -47
  85. package/src/cli/viewer.ts +28 -63
  86. package/src/cli/web-viewer.ts +1956 -0
  87. package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
  88. package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
  89. package/src/embeddings/embedding-cache.ts +318 -0
  90. package/src/embeddings/index.ts +20 -0
  91. package/src/embeddings/local-embeddings.ts +419 -0
  92. package/src/hooks/__tests__/handlers.test.ts +58 -17
  93. package/src/hooks/__tests__/integration.test.ts +77 -26
  94. package/src/hooks/context.ts +13 -2
  95. package/src/hooks/observation.ts +13 -2
  96. package/src/hooks/service.ts +39 -100
  97. package/src/hooks/session-init.ts +13 -2
  98. package/src/hooks/summarize.ts +13 -2
  99. package/src/index.ts +210 -116
  100. package/src/mcp/server.ts +20 -3
  101. package/src/search/__tests__/hybrid-search.test.ts +669 -0
  102. package/src/search/__tests__/token-economics.test.ts +276 -0
  103. package/src/search/hybrid-search.ts +968 -0
  104. package/src/search/index.ts +29 -0
  105. package/src/search/token-economics.ts +367 -0
  106. package/src/types.ts +0 -96
  107. package/src/__tests__/sqljs-backend.test.ts +0 -410
  108. package/src/migration.ts +0 -574
  109. package/src/sql.js.d.ts +0 -70
  110. package/src/sqljs-backend.ts +0 -789
@@ -0,0 +1,669 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import Database from 'better-sqlite3';
3
+ import type { Database as BetterDatabase } from 'better-sqlite3';
4
+ import {
5
+ HybridSearchEngine,
6
+ createHybridSearchEngine,
7
+ } from '../hybrid-search.js';
8
+
9
+ describe('HybridSearchEngine', () => {
10
+ let db: BetterDatabase;
11
+ let engine: HybridSearchEngine;
12
+
13
+ beforeEach(async () => {
14
+ db = new Database(':memory:');
15
+
16
+ // Create memory_entries table
17
+ db.exec(`
18
+ CREATE TABLE memory_entries (
19
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ id TEXT UNIQUE NOT NULL,
21
+ key TEXT NOT NULL,
22
+ content TEXT NOT NULL,
23
+ type TEXT DEFAULT 'semantic',
24
+ namespace TEXT DEFAULT 'default',
25
+ tags TEXT DEFAULT '[]',
26
+ metadata TEXT DEFAULT '{}',
27
+ embedding BLOB,
28
+ owner_id TEXT,
29
+ access_level TEXT DEFAULT 'project',
30
+ created_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ expires_at INTEGER,
33
+ version INTEGER DEFAULT 1,
34
+ "references" TEXT DEFAULT '[]',
35
+ access_count INTEGER DEFAULT 0,
36
+ last_accessed_at INTEGER NOT NULL
37
+ )
38
+ `);
39
+
40
+ engine = new HybridSearchEngine(db);
41
+ await engine.initialize();
42
+ });
43
+
44
+ afterEach(() => {
45
+ db.close();
46
+ });
47
+
48
+ describe('initialization', () => {
49
+ it('should initialize without error', () => {
50
+ expect(engine).toBeDefined();
51
+ });
52
+
53
+ it('should detect FTS5 availability correctly', () => {
54
+ const available = engine.isFtsAvailable();
55
+ expect(typeof available).toBe('boolean');
56
+ // better-sqlite3 should always have FTS5
57
+ expect(available).toBe(true);
58
+ });
59
+
60
+ it('should detect trigram tokenizer for CJK support', () => {
61
+ const tokenizer = engine.getActiveTokenizer();
62
+ // better-sqlite3 includes trigram tokenizer
63
+ expect(tokenizer).toBe('trigram');
64
+ expect(engine.isCjkOptimized()).toBe(true);
65
+ });
66
+
67
+ it('should create FTS5 virtual table', () => {
68
+ const result = db.prepare(
69
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_fts'"
70
+ ).get() as { name: string } | undefined;
71
+ expect(result?.name).toBe('memory_fts');
72
+ });
73
+
74
+ it('should create sync triggers', () => {
75
+ const result = db.prepare(
76
+ "SELECT name FROM sqlite_master WHERE type='trigger'"
77
+ ).all() as { name: string }[];
78
+ const triggerNames = result.map((r) => r.name);
79
+ expect(triggerNames).toContain('memory_fts_insert');
80
+ expect(triggerNames).toContain('memory_fts_delete');
81
+ expect(triggerNames).toContain('memory_fts_update');
82
+ });
83
+ });
84
+
85
+ describe('keyword search', () => {
86
+ beforeEach(async () => {
87
+ const now = Date.now();
88
+ const entries = [
89
+ { id: 'e1', key: 'auth', content: 'JWT authentication with refresh tokens', namespace: 'patterns' },
90
+ { id: 'e2', key: 'database', content: 'PostgreSQL connection pooling', namespace: 'patterns' },
91
+ { id: 'e3', key: 'api', content: 'REST API with authentication headers', namespace: 'decisions' },
92
+ { id: 'e4', key: 'security', content: 'OAuth2 authentication flow', namespace: 'patterns' },
93
+ ];
94
+
95
+ const stmt = db.prepare(
96
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
97
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
98
+ );
99
+ for (const entry of entries) {
100
+ stmt.run(entry.id, entry.key, entry.content, entry.namespace, now, now, now);
101
+ }
102
+
103
+ await engine.rebuildFtsIndex();
104
+ });
105
+
106
+ it('should find entries by keyword', async () => {
107
+ const results = await engine.searchCompact('authentication', { includeSemantic: false });
108
+
109
+ expect(results.length).toBeGreaterThan(0);
110
+ expect(results.some((r) => r.id === 'e1')).toBe(true); // JWT authentication
111
+ });
112
+
113
+ it('should return compact results with required fields', async () => {
114
+ const results = await engine.searchCompact('authentication', { includeSemantic: false });
115
+
116
+ expect(results.length).toBeGreaterThan(0);
117
+ for (const result of results) {
118
+ expect(result.id).toBeDefined();
119
+ expect(result.key).toBeDefined();
120
+ expect(result.namespace).toBeDefined();
121
+ expect(result.score).toBeGreaterThanOrEqual(0);
122
+ expect(result.snippet).toBeDefined();
123
+ expect(result.estimatedTokens).toBeGreaterThan(0);
124
+ }
125
+ });
126
+
127
+ it('should filter by namespace', async () => {
128
+ const results = await engine.searchCompact('authentication', {
129
+ namespace: 'patterns',
130
+ includeSemantic: false,
131
+ });
132
+
133
+ expect(results.length).toBeGreaterThan(0);
134
+ for (const result of results) {
135
+ expect(result.namespace).toBe('patterns');
136
+ }
137
+ });
138
+
139
+ it('should handle empty query', async () => {
140
+ const results = await engine.searchCompact('', { includeSemantic: false });
141
+ expect(results.length).toBe(0);
142
+ });
143
+
144
+ it('should handle query with special characters', async () => {
145
+ const results = await engine.searchCompact('test*[query]', { includeSemantic: false });
146
+ expect(Array.isArray(results)).toBe(true);
147
+ });
148
+
149
+ it('should find multiple matching entries', async () => {
150
+ const results = await engine.searchCompact('authentication', { includeSemantic: false });
151
+ // Should find e1, e3, e4 (all have "authentication")
152
+ expect(results.length).toBeGreaterThanOrEqual(3);
153
+ });
154
+ });
155
+
156
+ describe('CJK language support', () => {
157
+ it('should support Japanese (日本語) search', async () => {
158
+ const now = Date.now();
159
+ db.prepare(
160
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
161
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
162
+ ).run('jp1', 'japanese', '日本語のテスト内容です', 'patterns', now, now, now);
163
+ await engine.rebuildFtsIndex();
164
+
165
+ const results = await engine.searchCompact('日本語', { includeSemantic: false });
166
+ expect(results.some((r) => r.id === 'jp1')).toBe(true);
167
+ });
168
+
169
+ it('should support Chinese (中文) search', async () => {
170
+ const now = Date.now();
171
+ db.prepare(
172
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
173
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
174
+ ).run('cn1', 'chinese', '中文测试内容', 'patterns', now, now, now);
175
+ await engine.rebuildFtsIndex();
176
+
177
+ const results = await engine.searchCompact('中文', { includeSemantic: false });
178
+ expect(results.some((r) => r.id === 'cn1')).toBe(true);
179
+ });
180
+
181
+ it('should support Korean (한국어) search', async () => {
182
+ const now = Date.now();
183
+ db.prepare(
184
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
185
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
186
+ ).run('kr1', 'korean', '한국어 테스트 내용입니다', 'patterns', now, now, now);
187
+ await engine.rebuildFtsIndex();
188
+
189
+ const results = await engine.searchCompact('한국어', { includeSemantic: false });
190
+ expect(results.some((r) => r.id === 'kr1')).toBe(true);
191
+ });
192
+
193
+ it('should support mixed CJK and English search', async () => {
194
+ const now = Date.now();
195
+ db.prepare(
196
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
197
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
198
+ ).run('mix1', 'mixed', 'API設計パターン Japanese API design', 'patterns', now, now, now);
199
+ await engine.rebuildFtsIndex();
200
+
201
+ // Search Japanese
202
+ const jpResults = await engine.searchCompact('設計パターン', { includeSemantic: false });
203
+ expect(jpResults.some((r) => r.id === 'mix1')).toBe(true);
204
+
205
+ // Search English
206
+ const enResults = await engine.searchCompact('design', { includeSemantic: false });
207
+ expect(enResults.some((r) => r.id === 'mix1')).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe('FTS5 features', () => {
212
+ beforeEach(async () => {
213
+ const now = Date.now();
214
+ const entries = [
215
+ { id: 'e1', key: 'auth', content: 'JWT authentication with refresh tokens', namespace: 'patterns' },
216
+ { id: 'e2', key: 'database', content: 'PostgreSQL connection pooling', namespace: 'patterns' },
217
+ { id: 'e3', key: 'api', content: 'REST API with authentication headers', namespace: 'decisions' },
218
+ ];
219
+
220
+ const stmt = db.prepare(
221
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
222
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
223
+ );
224
+ for (const entry of entries) {
225
+ stmt.run(entry.id, entry.key, entry.content, entry.namespace, now, now, now);
226
+ }
227
+ await engine.rebuildFtsIndex();
228
+ });
229
+
230
+ it('should use BM25 ranking', async () => {
231
+ const results = await engine.searchCompact('authentication', { includeSemantic: false });
232
+ // With BM25, results should be ranked by relevance
233
+ expect(results.length).toBeGreaterThan(0);
234
+ // First result should have highest score
235
+ for (let i = 1; i < results.length; i++) {
236
+ expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
237
+ }
238
+ });
239
+
240
+ it('should sync FTS index on insert via trigger', async () => {
241
+ const now = Date.now();
242
+ db.prepare(
243
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
244
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
245
+ ).run('new1', 'new-key', 'Brand new content for testing', 'patterns', now, now, now);
246
+
247
+ // Should find without manual rebuildFtsIndex due to trigger
248
+ const results = await engine.searchCompact('Brand new content', { includeSemantic: false });
249
+ expect(results.some((r) => r.id === 'new1')).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe('LIKE fallback', () => {
254
+ it('should work when engine not initialized', async () => {
255
+ // Create engine with fallback forced (by using db without FTS5 init)
256
+ const fallbackEngine = new HybridSearchEngine(db, { fallbackToLike: true });
257
+ // Don't initialize - simulates no FTS5
258
+
259
+ const now = Date.now();
260
+ db.prepare(
261
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
262
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
263
+ ).run('fallback1', 'test', 'Fallback test content', 'default', now, now, now);
264
+
265
+ // This will use LIKE fallback since engine not initialized
266
+ const results = await fallbackEngine.searchCompact('Fallback', { includeSemantic: false });
267
+ expect(results.some((r) => r.id === 'fallback1')).toBe(true);
268
+ });
269
+ });
270
+
271
+ describe('3-layer search workflow', () => {
272
+ beforeEach(async () => {
273
+ const baseTime = Date.now();
274
+ const entries = [
275
+ { id: 'e1', key: 'step1', content: 'First step content', created_at: baseTime - 3000 },
276
+ { id: 'e2', key: 'step2', content: 'Second step content', created_at: baseTime - 2000 },
277
+ { id: 'e3', key: 'step3', content: 'Third step content', created_at: baseTime - 1000 },
278
+ { id: 'e4', key: 'step4', content: 'Fourth step content', created_at: baseTime },
279
+ ];
280
+
281
+ const stmt = db.prepare(
282
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
283
+ VALUES (?, ?, ?, 'default', '[]', ?, ?, ?)`
284
+ );
285
+ for (const entry of entries) {
286
+ stmt.run(entry.id, entry.key, entry.content, entry.created_at, entry.created_at, entry.created_at);
287
+ }
288
+ await engine.rebuildFtsIndex();
289
+ });
290
+
291
+ it('Layer 1: should return compact results with snippets', async () => {
292
+ const results = await engine.searchCompact('step content', { includeSemantic: false });
293
+
294
+ expect(results.length).toBeGreaterThan(0);
295
+ for (const result of results) {
296
+ expect(result.snippet.length).toBeLessThanOrEqual(100);
297
+ expect(result.estimatedTokens).toBeGreaterThan(0);
298
+ }
299
+ });
300
+
301
+ it('Layer 2: should return timeline context with before/after', async () => {
302
+ const timeline = await engine.searchTimeline(['e2'], 1);
303
+
304
+ expect(timeline.length).toBe(1);
305
+ expect(timeline[0].entry.id).toBe('e2');
306
+ expect(timeline[0].before.length).toBe(1); // e1
307
+ expect(timeline[0].after.length).toBe(1); // e3
308
+ expect(timeline[0].before[0].id).toBe('e1');
309
+ expect(timeline[0].after[0].id).toBe('e3');
310
+ });
311
+
312
+ it('Layer 2: should handle multiple context windows', async () => {
313
+ const timeline = await engine.searchTimeline(['e2'], 2);
314
+
315
+ expect(timeline[0].before.length).toBe(1); // Only e1 exists before
316
+ expect(timeline[0].after.length).toBe(2); // e3 and e4
317
+ });
318
+
319
+ it('Layer 3: should return full entries with all fields', async () => {
320
+ const entries = await engine.getFull(['e1', 'e2']);
321
+
322
+ expect(entries.length).toBe(2);
323
+ expect(entries[0].id).toBe('e1');
324
+ expect(entries[0].content).toBe('First step content');
325
+ expect(entries[0].key).toBe('step1');
326
+ expect(entries[1].id).toBe('e2');
327
+ expect(entries[1].content).toBe('Second step content');
328
+ });
329
+
330
+ it('Layer 3: should handle empty ID list', async () => {
331
+ const entries = await engine.getFull([]);
332
+ expect(entries.length).toBe(0);
333
+ });
334
+
335
+ it('Layer 3: should preserve order of requested IDs', async () => {
336
+ const entries = await engine.getFull(['e3', 'e1', 'e4']);
337
+
338
+ expect(entries.length).toBe(3);
339
+ expect(entries[0].id).toBe('e3');
340
+ expect(entries[1].id).toBe('e1');
341
+ expect(entries[2].id).toBe('e4');
342
+ });
343
+ });
344
+
345
+ describe('hybrid search with economics', () => {
346
+ beforeEach(async () => {
347
+ const now = Date.now();
348
+ db.prepare(
349
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
350
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
351
+ ).run('test1', 'test-key', 'Test content for hybrid search with some longer text to measure tokens', 'default', now, now, now);
352
+ await engine.rebuildFtsIndex();
353
+ });
354
+
355
+ it('should return full search result with all components', async () => {
356
+ const result = await engine.search('test', { fetchFull: true });
357
+
358
+ expect(result.results).toBeDefined();
359
+ expect(result.compact).toBeDefined();
360
+ expect(result.economics).toBeDefined();
361
+ expect(result.timing).toBeDefined();
362
+ });
363
+
364
+ it('should track token economics', async () => {
365
+ const result = await engine.search('test', { fetchFull: true });
366
+
367
+ expect(result.economics.fullResultTokens).toBeGreaterThanOrEqual(0);
368
+ expect(result.economics.actualTokens).toBeGreaterThanOrEqual(0);
369
+ expect(result.economics.savingsPercent).toBeGreaterThanOrEqual(0);
370
+ expect(result.economics.layers).toBeDefined();
371
+ });
372
+
373
+ it('should track timing metrics', async () => {
374
+ const result = await engine.search('test');
375
+
376
+ expect(result.timing.keywordMs).toBeGreaterThanOrEqual(0);
377
+ expect(result.timing.totalMs).toBeGreaterThanOrEqual(0);
378
+ expect(result.timing.totalMs).toBeGreaterThanOrEqual(result.timing.keywordMs);
379
+ });
380
+
381
+ it('should respect limit option', async () => {
382
+ const now = Date.now();
383
+ const stmt = db.prepare(
384
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
385
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
386
+ );
387
+ for (let i = 0; i < 5; i++) {
388
+ stmt.run(`test${i + 2}`, `key${i}`, `Test content number ${i}`, 'default', now, now, now);
389
+ }
390
+ await engine.rebuildFtsIndex();
391
+
392
+ const result = await engine.search('test', { limit: 2 });
393
+ expect(result.compact.length).toBeLessThanOrEqual(2);
394
+ });
395
+ });
396
+
397
+ describe('configuration', () => {
398
+ it('should use default configuration', () => {
399
+ const config = engine.getConfig();
400
+
401
+ expect(config.keywordWeight).toBe(0.3);
402
+ expect(config.semanticWeight).toBe(0.7);
403
+ expect(config.minScore).toBe(0.1);
404
+ expect(config.useBM25).toBe(true);
405
+ expect(config.tokenizer).toBe('trigram');
406
+ expect(config.fallbackToLike).toBe(true);
407
+ });
408
+
409
+ it('should accept custom configuration', () => {
410
+ const customEngine = new HybridSearchEngine(db, {
411
+ keywordWeight: 0.5,
412
+ semanticWeight: 0.5,
413
+ minScore: 0.2,
414
+ tokenizer: 'unicode61',
415
+ });
416
+
417
+ const config = customEngine.getConfig();
418
+
419
+ expect(config.keywordWeight).toBe(0.5);
420
+ expect(config.semanticWeight).toBe(0.5);
421
+ expect(config.minScore).toBe(0.2);
422
+ expect(config.tokenizer).toBe('unicode61');
423
+ });
424
+
425
+ it('should update configuration dynamically', () => {
426
+ engine.updateConfig({ keywordWeight: 0.4, minScore: 0.15 });
427
+
428
+ const config = engine.getConfig();
429
+ expect(config.keywordWeight).toBe(0.4);
430
+ expect(config.minScore).toBe(0.15);
431
+ // Other values should remain unchanged
432
+ expect(config.semanticWeight).toBe(0.7);
433
+ });
434
+ });
435
+
436
+ describe('createHybridSearchEngine factory', () => {
437
+ it('should create engine with default config', () => {
438
+ const engine = createHybridSearchEngine(db);
439
+ expect(engine).toBeInstanceOf(HybridSearchEngine);
440
+ });
441
+
442
+ it('should create engine with custom config', () => {
443
+ const engine = createHybridSearchEngine(db, { keywordWeight: 0.6 });
444
+ expect(engine.getConfig().keywordWeight).toBe(0.6);
445
+ });
446
+ });
447
+
448
+ describe('edge cases', () => {
449
+ it('should handle very long content', async () => {
450
+ const now = Date.now();
451
+ const longContent = 'test '.repeat(1000); // 5000 chars
452
+ db.prepare(
453
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
454
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
455
+ ).run('long1', 'long-key', longContent, 'default', now, now, now);
456
+ await engine.rebuildFtsIndex();
457
+
458
+ const results = await engine.searchCompact('test', { includeSemantic: false });
459
+ expect(results.some((r) => r.id === 'long1')).toBe(true);
460
+ // Snippet should be truncated
461
+ expect(results.find((r) => r.id === 'long1')?.snippet.length).toBeLessThanOrEqual(100);
462
+ });
463
+
464
+ it('should handle entries with no matches', async () => {
465
+ const results = await engine.searchCompact('nonexistent_query_xyz', { includeSemantic: false });
466
+ expect(results.length).toBe(0);
467
+ });
468
+
469
+ it('should handle whitespace-only query', async () => {
470
+ const results = await engine.searchCompact(' ', { includeSemantic: false });
471
+ expect(results.length).toBe(0);
472
+ });
473
+ });
474
+
475
+ describe('non-trigram tokenizers', () => {
476
+ it('should work with unicode61 tokenizer', async () => {
477
+ const unicode61Engine = new HybridSearchEngine(db, { tokenizer: 'unicode61' });
478
+ await unicode61Engine.initialize();
479
+
480
+ const now = Date.now();
481
+ db.prepare(
482
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
483
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
484
+ ).run('u61-1', 'unicode-test', 'Testing unicode61 tokenizer with english text', 'default', now, now, now);
485
+
486
+ await unicode61Engine.rebuildFtsIndex();
487
+
488
+ const results = await unicode61Engine.searchCompact('testing english', { includeSemantic: false });
489
+ expect(results.some((r) => r.id === 'u61-1')).toBe(true);
490
+ });
491
+
492
+ it('should work with porter tokenizer', async () => {
493
+ const porterEngine = new HybridSearchEngine(db, { tokenizer: 'porter' });
494
+ await porterEngine.initialize();
495
+
496
+ const now = Date.now();
497
+ db.prepare(
498
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
499
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
500
+ ).run('porter-1', 'porter-test', 'Running and jumping are activities', 'default', now, now, now);
501
+
502
+ await porterEngine.rebuildFtsIndex();
503
+
504
+ // Porter stemmer should match "run" to "running"
505
+ const results = await porterEngine.searchCompact('run', { includeSemantic: false });
506
+ expect(results.some((r) => r.id === 'porter-1')).toBe(true);
507
+ });
508
+
509
+ it('should sanitize query for non-trigram tokenizers', async () => {
510
+ const unicode61Engine = new HybridSearchEngine(db, { tokenizer: 'unicode61' });
511
+ await unicode61Engine.initialize();
512
+
513
+ const now = Date.now();
514
+ db.prepare(
515
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
516
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
517
+ ).run('sanitize-1', 'sanitize-test', 'Multiple words in content here', 'default', now, now, now);
518
+
519
+ await unicode61Engine.rebuildFtsIndex();
520
+
521
+ // Multi-word query should be sanitized to "word1" OR "word2"
522
+ const results = await unicode61Engine.searchCompact('Multiple words', { includeSemantic: false });
523
+ expect(results.some((r) => r.id === 'sanitize-1')).toBe(true);
524
+ });
525
+ });
526
+
527
+ describe('semantic search with embeddings', () => {
528
+ it('should search entries with embeddings', async () => {
529
+ const mockEmbeddingGenerator = async (text: string): Promise<Float32Array> => {
530
+ // Simple mock that returns consistent embeddings
531
+ const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
532
+ const embedding = new Float32Array(384);
533
+ for (let i = 0; i < 384; i++) {
534
+ embedding[i] = Math.sin(hash + i) * 0.5 + 0.5;
535
+ }
536
+ return embedding;
537
+ };
538
+
539
+ const semanticEngine = new HybridSearchEngine(db, {}, mockEmbeddingGenerator);
540
+ await semanticEngine.initialize();
541
+
542
+ const now = Date.now();
543
+ const embedding = await mockEmbeddingGenerator('authentication pattern');
544
+ const embeddingBuffer = Buffer.from(embedding.buffer);
545
+
546
+ db.prepare(
547
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, embedding, created_at, updated_at, last_accessed_at)
548
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)`
549
+ ).run('emb-1', 'auth-pattern', 'JWT authentication pattern for secure APIs', 'patterns', embeddingBuffer, now, now, now);
550
+
551
+ await semanticEngine.rebuildFtsIndex();
552
+
553
+ // Search with semantic enabled
554
+ const results = await semanticEngine.searchCompact('authentication pattern', {
555
+ includeKeyword: false,
556
+ includeSemantic: true,
557
+ });
558
+
559
+ expect(results.length).toBeGreaterThan(0);
560
+ expect(results[0].semanticScore).toBeGreaterThan(0);
561
+ });
562
+
563
+ it('should fuse keyword and semantic scores', async () => {
564
+ const mockEmbeddingGenerator = async (text: string): Promise<Float32Array> => {
565
+ const embedding = new Float32Array(384);
566
+ for (let i = 0; i < 384; i++) {
567
+ embedding[i] = Math.random();
568
+ }
569
+ return embedding;
570
+ };
571
+
572
+ const fusionEngine = new HybridSearchEngine(db, {
573
+ keywordWeight: 0.3,
574
+ semanticWeight: 0.7,
575
+ }, mockEmbeddingGenerator);
576
+ await fusionEngine.initialize();
577
+
578
+ const now = Date.now();
579
+ const embedding = await mockEmbeddingGenerator('test content');
580
+ const embeddingBuffer = Buffer.from(embedding.buffer);
581
+
582
+ db.prepare(
583
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, embedding, created_at, updated_at, last_accessed_at)
584
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)`
585
+ ).run('fusion-1', 'fusion-test', 'Test content for fusion search', 'default', embeddingBuffer, now, now, now);
586
+
587
+ await fusionEngine.rebuildFtsIndex();
588
+
589
+ // Search with both enabled
590
+ const results = await fusionEngine.searchCompact('test content', {
591
+ includeKeyword: true,
592
+ includeSemantic: true,
593
+ });
594
+
595
+ expect(results.length).toBeGreaterThan(0);
596
+ // Score should be a weighted combination
597
+ const result = results.find((r) => r.id === 'fusion-1');
598
+ if (result) {
599
+ expect(result.keywordScore).toBeGreaterThanOrEqual(0);
600
+ expect(result.semanticScore).toBeGreaterThanOrEqual(0);
601
+ }
602
+ });
603
+
604
+ it('should return full entries with embeddings via getFull', async () => {
605
+ const now = Date.now();
606
+ const embedding = new Float32Array(384);
607
+ for (let i = 0; i < 384; i++) {
608
+ embedding[i] = i / 384;
609
+ }
610
+ const embeddingBuffer = Buffer.from(embedding.buffer);
611
+
612
+ db.prepare(
613
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, embedding, created_at, updated_at, last_accessed_at)
614
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)`
615
+ ).run('full-emb-1', 'full-emb-test', 'Content with embedding', 'default', embeddingBuffer, now, now, now);
616
+
617
+ const entries = await engine.getFull(['full-emb-1']);
618
+
619
+ expect(entries.length).toBe(1);
620
+ expect(entries[0].embedding).toBeDefined();
621
+ expect(entries[0].embedding?.length).toBe(384);
622
+ expect(entries[0].embedding?.[0]).toBeCloseTo(0, 5);
623
+ expect(entries[0].embedding?.[383]).toBeCloseTo(383 / 384, 5);
624
+ });
625
+ });
626
+
627
+ describe('error handling', () => {
628
+ it('should handle FTS5 not available gracefully', async () => {
629
+ // Create a mock database that doesn't support FTS5
630
+ const mockDb = new Database(':memory:');
631
+ mockDb.exec(`
632
+ CREATE TABLE memory_entries (
633
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
634
+ id TEXT UNIQUE NOT NULL,
635
+ key TEXT NOT NULL,
636
+ content TEXT NOT NULL,
637
+ namespace TEXT DEFAULT 'default',
638
+ tags TEXT DEFAULT '[]',
639
+ created_at INTEGER NOT NULL,
640
+ updated_at INTEGER NOT NULL,
641
+ last_accessed_at INTEGER NOT NULL
642
+ )
643
+ `);
644
+
645
+ // Override the FTS5 check to simulate unavailability
646
+ const noFtsEngine = new HybridSearchEngine(mockDb, { fallbackToLike: true });
647
+
648
+ // Insert data before initializing (simulating no FTS5)
649
+ const now = Date.now();
650
+ mockDb.prepare(
651
+ `INSERT INTO memory_entries (id, key, content, namespace, tags, created_at, updated_at, last_accessed_at)
652
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?)`
653
+ ).run('nofts-1', 'fallback-test', 'Testing LIKE fallback search', 'default', now, now, now);
654
+
655
+ await noFtsEngine.initialize();
656
+
657
+ // Should still work via LIKE fallback
658
+ const results = await noFtsEngine.searchCompact('fallback', { includeSemantic: false });
659
+ expect(results.length).toBeGreaterThan(0);
660
+
661
+ mockDb.close();
662
+ });
663
+
664
+ it('should handle missing timeline entries', async () => {
665
+ const timeline = await engine.searchTimeline(['nonexistent-id']);
666
+ expect(timeline.length).toBe(0);
667
+ });
668
+ });
669
+ });