@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,1000 @@
1
+ /**
2
+ * BetterSqlite3Backend - Native SQLite with FTS5 Trigram for CJK Support
3
+ *
4
+ * Production-grade backend using better-sqlite3 (native SQLite).
5
+ * Provides:
6
+ * - FTS5 with trigram tokenizer for CJK (Japanese, Chinese, Korean)
7
+ * - BM25 ranking for relevance scoring
8
+ * - 10x faster than sql.js for large datasets
9
+ * - Proper word segmentation for all languages
10
+ *
11
+ * Requires:
12
+ * - Node.js environment (no browser support)
13
+ * - npm install better-sqlite3
14
+ *
15
+ * @module @agentkits/memory/better-sqlite3-backend
16
+ */
17
+
18
+ import { EventEmitter } from 'node:events';
19
+ import { existsSync, mkdirSync } from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import type Database from 'better-sqlite3';
22
+ import {
23
+ IMemoryBackend,
24
+ MemoryEntry,
25
+ MemoryEntryUpdate,
26
+ MemoryQuery,
27
+ SearchOptions,
28
+ SearchResult,
29
+ BackendStats,
30
+ HealthCheckResult,
31
+ ComponentHealth,
32
+ MemoryType,
33
+ EmbeddingGenerator,
34
+ generateMemoryId,
35
+ } from './types.js';
36
+
37
+ /**
38
+ * Configuration for BetterSqlite3 Backend
39
+ */
40
+ export interface BetterSqlite3BackendConfig {
41
+ /** Path to SQLite database file (:memory: for in-memory) */
42
+ databasePath: string;
43
+
44
+ /** Enable query optimization and WAL mode */
45
+ optimize: boolean;
46
+
47
+ /** Default namespace */
48
+ defaultNamespace: string;
49
+
50
+ /** Embedding generator for semantic search */
51
+ embeddingGenerator?: EmbeddingGenerator;
52
+
53
+ /** Maximum entries before auto-cleanup */
54
+ maxEntries: number;
55
+
56
+ /** Enable verbose logging */
57
+ verbose: boolean;
58
+
59
+ /**
60
+ * FTS5 tokenizer to use
61
+ * - 'trigram': Best for CJK (Japanese, Chinese, Korean) - works with all languages
62
+ * - 'unicode61': Standard tokenizer, good for English/Latin
63
+ * - 'porter': Stemming for English
64
+ */
65
+ ftsTokenizer: 'trigram' | 'unicode61' | 'porter';
66
+
67
+ /** Path to custom SQLite extension (e.g., lindera for advanced Japanese) */
68
+ extensionPath?: string;
69
+
70
+ /** Custom tokenizer name when using extension (e.g., 'lindera_tokenizer') */
71
+ customTokenizer?: string;
72
+ }
73
+
74
+ /**
75
+ * Default configuration values
76
+ */
77
+ const DEFAULT_CONFIG: BetterSqlite3BackendConfig = {
78
+ databasePath: ':memory:',
79
+ optimize: true,
80
+ defaultNamespace: 'default',
81
+ maxEntries: 1000000,
82
+ verbose: false,
83
+ ftsTokenizer: 'trigram', // Best for CJK out of the box
84
+ };
85
+
86
+ /**
87
+ * Estimate tokens from content (~4 chars per token)
88
+ */
89
+ function estimateTokens(text: string): number {
90
+ return Math.ceil(text.length / 4);
91
+ }
92
+
93
+ /**
94
+ * BetterSqlite3 Backend for Production Memory Storage
95
+ *
96
+ * Features:
97
+ * - Native SQLite performance (10x faster than sql.js)
98
+ * - FTS5 with trigram tokenizer for CJK language support
99
+ * - BM25 relevance ranking
100
+ * - WAL mode for concurrent reads
101
+ * - Optional extension loading (lindera, ICU, etc.)
102
+ */
103
+ export class BetterSqlite3Backend extends EventEmitter implements IMemoryBackend {
104
+ private config: BetterSqlite3BackendConfig;
105
+ private db: Database.Database | null = null;
106
+ private initialized: boolean = false;
107
+ private ftsAvailable: boolean = false;
108
+
109
+ // Performance tracking
110
+ private stats = {
111
+ queryCount: 0,
112
+ totalQueryTime: 0,
113
+ writeCount: 0,
114
+ totalWriteTime: 0,
115
+ };
116
+
117
+ constructor(config: Partial<BetterSqlite3BackendConfig> = {}) {
118
+ super();
119
+ this.config = { ...DEFAULT_CONFIG, ...config };
120
+ }
121
+
122
+ /**
123
+ * Initialize the BetterSqlite3 backend
124
+ */
125
+ async initialize(): Promise<void> {
126
+ if (this.initialized) return;
127
+
128
+ // Dynamic import for better-sqlite3 (optional dependency)
129
+ let DatabaseConstructor: typeof import('better-sqlite3');
130
+ try {
131
+ DatabaseConstructor = (await import('better-sqlite3')).default;
132
+ } catch (error) {
133
+ throw new Error(
134
+ 'better-sqlite3 is not installed. ' +
135
+ 'For production CJK support, run: npm install better-sqlite3'
136
+ );
137
+ }
138
+
139
+ // Ensure directory exists for file-based databases
140
+ if (this.config.databasePath !== ':memory:') {
141
+ const dir = path.dirname(this.config.databasePath);
142
+ if (dir && !existsSync(dir)) {
143
+ mkdirSync(dir, { recursive: true });
144
+ }
145
+ }
146
+
147
+ // Create database
148
+ this.db = new DatabaseConstructor(this.config.databasePath);
149
+
150
+ if (this.config.verbose) {
151
+ const versionRow = this.db.prepare('SELECT sqlite_version() as version').get() as { version: string };
152
+ console.log(`[BetterSqlite3] Opened database: ${this.config.databasePath}`);
153
+ console.log(`[BetterSqlite3] SQLite version: ${versionRow.version}`);
154
+ }
155
+
156
+ // Enable optimizations
157
+ if (this.config.optimize) {
158
+ this.db.pragma('journal_mode = WAL');
159
+ this.db.pragma('synchronous = NORMAL');
160
+ this.db.pragma('cache_size = -64000'); // 64MB cache
161
+ this.db.pragma('temp_store = MEMORY');
162
+ }
163
+
164
+ // Load custom extension if provided
165
+ if (this.config.extensionPath) {
166
+ try {
167
+ this.db.loadExtension(this.config.extensionPath);
168
+ if (this.config.verbose) {
169
+ console.log(`[BetterSqlite3] Loaded extension: ${this.config.extensionPath}`);
170
+ }
171
+ } catch (error) {
172
+ console.warn(`[BetterSqlite3] Failed to load extension: ${error}`);
173
+ }
174
+ }
175
+
176
+ // Create schema
177
+ this.createSchema();
178
+
179
+ // Create FTS5 table with appropriate tokenizer
180
+ this.createFtsTable();
181
+
182
+ this.initialized = true;
183
+ this.emit('initialized');
184
+
185
+ if (this.config.verbose) {
186
+ console.log('[BetterSqlite3] Backend initialized');
187
+ console.log(`[BetterSqlite3] FTS5 available: ${this.ftsAvailable}`);
188
+ console.log(`[BetterSqlite3] Tokenizer: ${this.getActiveTokenizer()}`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Create the database schema
194
+ */
195
+ private createSchema(): void {
196
+ if (!this.db) throw new Error('Database not initialized');
197
+
198
+ this.db.exec(`
199
+ CREATE TABLE IF NOT EXISTS memory_entries (
200
+ id TEXT PRIMARY KEY,
201
+ key TEXT NOT NULL,
202
+ content TEXT NOT NULL,
203
+ type TEXT DEFAULT 'semantic',
204
+ namespace TEXT DEFAULT 'default',
205
+ tags TEXT DEFAULT '[]',
206
+ metadata TEXT DEFAULT '{}',
207
+ embedding BLOB,
208
+ session_id TEXT,
209
+ owner_id TEXT,
210
+ access_level TEXT DEFAULT 'project',
211
+ created_at INTEGER NOT NULL,
212
+ updated_at INTEGER NOT NULL,
213
+ expires_at INTEGER,
214
+ version INTEGER DEFAULT 1,
215
+ "references" TEXT DEFAULT '[]',
216
+ access_count INTEGER DEFAULT 0,
217
+ last_accessed_at INTEGER NOT NULL
218
+ )
219
+ `);
220
+
221
+ // Create indexes
222
+ this.db.exec(`
223
+ CREATE INDEX IF NOT EXISTS idx_namespace ON memory_entries(namespace);
224
+ CREATE INDEX IF NOT EXISTS idx_key ON memory_entries(key);
225
+ CREATE INDEX IF NOT EXISTS idx_type ON memory_entries(type);
226
+ CREATE INDEX IF NOT EXISTS idx_created_at ON memory_entries(created_at);
227
+ CREATE INDEX IF NOT EXISTS idx_namespace_key ON memory_entries(namespace, key);
228
+ `);
229
+ }
230
+
231
+ /**
232
+ * Create FTS5 virtual table with appropriate tokenizer
233
+ */
234
+ private createFtsTable(): void {
235
+ if (!this.db) throw new Error('Database not initialized');
236
+
237
+ // Determine tokenizer configuration
238
+ let tokenizerConfig: string;
239
+ if (this.config.customTokenizer) {
240
+ tokenizerConfig = `tokenize='${this.config.customTokenizer}'`;
241
+ } else {
242
+ switch (this.config.ftsTokenizer) {
243
+ case 'trigram':
244
+ tokenizerConfig = "tokenize='trigram'";
245
+ break;
246
+ case 'porter':
247
+ tokenizerConfig = "tokenize='porter unicode61'";
248
+ break;
249
+ default:
250
+ tokenizerConfig = "tokenize='unicode61'";
251
+ }
252
+ }
253
+
254
+ try {
255
+ // Create FTS5 virtual table
256
+ this.db.exec(`
257
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
258
+ key,
259
+ content,
260
+ namespace,
261
+ tags,
262
+ content=memory_entries,
263
+ content_rowid=rowid,
264
+ ${tokenizerConfig}
265
+ )
266
+ `);
267
+
268
+ // Create triggers to keep FTS in sync
269
+ this.db.exec(`
270
+ CREATE TRIGGER IF NOT EXISTS memory_fts_insert AFTER INSERT ON memory_entries BEGIN
271
+ INSERT INTO memory_fts(rowid, key, content, namespace, tags)
272
+ VALUES (NEW.rowid, NEW.key, NEW.content, NEW.namespace, NEW.tags);
273
+ END
274
+ `);
275
+
276
+ this.db.exec(`
277
+ CREATE TRIGGER IF NOT EXISTS memory_fts_delete AFTER DELETE ON memory_entries BEGIN
278
+ INSERT INTO memory_fts(memory_fts, rowid, key, content, namespace, tags)
279
+ VALUES ('delete', OLD.rowid, OLD.key, OLD.content, OLD.namespace, OLD.tags);
280
+ END
281
+ `);
282
+
283
+ this.db.exec(`
284
+ CREATE TRIGGER IF NOT EXISTS memory_fts_update AFTER UPDATE ON memory_entries BEGIN
285
+ INSERT INTO memory_fts(memory_fts, rowid, key, content, namespace, tags)
286
+ VALUES ('delete', OLD.rowid, OLD.key, OLD.content, OLD.namespace, OLD.tags);
287
+ INSERT INTO memory_fts(rowid, key, content, namespace, tags)
288
+ VALUES (NEW.rowid, NEW.key, NEW.content, NEW.namespace, NEW.tags);
289
+ END
290
+ `);
291
+
292
+ // Populate FTS from existing entries
293
+ this.db.exec(`
294
+ INSERT INTO memory_fts(memory_fts) VALUES('rebuild')
295
+ `);
296
+
297
+ this.ftsAvailable = true;
298
+
299
+ if (this.config.verbose) {
300
+ console.log(`[BetterSqlite3] FTS5 initialized with ${tokenizerConfig}`);
301
+ }
302
+ } catch (error) {
303
+ console.warn(`[BetterSqlite3] FTS5 initialization failed: ${error}`);
304
+ this.ftsAvailable = false;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Get the active tokenizer being used
310
+ */
311
+ getActiveTokenizer(): string {
312
+ if (this.config.customTokenizer) {
313
+ return this.config.customTokenizer;
314
+ }
315
+ return this.config.ftsTokenizer;
316
+ }
317
+
318
+ /**
319
+ * Check if FTS5 is available and CJK optimized
320
+ */
321
+ isFtsAvailable(): boolean {
322
+ return this.ftsAvailable;
323
+ }
324
+
325
+ /**
326
+ * Check if CJK is optimally supported (trigram or lindera)
327
+ */
328
+ isCjkOptimized(): boolean {
329
+ return this.ftsAvailable && (
330
+ this.config.ftsTokenizer === 'trigram' ||
331
+ this.config.customTokenizer?.includes('lindera') === true
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Shutdown the backend
337
+ */
338
+ async shutdown(): Promise<void> {
339
+ if (this.db) {
340
+ this.db.close();
341
+ this.db = null;
342
+ }
343
+ this.initialized = false;
344
+ this.emit('shutdown');
345
+ }
346
+
347
+ /**
348
+ * Store a memory entry
349
+ */
350
+ async store(entry: MemoryEntry): Promise<void> {
351
+ if (!this.db) throw new Error('Database not initialized');
352
+
353
+ const startTime = Date.now();
354
+
355
+ const stmt = this.db.prepare(`
356
+ INSERT OR REPLACE INTO memory_entries
357
+ (id, key, content, type, namespace, tags, metadata, embedding, session_id,
358
+ owner_id, access_level, created_at, updated_at, expires_at, version,
359
+ "references", access_count, last_accessed_at)
360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
361
+ `);
362
+
363
+ stmt.run(
364
+ entry.id,
365
+ entry.key,
366
+ entry.content,
367
+ entry.type,
368
+ entry.namespace,
369
+ JSON.stringify(entry.tags),
370
+ JSON.stringify(entry.metadata),
371
+ entry.embedding ? Buffer.from(entry.embedding.buffer) : null,
372
+ entry.sessionId || null,
373
+ entry.ownerId || null,
374
+ entry.accessLevel,
375
+ entry.createdAt,
376
+ entry.updatedAt,
377
+ entry.expiresAt || null,
378
+ entry.version,
379
+ JSON.stringify(entry.references),
380
+ entry.accessCount,
381
+ entry.lastAccessedAt
382
+ );
383
+
384
+ const duration = Date.now() - startTime;
385
+ this.stats.writeCount++;
386
+ this.stats.totalWriteTime += duration;
387
+ this.emit('entry:stored', { entry, duration });
388
+ }
389
+
390
+ /**
391
+ * Retrieve a memory entry by ID
392
+ */
393
+ async get(id: string): Promise<MemoryEntry | null> {
394
+ if (!this.db) throw new Error('Database not initialized');
395
+
396
+ const startTime = Date.now();
397
+ const stmt = this.db.prepare('SELECT * FROM memory_entries WHERE id = ?');
398
+ const row = stmt.get(id) as Record<string, unknown> | undefined;
399
+
400
+ this.stats.queryCount++;
401
+ this.stats.totalQueryTime += Date.now() - startTime;
402
+
403
+ if (!row) return null;
404
+
405
+ // Update access count
406
+ this.db.prepare(
407
+ 'UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?'
408
+ ).run(Date.now(), id);
409
+
410
+ return this.rowToEntry(row);
411
+ }
412
+
413
+ /**
414
+ * Retrieve a memory entry by key within a namespace
415
+ */
416
+ async getByKey(namespace: string, key: string): Promise<MemoryEntry | null> {
417
+ if (!this.db) throw new Error('Database not initialized');
418
+
419
+ const stmt = this.db.prepare(
420
+ 'SELECT * FROM memory_entries WHERE namespace = ? AND key = ?'
421
+ );
422
+ const row = stmt.get(namespace, key) as Record<string, unknown> | undefined;
423
+
424
+ if (!row) return null;
425
+
426
+ // Update access count
427
+ this.db.prepare(
428
+ 'UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?'
429
+ ).run(Date.now(), row.id);
430
+
431
+ return this.rowToEntry(row);
432
+ }
433
+
434
+ /**
435
+ * Update a memory entry
436
+ */
437
+ async update(id: string, update: MemoryEntryUpdate): Promise<MemoryEntry | null> {
438
+ if (!this.db) throw new Error('Database not initialized');
439
+
440
+ const existing = await this.get(id);
441
+ if (!existing) return null;
442
+
443
+ const updated: MemoryEntry = {
444
+ ...existing,
445
+ ...update,
446
+ updatedAt: Date.now(),
447
+ version: existing.version + 1,
448
+ };
449
+
450
+ await this.store(updated);
451
+ return updated;
452
+ }
453
+
454
+ /**
455
+ * Delete a memory entry
456
+ */
457
+ async delete(id: string): Promise<boolean> {
458
+ if (!this.db) throw new Error('Database not initialized');
459
+
460
+ const result = this.db.prepare('DELETE FROM memory_entries WHERE id = ?').run(id);
461
+ return result.changes > 0;
462
+ }
463
+
464
+ /**
465
+ * Query memory entries
466
+ */
467
+ async query(query: MemoryQuery): Promise<MemoryEntry[]> {
468
+ if (!this.db) throw new Error('Database not initialized');
469
+
470
+ const startTime = Date.now();
471
+ const conditions: string[] = [];
472
+ const params: unknown[] = [];
473
+
474
+ if (query.namespace) {
475
+ conditions.push('namespace = ?');
476
+ params.push(query.namespace);
477
+ }
478
+
479
+ if (query.memoryType) {
480
+ conditions.push('type = ?');
481
+ params.push(query.memoryType);
482
+ }
483
+
484
+ if (query.tags && query.tags.length > 0) {
485
+ const tagConditions = query.tags.map(() => 'tags LIKE ?');
486
+ conditions.push(`(${tagConditions.join(' OR ')})`);
487
+ query.tags.forEach((tag) => params.push(`%"${tag}"%`));
488
+ }
489
+
490
+ if (query.createdBefore) {
491
+ conditions.push('created_at < ?');
492
+ params.push(query.createdBefore);
493
+ }
494
+
495
+ if (query.createdAfter) {
496
+ conditions.push('created_at > ?');
497
+ params.push(query.createdAfter);
498
+ }
499
+
500
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
501
+ const limit = query.limit || 100;
502
+
503
+ const sql = `
504
+ SELECT * FROM memory_entries
505
+ ${whereClause}
506
+ ORDER BY created_at DESC
507
+ LIMIT ?
508
+ `;
509
+ params.push(limit);
510
+
511
+ const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
512
+
513
+ this.stats.queryCount++;
514
+ this.stats.totalQueryTime += Date.now() - startTime;
515
+
516
+ return rows.map((row) => this.rowToEntry(row));
517
+ }
518
+
519
+ /**
520
+ * Full-text search using FTS5
521
+ */
522
+ async searchFts(query: string, options: { namespace?: string; limit?: number } = {}): Promise<MemoryEntry[]> {
523
+ if (!this.db) throw new Error('Database not initialized');
524
+
525
+ if (!this.ftsAvailable) {
526
+ // Fall back to LIKE search
527
+ return this.searchLike(query, options);
528
+ }
529
+
530
+ const limit = options.limit || 100;
531
+ const params: (string | number)[] = [];
532
+
533
+ // Sanitize query for FTS5
534
+ const sanitizedQuery = this.sanitizeFtsQuery(query);
535
+ if (!sanitizedQuery) return [];
536
+
537
+ let sql: string;
538
+ if (options.namespace) {
539
+ sql = `
540
+ SELECT m.*, bm25(memory_fts) as rank
541
+ FROM memory_fts f
542
+ JOIN memory_entries m ON f.rowid = m.rowid
543
+ WHERE memory_fts MATCH ? AND m.namespace = ?
544
+ ORDER BY rank
545
+ LIMIT ?
546
+ `;
547
+ params.push(sanitizedQuery, options.namespace, limit);
548
+ } else {
549
+ sql = `
550
+ SELECT m.*, bm25(memory_fts) as rank
551
+ FROM memory_fts f
552
+ JOIN memory_entries m ON f.rowid = m.rowid
553
+ WHERE memory_fts MATCH ?
554
+ ORDER BY rank
555
+ LIMIT ?
556
+ `;
557
+ params.push(sanitizedQuery, limit);
558
+ }
559
+
560
+ try {
561
+ const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
562
+ return rows.map((row) => this.rowToEntry(row));
563
+ } catch (error) {
564
+ // Fall back to LIKE on error
565
+ console.warn(`[BetterSqlite3] FTS5 search failed, falling back to LIKE: ${error}`);
566
+ return this.searchLike(query, options);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * LIKE-based search fallback
572
+ */
573
+ private searchLike(query: string, options: { namespace?: string; limit?: number }): MemoryEntry[] {
574
+ if (!this.db) throw new Error('Database not initialized');
575
+
576
+ const pattern = `%${query}%`;
577
+ const limit = options.limit || 100;
578
+
579
+ let sql: string;
580
+ const params: (string | number)[] = [];
581
+
582
+ if (options.namespace) {
583
+ sql = `
584
+ SELECT * FROM memory_entries
585
+ WHERE (content LIKE ? OR key LIKE ? OR tags LIKE ?) AND namespace = ?
586
+ ORDER BY created_at DESC
587
+ LIMIT ?
588
+ `;
589
+ params.push(pattern, pattern, pattern, options.namespace, limit);
590
+ } else {
591
+ sql = `
592
+ SELECT * FROM memory_entries
593
+ WHERE content LIKE ? OR key LIKE ? OR tags LIKE ?
594
+ ORDER BY created_at DESC
595
+ LIMIT ?
596
+ `;
597
+ params.push(pattern, pattern, pattern, limit);
598
+ }
599
+
600
+ const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
601
+ return rows.map((row) => this.rowToEntry(row));
602
+ }
603
+
604
+ /**
605
+ * Sanitize query for FTS5
606
+ */
607
+ private sanitizeFtsQuery(query: string): string {
608
+ // Remove special FTS5 operators and wrap terms in quotes
609
+ return query
610
+ .replace(/[^\w\s\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u3400-\u4DBF]/g, ' ')
611
+ .split(/\s+/)
612
+ .filter((term) => term.length > 0)
613
+ .map((term) => `"${term}"`)
614
+ .join(' OR ');
615
+ }
616
+
617
+ /**
618
+ * Semantic vector search
619
+ */
620
+ async search(embedding: Float32Array, options: SearchOptions): Promise<SearchResult[]> {
621
+ if (!this.db) throw new Error('Database not initialized');
622
+
623
+ const startTime = Date.now();
624
+
625
+ // Get all entries with embeddings
626
+ let sql = `
627
+ SELECT * FROM memory_entries
628
+ WHERE embedding IS NOT NULL
629
+ `;
630
+ const params: unknown[] = [];
631
+
632
+ // Apply filters if provided
633
+ if (options.filters?.namespace) {
634
+ sql += ' AND namespace = ?';
635
+ params.push(options.filters.namespace);
636
+ }
637
+
638
+ if (options.filters?.memoryType) {
639
+ sql += ' AND type = ?';
640
+ params.push(options.filters.memoryType);
641
+ }
642
+
643
+ const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
644
+
645
+ // Calculate cosine similarity
646
+ const results: SearchResult[] = rows
647
+ .map((row) => {
648
+ const entry = this.rowToEntry(row);
649
+ if (!entry.embedding) return null;
650
+
651
+ const similarity = this.cosineSimilarity(embedding, entry.embedding);
652
+ const distance = 1 - similarity;
653
+
654
+ // Apply threshold (similarity threshold, not distance)
655
+ if (options.threshold !== undefined && similarity < options.threshold) {
656
+ return null;
657
+ }
658
+
659
+ return { entry, score: similarity, distance };
660
+ })
661
+ .filter((r): r is SearchResult => r !== null);
662
+
663
+ // Sort by score and limit
664
+ results.sort((a, b) => b.score - a.score);
665
+
666
+ this.stats.queryCount++;
667
+ this.stats.totalQueryTime += Date.now() - startTime;
668
+
669
+ return results.slice(0, options.k);
670
+ }
671
+
672
+ /**
673
+ * Calculate cosine similarity between two vectors
674
+ */
675
+ private cosineSimilarity(a: Float32Array, b: Float32Array): number {
676
+ if (a.length !== b.length) return 0;
677
+
678
+ let dotProduct = 0;
679
+ let normA = 0;
680
+ let normB = 0;
681
+
682
+ for (let i = 0; i < a.length; i++) {
683
+ dotProduct += a[i] * b[i];
684
+ normA += a[i] * a[i];
685
+ normB += b[i] * b[i];
686
+ }
687
+
688
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
689
+ return denominator === 0 ? 0 : dotProduct / denominator;
690
+ }
691
+
692
+ /**
693
+ * Bulk insert entries
694
+ */
695
+ async bulkInsert(entries: MemoryEntry[]): Promise<void> {
696
+ if (!this.db) throw new Error('Database not initialized');
697
+ if (entries.length === 0) return;
698
+
699
+ const stmt = this.db.prepare(`
700
+ INSERT OR REPLACE INTO memory_entries
701
+ (id, key, content, type, namespace, tags, metadata, embedding, session_id,
702
+ owner_id, access_level, created_at, updated_at, expires_at, version,
703
+ "references", access_count, last_accessed_at)
704
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
705
+ `);
706
+
707
+ const insert = this.db.transaction((entries: MemoryEntry[]) => {
708
+ for (const entry of entries) {
709
+ stmt.run(
710
+ entry.id,
711
+ entry.key,
712
+ entry.content,
713
+ entry.type,
714
+ entry.namespace,
715
+ JSON.stringify(entry.tags),
716
+ JSON.stringify(entry.metadata),
717
+ entry.embedding ? Buffer.from(entry.embedding.buffer) : null,
718
+ entry.sessionId || null,
719
+ entry.ownerId || null,
720
+ entry.accessLevel,
721
+ entry.createdAt,
722
+ entry.updatedAt,
723
+ entry.expiresAt || null,
724
+ entry.version,
725
+ JSON.stringify(entry.references),
726
+ entry.accessCount,
727
+ entry.lastAccessedAt
728
+ );
729
+ }
730
+ });
731
+
732
+ insert(entries);
733
+ this.emit('bulkInserted', entries.length);
734
+ }
735
+
736
+ /**
737
+ * Bulk delete entries
738
+ */
739
+ async bulkDelete(ids: string[]): Promise<number> {
740
+ if (!this.db) throw new Error('Database not initialized');
741
+ if (ids.length === 0) return 0;
742
+
743
+ const placeholders = ids.map(() => '?').join(', ');
744
+ const result = this.db
745
+ .prepare(`DELETE FROM memory_entries WHERE id IN (${placeholders})`)
746
+ .run(...ids);
747
+
748
+ return result.changes;
749
+ }
750
+
751
+ /**
752
+ * Get entry count
753
+ */
754
+ async count(namespace?: string): Promise<number> {
755
+ if (!this.db) throw new Error('Database not initialized');
756
+
757
+ if (namespace) {
758
+ const result = this.db
759
+ .prepare('SELECT COUNT(*) as count FROM memory_entries WHERE namespace = ?')
760
+ .get(namespace) as { count: number };
761
+ return result.count;
762
+ }
763
+
764
+ const result = this.db
765
+ .prepare('SELECT COUNT(*) as count FROM memory_entries')
766
+ .get() as { count: number };
767
+ return result.count;
768
+ }
769
+
770
+ /**
771
+ * List all namespaces
772
+ */
773
+ async listNamespaces(): Promise<string[]> {
774
+ if (!this.db) throw new Error('Database not initialized');
775
+
776
+ const rows = this.db
777
+ .prepare('SELECT DISTINCT namespace FROM memory_entries')
778
+ .all() as { namespace: string }[];
779
+
780
+ return rows.map((r) => r.namespace);
781
+ }
782
+
783
+ /**
784
+ * Clear all entries in a namespace
785
+ */
786
+ async clearNamespace(namespace: string): Promise<number> {
787
+ if (!this.db) throw new Error('Database not initialized');
788
+
789
+ const result = this.db
790
+ .prepare('DELETE FROM memory_entries WHERE namespace = ?')
791
+ .run(namespace);
792
+
793
+ return result.changes;
794
+ }
795
+
796
+ /**
797
+ * Get backend statistics
798
+ */
799
+ async getStats(): Promise<BackendStats> {
800
+ if (!this.db) throw new Error('Database not initialized');
801
+
802
+ const totalEntries = await this.count();
803
+ const namespaces = await this.listNamespaces();
804
+
805
+ const entriesByNamespace: Record<string, number> = {};
806
+ for (const ns of namespaces) {
807
+ entriesByNamespace[ns] = await this.count(ns);
808
+ }
809
+
810
+ // Get database size
811
+ const pageCount = this.db.pragma('page_count', { simple: true }) as number;
812
+ const pageSize = this.db.pragma('page_size', { simple: true }) as number;
813
+ const dbSizeBytes = pageCount * pageSize;
814
+
815
+ // Get type breakdown
816
+ const typeRows = this.db.prepare(`
817
+ SELECT type, COUNT(*) as count FROM memory_entries GROUP BY type
818
+ `).all() as { type: MemoryType; count: number }[];
819
+
820
+ const entriesByType: Record<MemoryType, number> = {
821
+ episodic: 0,
822
+ semantic: 0,
823
+ procedural: 0,
824
+ working: 0,
825
+ cache: 0,
826
+ };
827
+ for (const row of typeRows) {
828
+ entriesByType[row.type] = row.count;
829
+ }
830
+
831
+ return {
832
+ totalEntries,
833
+ entriesByNamespace,
834
+ entriesByType,
835
+ memoryUsage: dbSizeBytes,
836
+ avgQueryTime: this.stats.queryCount > 0 ? this.stats.totalQueryTime / this.stats.queryCount : 0,
837
+ avgSearchTime: this.stats.queryCount > 0 ? this.stats.totalQueryTime / this.stats.queryCount : 0,
838
+ };
839
+ }
840
+
841
+ /**
842
+ * Perform health check
843
+ */
844
+ async healthCheck(): Promise<HealthCheckResult> {
845
+ const issues: string[] = [];
846
+ const recommendations: string[] = [];
847
+
848
+ // Check database (storage)
849
+ let storageHealth: ComponentHealth;
850
+ const storageStart = Date.now();
851
+ try {
852
+ if (this.db) {
853
+ this.db.prepare('SELECT 1').get();
854
+ storageHealth = {
855
+ status: 'healthy',
856
+ latency: Date.now() - storageStart,
857
+ };
858
+ } else {
859
+ storageHealth = {
860
+ status: 'unhealthy',
861
+ latency: 0,
862
+ message: 'Database not initialized',
863
+ };
864
+ issues.push('Database not initialized');
865
+ }
866
+ } catch (error) {
867
+ storageHealth = {
868
+ status: 'unhealthy',
869
+ latency: Date.now() - storageStart,
870
+ message: String(error),
871
+ };
872
+ issues.push(`Database error: ${error}`);
873
+ }
874
+
875
+ // Check FTS5 (index)
876
+ const indexHealth: ComponentHealth = {
877
+ status: this.ftsAvailable ? 'healthy' : 'degraded',
878
+ latency: 0,
879
+ message: this.ftsAvailable
880
+ ? `Tokenizer: ${this.getActiveTokenizer()}`
881
+ : 'FTS5 not available, using LIKE fallback',
882
+ };
883
+ if (!this.ftsAvailable) {
884
+ recommendations.push('Enable FTS5 for better search performance');
885
+ }
886
+
887
+ // Check CJK optimization (cache - repurpose for CJK status)
888
+ const cacheHealth: ComponentHealth = {
889
+ status: this.isCjkOptimized() ? 'healthy' : 'degraded',
890
+ latency: 0,
891
+ message: this.isCjkOptimized()
892
+ ? 'Trigram tokenizer active for CJK'
893
+ : 'CJK using fallback search',
894
+ };
895
+ if (!this.isCjkOptimized()) {
896
+ recommendations.push('Use trigram tokenizer for proper CJK (Japanese/Chinese/Korean) support');
897
+ }
898
+
899
+ // Determine overall status
900
+ let status: 'healthy' | 'degraded' | 'unhealthy';
901
+ if (storageHealth.status === 'unhealthy') {
902
+ status = 'unhealthy';
903
+ } else if (indexHealth.status === 'degraded' || cacheHealth.status === 'degraded') {
904
+ status = 'degraded';
905
+ } else {
906
+ status = 'healthy';
907
+ }
908
+
909
+ return {
910
+ status,
911
+ timestamp: Date.now(),
912
+ components: {
913
+ storage: storageHealth,
914
+ index: indexHealth,
915
+ cache: cacheHealth,
916
+ },
917
+ issues,
918
+ recommendations,
919
+ };
920
+ }
921
+
922
+ /**
923
+ * Get the underlying database for advanced operations
924
+ */
925
+ getDatabase(): Database.Database | null {
926
+ return this.db;
927
+ }
928
+
929
+ /**
930
+ * Rebuild FTS index
931
+ */
932
+ async rebuildFtsIndex(): Promise<void> {
933
+ if (!this.db || !this.ftsAvailable) return;
934
+
935
+ this.db.exec("INSERT INTO memory_fts(memory_fts) VALUES('rebuild')");
936
+
937
+ if (this.config.verbose) {
938
+ console.log('[BetterSqlite3] FTS index rebuilt');
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Convert database row to MemoryEntry
944
+ */
945
+ private rowToEntry(row: Record<string, unknown>): MemoryEntry {
946
+ let embedding: Float32Array | undefined;
947
+ if (row.embedding) {
948
+ const buffer = row.embedding as Buffer;
949
+ embedding = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4);
950
+ }
951
+
952
+ return {
953
+ id: row.id as string,
954
+ key: row.key as string,
955
+ content: row.content as string,
956
+ embedding,
957
+ type: row.type as MemoryType,
958
+ namespace: row.namespace as string,
959
+ tags: JSON.parse((row.tags as string) || '[]'),
960
+ metadata: JSON.parse((row.metadata as string) || '{}'),
961
+ sessionId: row.session_id as string | undefined,
962
+ ownerId: row.owner_id as string | undefined,
963
+ accessLevel: row.access_level as MemoryEntry['accessLevel'],
964
+ createdAt: row.created_at as number,
965
+ updatedAt: row.updated_at as number,
966
+ expiresAt: row.expires_at as number | undefined,
967
+ version: row.version as number,
968
+ references: JSON.parse((row.references as string) || '[]'),
969
+ accessCount: row.access_count as number,
970
+ lastAccessedAt: row.last_accessed_at as number,
971
+ };
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Create a BetterSqlite3 backend with default CJK support
977
+ */
978
+ export function createBetterSqlite3Backend(
979
+ config?: Partial<BetterSqlite3BackendConfig>
980
+ ): BetterSqlite3Backend {
981
+ return new BetterSqlite3Backend({
982
+ ...config,
983
+ ftsTokenizer: config?.ftsTokenizer || 'trigram', // Default to trigram for CJK
984
+ });
985
+ }
986
+
987
+ /**
988
+ * Create a BetterSqlite3 backend with lindera extension for advanced Japanese
989
+ */
990
+ export function createJapaneseOptimizedBackend(
991
+ config: Partial<BetterSqlite3BackendConfig> & { linderaPath: string }
992
+ ): BetterSqlite3Backend {
993
+ return new BetterSqlite3Backend({
994
+ ...config,
995
+ extensionPath: config.linderaPath,
996
+ customTokenizer: 'lindera_tokenizer',
997
+ });
998
+ }
999
+
1000
+ export default BetterSqlite3Backend;