/** * BetterSqlite3Backend - Native SQLite with FTS5 Trigram and sqlite-vec * * Production-grade backend using better-sqlite3 (native SQLite). * Provides: * - FTS5 with trigram tokenizer for CJK (Japanese, Chinese, Korean) * - BM25 ranking for relevance scoring * - sqlite-vec for persisted vector search (no rebuild on startup) * - 10x faster than sql.js for large datasets * - Proper word segmentation for all languages * * Requires: * - Node.js environment (no browser support) * - npm install better-sqlite3 sqlite-vec * * @module @agentkits/memory/better-sqlite3-backend */ import { EventEmitter } from 'node:events'; import { existsSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import type Database from 'better-sqlite3'; import { IMemoryBackend, MemoryEntry, MemoryEntryUpdate, MemoryQuery, SearchOptions, SearchResult, BackendStats, HealthCheckResult, ComponentHealth, MemoryType, EmbeddingGenerator, generateMemoryId, } from './types.js'; /** * Configuration for BetterSqlite3 Backend */ export interface BetterSqlite3BackendConfig { /** Path to SQLite database file (:memory: for in-memory) */ databasePath: string; /** Enable query optimization and WAL mode */ optimize: boolean; /** Default namespace */ defaultNamespace: string; /** Embedding generator for semantic search */ embeddingGenerator?: EmbeddingGenerator; /** Maximum entries before auto-cleanup */ maxEntries: number; /** Enable verbose logging */ verbose: boolean; /** * FTS5 tokenizer to use * - 'trigram': Best for CJK (Japanese, Chinese, Korean) - works with all languages * - 'unicode61': Standard tokenizer, good for English/Latin * - 'porter': Stemming for English */ ftsTokenizer: 'trigram' | 'unicode61' | 'porter'; /** Path to custom SQLite extension (e.g., lindera for advanced Japanese) */ extensionPath?: string; /** Custom tokenizer name when using extension (e.g., 'lindera_tokenizer') */ customTokenizer?: string; /** Enable sqlite-vec for vector search (default: true) */ enableVectorSearch?: boolean; /** Vector dimensions for sqlite-vec (default: 384) */ vectorDimensions?: number; } /** * Default configuration values */ const DEFAULT_CONFIG: BetterSqlite3BackendConfig = { databasePath: ':memory:', optimize: true, defaultNamespace: 'default', maxEntries: 1000000, verbose: false, ftsTokenizer: 'trigram', // Best for CJK out of the box enableVectorSearch: true, vectorDimensions: 384, }; /** * Estimate tokens from content (~4 chars per token) */ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } /** * BetterSqlite3 Backend for Production Memory Storage * * Features: * - Native SQLite performance (10x faster than sql.js) * - FTS5 with trigram tokenizer for CJK language support * - BM25 relevance ranking * - WAL mode for concurrent reads * - Optional extension loading (lindera, ICU, etc.) */ export class BetterSqlite3Backend extends EventEmitter implements IMemoryBackend { private config: BetterSqlite3BackendConfig; private db: Database.Database | null = null; private initialized: boolean = false; private ftsAvailable: boolean = false; private vectorAvailable: boolean = false; // Performance tracking private stats = { queryCount: 0, totalQueryTime: 0, writeCount: 0, totalWriteTime: 0, }; constructor(config: Partial = {}) { super(); this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Initialize the BetterSqlite3 backend */ async initialize(): Promise { if (this.initialized) return; // Dynamic import for better-sqlite3 (optional dependency) let DatabaseConstructor: typeof import('better-sqlite3'); try { DatabaseConstructor = (await import('better-sqlite3')).default; } catch (error) { throw new Error( 'better-sqlite3 is not installed. ' + 'For production CJK support, run: npm install better-sqlite3' ); } // Ensure directory exists for file-based databases if (this.config.databasePath !== ':memory:') { const dir = path.dirname(this.config.databasePath); if (dir && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } // Create database this.db = new DatabaseConstructor(this.config.databasePath); if (this.config.verbose) { const versionRow = this.db.prepare('SELECT sqlite_version() as version').get() as { version: string }; console.log(`[BetterSqlite3] Opened database: ${this.config.databasePath}`); console.log(`[BetterSqlite3] SQLite version: ${versionRow.version}`); } // Enable optimizations if (this.config.optimize) { this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); this.db.pragma('cache_size = -64000'); // 64MB cache this.db.pragma('temp_store = MEMORY'); } // Load custom extension if provided if (this.config.extensionPath) { try { this.db.loadExtension(this.config.extensionPath); if (this.config.verbose) { console.log(`[BetterSqlite3] Loaded extension: ${this.config.extensionPath}`); } } catch (error) { console.warn(`[BetterSqlite3] Failed to load extension: ${error}`); } } // Create schema this.createSchema(); // Create FTS5 table with appropriate tokenizer this.createFtsTable(); // Load sqlite-vec extension and create vector table if (this.config.enableVectorSearch) { await this.initializeSqliteVec(); } this.initialized = true; this.emit('initialized'); if (this.config.verbose) { console.log('[BetterSqlite3] Backend initialized'); console.log(`[BetterSqlite3] FTS5 available: ${this.ftsAvailable}`); console.log(`[BetterSqlite3] Tokenizer: ${this.getActiveTokenizer()}`); } } /** * Create the database schema */ private createSchema(): void { if (!this.db) throw new Error('Database not initialized'); this.db.exec(` CREATE TABLE IF NOT EXISTS memory_entries ( id TEXT PRIMARY KEY, key TEXT NOT NULL, content TEXT NOT NULL, type TEXT DEFAULT 'semantic', namespace TEXT DEFAULT 'default', tags TEXT DEFAULT '[]', metadata TEXT DEFAULT '{}', embedding BLOB, session_id TEXT, owner_id TEXT, access_level TEXT DEFAULT 'project', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, expires_at INTEGER, version INTEGER DEFAULT 1, "references" TEXT DEFAULT '[]', access_count INTEGER DEFAULT 0, last_accessed_at INTEGER NOT NULL ) `); // Create indexes this.db.exec(` CREATE INDEX IF NOT EXISTS idx_namespace ON memory_entries(namespace); CREATE INDEX IF NOT EXISTS idx_key ON memory_entries(key); CREATE INDEX IF NOT EXISTS idx_type ON memory_entries(type); CREATE INDEX IF NOT EXISTS idx_created_at ON memory_entries(created_at); CREATE INDEX IF NOT EXISTS idx_namespace_key ON memory_entries(namespace, key); `); } /** * Create FTS5 virtual table with appropriate tokenizer */ private createFtsTable(): void { if (!this.db) throw new Error('Database not initialized'); // Determine tokenizer configuration let tokenizerConfig: string; if (this.config.customTokenizer) { tokenizerConfig = `tokenize='${this.config.customTokenizer}'`; } else { switch (this.config.ftsTokenizer) { case 'trigram': tokenizerConfig = "tokenize='trigram'"; break; case 'porter': tokenizerConfig = "tokenize='porter unicode61'"; break; default: tokenizerConfig = "tokenize='unicode61'"; } } try { // Create FTS5 virtual table this.db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( key, content, namespace, tags, content=memory_entries, content_rowid=rowid, ${tokenizerConfig} ) `); // Create triggers to keep FTS in sync this.db.exec(` CREATE TRIGGER IF NOT EXISTS memory_fts_insert AFTER INSERT ON memory_entries BEGIN INSERT INTO memory_fts(rowid, key, content, namespace, tags) VALUES (NEW.rowid, NEW.key, NEW.content, NEW.namespace, NEW.tags); END `); this.db.exec(` CREATE TRIGGER IF NOT EXISTS memory_fts_delete AFTER DELETE ON memory_entries BEGIN INSERT INTO memory_fts(memory_fts, rowid, key, content, namespace, tags) VALUES ('delete', OLD.rowid, OLD.key, OLD.content, OLD.namespace, OLD.tags); END `); this.db.exec(` CREATE TRIGGER IF NOT EXISTS memory_fts_update AFTER UPDATE ON memory_entries BEGIN INSERT INTO memory_fts(memory_fts, rowid, key, content, namespace, tags) VALUES ('delete', OLD.rowid, OLD.key, OLD.content, OLD.namespace, OLD.tags); INSERT INTO memory_fts(rowid, key, content, namespace, tags) VALUES (NEW.rowid, NEW.key, NEW.content, NEW.namespace, NEW.tags); END `); // Populate FTS from existing entries this.db.exec(` INSERT INTO memory_fts(memory_fts) VALUES('rebuild') `); this.ftsAvailable = true; if (this.config.verbose) { console.log(`[BetterSqlite3] FTS5 initialized with ${tokenizerConfig}`); } } catch (error) { console.warn(`[BetterSqlite3] FTS5 initialization failed: ${error}`); this.ftsAvailable = false; } } /** * Get the active tokenizer being used */ getActiveTokenizer(): string { if (this.config.customTokenizer) { return this.config.customTokenizer; } return this.config.ftsTokenizer; } /** * Check if FTS5 is available and CJK optimized */ isFtsAvailable(): boolean { return this.ftsAvailable; } /** * Check if CJK is optimally supported (trigram or lindera) */ isCjkOptimized(): boolean { return this.ftsAvailable && ( this.config.ftsTokenizer === 'trigram' || this.config.customTokenizer?.includes('lindera') === true ); } /** * Check if vector search is available (sqlite-vec loaded) */ isVectorAvailable(): boolean { return this.vectorAvailable; } /** * Initialize sqlite-vec extension and create vector table */ private async initializeSqliteVec(): Promise { if (!this.db) throw new Error('Database not initialized'); try { // Dynamic import for sqlite-vec const sqliteVec = await import('sqlite-vec'); // Load the extension sqliteVec.load(this.db); const dimensions = this.config.vectorDimensions || 384; // Create vec0 virtual table for vector storage // Using float32 for full precision this.db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS memory_vectors USING vec0( embedding float[${dimensions}] ) `); this.vectorAvailable = true; if (this.config.verbose) { console.log(`[BetterSqlite3] sqlite-vec initialized with ${dimensions} dimensions`); } } catch (error) { console.warn(`[BetterSqlite3] sqlite-vec initialization failed: ${error}`); console.warn('[BetterSqlite3] Falling back to brute-force vector search'); this.vectorAvailable = false; } } /** * Shutdown the backend */ async shutdown(): Promise { if (this.db) { this.db.close(); this.db = null; } this.initialized = false; this.emit('shutdown'); } /** * Store a memory entry */ async store(entry: MemoryEntry): Promise { if (!this.db) throw new Error('Database not initialized'); const startTime = Date.now(); const stmt = this.db.prepare(` INSERT OR REPLACE INTO memory_entries (id, key, content, type, namespace, tags, metadata, embedding, session_id, owner_id, access_level, created_at, updated_at, expires_at, version, "references", access_count, last_accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( entry.id, entry.key, entry.content, entry.type, entry.namespace, JSON.stringify(entry.tags), JSON.stringify(entry.metadata), entry.embedding ? Buffer.from(entry.embedding.buffer, entry.embedding.byteOffset, entry.embedding.byteLength) : null, entry.sessionId || null, entry.ownerId || null, entry.accessLevel, entry.createdAt, entry.updatedAt, entry.expiresAt || null, entry.version, JSON.stringify(entry.references), entry.accessCount, entry.lastAccessedAt ); // Store vector in sqlite-vec table if available and embedding exists if (this.vectorAvailable && entry.embedding) { // Get the rowid for the entry const rowResult = this.db.prepare('SELECT rowid FROM memory_entries WHERE id = ?').get(entry.id) as { rowid: number | bigint } | undefined; if (rowResult) { try { // Convert rowid to number (better-sqlite3 may return bigint) const rowid = Number(rowResult.rowid); // Delete existing vector if any (for REPLACE case) this.db.exec(`DELETE FROM memory_vectors WHERE rowid = ${rowid}`); // Insert new vector - sqlite-vec accepts JSON array format const vectorJson = JSON.stringify(Array.from(entry.embedding)); // Use exec with direct SQL to avoid prepared statement issues with sqlite-vec this.db.exec(`INSERT INTO memory_vectors(rowid, embedding) VALUES (${rowid}, '${vectorJson}')`); } catch (error) { // Skip vector insertion on dimension mismatch or other errors if (this.config.verbose) { console.warn(`[BetterSqlite3] Vector insertion skipped for ${entry.id}: ${error}`); } } } } const duration = Date.now() - startTime; this.stats.writeCount++; this.stats.totalWriteTime += duration; this.emit('entry:stored', { entry, duration }); } /** * Retrieve a memory entry by ID */ async get(id: string): Promise { if (!this.db) throw new Error('Database not initialized'); const startTime = Date.now(); const stmt = this.db.prepare('SELECT * FROM memory_entries WHERE id = ?'); const row = stmt.get(id) as Record | undefined; this.stats.queryCount++; this.stats.totalQueryTime += Date.now() - startTime; if (!row) return null; // Update access count this.db.prepare( 'UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?' ).run(Date.now(), id); return this.rowToEntry(row); } /** * Retrieve a memory entry by key within a namespace */ async getByKey(namespace: string, key: string): Promise { if (!this.db) throw new Error('Database not initialized'); const stmt = this.db.prepare( 'SELECT * FROM memory_entries WHERE namespace = ? AND key = ?' ); const row = stmt.get(namespace, key) as Record | undefined; if (!row) return null; // Update access count this.db.prepare( 'UPDATE memory_entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?' ).run(Date.now(), row.id); return this.rowToEntry(row); } /** * Update a memory entry */ async update(id: string, update: MemoryEntryUpdate): Promise { if (!this.db) throw new Error('Database not initialized'); const existing = await this.get(id); if (!existing) return null; const updated: MemoryEntry = { ...existing, ...update, updatedAt: Date.now(), version: existing.version + 1, }; await this.store(updated); return updated; } /** * Delete a memory entry */ async delete(id: string): Promise { if (!this.db) throw new Error('Database not initialized'); // Get rowid before deletion for vector cleanup const rowResult = this.db.prepare('SELECT rowid FROM memory_entries WHERE id = ?').get(id) as { rowid: number | bigint } | undefined; const result = this.db.prepare('DELETE FROM memory_entries WHERE id = ?').run(id); // Delete from vector table if applicable if (result.changes > 0 && this.vectorAvailable && rowResult) { this.db.prepare('DELETE FROM memory_vectors WHERE rowid = ?').run(Number(rowResult.rowid)); } return result.changes > 0; } /** * Query memory entries */ async query(query: MemoryQuery): Promise { if (!this.db) throw new Error('Database not initialized'); const startTime = Date.now(); const conditions: string[] = []; const params: unknown[] = []; if (query.namespace) { conditions.push('namespace = ?'); params.push(query.namespace); } if (query.memoryType) { conditions.push('type = ?'); params.push(query.memoryType); } if (query.tags && query.tags.length > 0) { const tagConditions = query.tags.map(() => 'tags LIKE ?'); conditions.push(`(${tagConditions.join(' OR ')})`); query.tags.forEach((tag) => params.push(`%"${tag}"%`)); } if (query.createdBefore) { conditions.push('created_at < ?'); params.push(query.createdBefore); } if (query.createdAfter) { conditions.push('created_at > ?'); params.push(query.createdAfter); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const limit = query.limit || 100; const sql = ` SELECT * FROM memory_entries ${whereClause} ORDER BY created_at DESC LIMIT ? `; params.push(limit); const rows = this.db.prepare(sql).all(...params) as Record[]; this.stats.queryCount++; this.stats.totalQueryTime += Date.now() - startTime; return rows.map((row) => this.rowToEntry(row)); } /** * Full-text search using FTS5 */ async searchFts(query: string, options: { namespace?: string; limit?: number } = {}): Promise { if (!this.db) throw new Error('Database not initialized'); if (!this.ftsAvailable) { // Fall back to LIKE search return this.searchLike(query, options); } const limit = options.limit || 100; const params: (string | number)[] = []; // Sanitize query for FTS5 const sanitizedQuery = this.sanitizeFtsQuery(query); if (!sanitizedQuery) return []; let sql: string; if (options.namespace) { sql = ` SELECT m.*, bm25(memory_fts) as rank FROM memory_fts f JOIN memory_entries m ON f.rowid = m.rowid WHERE memory_fts MATCH ? AND m.namespace = ? ORDER BY rank LIMIT ? `; params.push(sanitizedQuery, options.namespace, limit); } else { sql = ` SELECT m.*, bm25(memory_fts) as rank FROM memory_fts f JOIN memory_entries m ON f.rowid = m.rowid WHERE memory_fts MATCH ? ORDER BY rank LIMIT ? `; params.push(sanitizedQuery, limit); } try { const rows = this.db.prepare(sql).all(...params) as Record[]; return rows.map((row) => this.rowToEntry(row)); } catch (error) { // Fall back to LIKE on error console.warn(`[BetterSqlite3] FTS5 search failed, falling back to LIKE: ${error}`); return this.searchLike(query, options); } } /** * LIKE-based search fallback */ private searchLike(query: string, options: { namespace?: string; limit?: number }): MemoryEntry[] { if (!this.db) throw new Error('Database not initialized'); const pattern = `%${query}%`; const limit = options.limit || 100; let sql: string; const params: (string | number)[] = []; if (options.namespace) { sql = ` SELECT * FROM memory_entries WHERE (content LIKE ? OR key LIKE ? OR tags LIKE ?) AND namespace = ? ORDER BY created_at DESC LIMIT ? `; params.push(pattern, pattern, pattern, options.namespace, limit); } else { sql = ` SELECT * FROM memory_entries WHERE content LIKE ? OR key LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ? `; params.push(pattern, pattern, pattern, limit); } const rows = this.db.prepare(sql).all(...params) as Record[]; return rows.map((row) => this.rowToEntry(row)); } /** * Sanitize query for FTS5 */ private sanitizeFtsQuery(query: string): string { // Remove special FTS5 operators and wrap terms in quotes return query .replace(/[^\w\s\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u3400-\u4DBF]/g, ' ') .split(/\s+/) .filter((term) => term.length > 0) .map((term) => `"${term}"`) .join(' OR '); } /** * Semantic vector search using sqlite-vec (KNN) or brute-force fallback */ async search(embedding: Float32Array, options: SearchOptions): Promise { if (!this.db) throw new Error('Database not initialized'); const startTime = Date.now(); // Use sqlite-vec KNN search if available if (this.vectorAvailable) { return this.searchWithSqliteVec(embedding, options, startTime); } // Fallback to brute-force search return this.searchBruteForce(embedding, options, startTime); } /** * Fast KNN search using sqlite-vec */ private searchWithSqliteVec( embedding: Float32Array, options: SearchOptions, startTime: number ): SearchResult[] { if (!this.db) throw new Error('Database not initialized'); const k = options.k || 10; // sqlite-vec KNN query - accepts JSON array format const queryJson = JSON.stringify(Array.from(embedding)); const vecResults = this.db.prepare(` SELECT v.rowid, v.distance FROM memory_vectors v WHERE v.embedding MATCH ? ORDER BY v.distance LIMIT ? `).all(queryJson, k) as { rowid: number; distance: number }[]; // Fetch full entries and apply filters const results: SearchResult[] = []; for (const { rowid, distance } of vecResults) { const row = this.db.prepare('SELECT * FROM memory_entries WHERE rowid = ?').get(rowid) as Record | undefined; if (!row) continue; const entry = this.rowToEntry(row); // Apply namespace filter if (options.filters?.namespace && entry.namespace !== options.filters.namespace) { continue; } // Apply type filter if (options.filters?.memoryType && entry.type !== options.filters.memoryType) { continue; } // Convert distance to similarity (cosine distance = 1 - similarity) const similarity = 1 - distance; // Apply threshold if (options.threshold !== undefined && similarity < options.threshold) { continue; } results.push({ entry, score: similarity, distance }); } this.stats.queryCount++; this.stats.totalQueryTime += Date.now() - startTime; return results; } /** * Brute-force vector search fallback */ private searchBruteForce( embedding: Float32Array, options: SearchOptions, startTime: number ): SearchResult[] { if (!this.db) throw new Error('Database not initialized'); // Get all entries with embeddings let sql = ` SELECT * FROM memory_entries WHERE embedding IS NOT NULL `; const params: unknown[] = []; // Apply filters if provided if (options.filters?.namespace) { sql += ' AND namespace = ?'; params.push(options.filters.namespace); } if (options.filters?.memoryType) { sql += ' AND type = ?'; params.push(options.filters.memoryType); } const rows = this.db.prepare(sql).all(...params) as Record[]; // Calculate cosine similarity const results: SearchResult[] = rows .map((row) => { const entry = this.rowToEntry(row); if (!entry.embedding) return null; const similarity = this.cosineSimilarity(embedding, entry.embedding); const distance = 1 - similarity; // Apply threshold (similarity threshold, not distance) if (options.threshold !== undefined && similarity < options.threshold) { return null; } return { entry, score: similarity, distance }; }) .filter((r): r is SearchResult => r !== null); // Sort by score and limit results.sort((a, b) => b.score - a.score); this.stats.queryCount++; this.stats.totalQueryTime += Date.now() - startTime; return results.slice(0, options.k); } /** * Calculate cosine similarity between two vectors */ private cosineSimilarity(a: Float32Array, b: Float32Array): number { if (a.length !== b.length) return 0; let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denominator = Math.sqrt(normA) * Math.sqrt(normB); return denominator === 0 ? 0 : dotProduct / denominator; } /** * Bulk insert entries */ async bulkInsert(entries: MemoryEntry[]): Promise { if (!this.db) throw new Error('Database not initialized'); if (entries.length === 0) return; const stmt = this.db.prepare(` INSERT OR REPLACE INTO memory_entries (id, key, content, type, namespace, tags, metadata, embedding, session_id, owner_id, access_level, created_at, updated_at, expires_at, version, "references", access_count, last_accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const getRowid = this.db.prepare('SELECT rowid FROM memory_entries WHERE id = ?'); const insert = this.db.transaction((entries: MemoryEntry[]) => { for (const entry of entries) { stmt.run( entry.id, entry.key, entry.content, entry.type, entry.namespace, JSON.stringify(entry.tags), JSON.stringify(entry.metadata), entry.embedding ? Buffer.from(entry.embedding.buffer, entry.embedding.byteOffset, entry.embedding.byteLength) : null, entry.sessionId || null, entry.ownerId || null, entry.accessLevel, entry.createdAt, entry.updatedAt, entry.expiresAt || null, entry.version, JSON.stringify(entry.references), entry.accessCount, entry.lastAccessedAt ); // Store vector in sqlite-vec table if available and embedding exists if (this.vectorAvailable && entry.embedding) { const rowResult = getRowid.get(entry.id) as { rowid: number | bigint } | undefined; if (rowResult) { try { const rowid = Number(rowResult.rowid); // Use exec for sqlite-vec (prepared statements don't work correctly) this.db!.exec(`DELETE FROM memory_vectors WHERE rowid = ${rowid}`); const vectorJson = JSON.stringify(Array.from(entry.embedding)); this.db!.exec(`INSERT INTO memory_vectors(rowid, embedding) VALUES (${rowid}, '${vectorJson}')`); } catch (error) { // Skip vector insertion on dimension mismatch or other errors if (this.config.verbose) { console.warn(`[BetterSqlite3] Vector insertion skipped for ${entry.id}: ${error}`); } } } } } }); insert(entries); this.emit('bulkInserted', entries.length); } /** * Bulk delete entries */ async bulkDelete(ids: string[]): Promise { if (!this.db) throw new Error('Database not initialized'); if (ids.length === 0) return 0; // Get rowids before deletion for vector cleanup const placeholders = ids.map(() => '?').join(', '); let rowids: number[] = []; if (this.vectorAvailable) { const rows = this.db .prepare(`SELECT rowid FROM memory_entries WHERE id IN (${placeholders})`) .all(...ids) as { rowid: number | bigint }[]; rowids = rows.map(r => Number(r.rowid)); } const result = this.db .prepare(`DELETE FROM memory_entries WHERE id IN (${placeholders})`) .run(...ids); // Delete vectors if applicable if (this.vectorAvailable && rowids.length > 0) { const vecPlaceholders = rowids.map(() => '?').join(', '); this.db.prepare(`DELETE FROM memory_vectors WHERE rowid IN (${vecPlaceholders})`).run(...rowids); } return result.changes; } /** * Get entry count */ async count(namespace?: string): Promise { if (!this.db) throw new Error('Database not initialized'); if (namespace) { const result = this.db .prepare('SELECT COUNT(*) as count FROM memory_entries WHERE namespace = ?') .get(namespace) as { count: number }; return result.count; } const result = this.db .prepare('SELECT COUNT(*) as count FROM memory_entries') .get() as { count: number }; return result.count; } /** * List all namespaces */ async listNamespaces(): Promise { if (!this.db) throw new Error('Database not initialized'); const rows = this.db .prepare('SELECT DISTINCT namespace FROM memory_entries') .all() as { namespace: string }[]; return rows.map((r) => r.namespace); } /** * Clear all entries in a namespace */ async clearNamespace(namespace: string): Promise { if (!this.db) throw new Error('Database not initialized'); // Get rowids before deletion for vector cleanup let rowids: number[] = []; if (this.vectorAvailable) { const rows = this.db .prepare('SELECT rowid FROM memory_entries WHERE namespace = ?') .all(namespace) as { rowid: number | bigint }[]; rowids = rows.map(r => Number(r.rowid)); } const result = this.db .prepare('DELETE FROM memory_entries WHERE namespace = ?') .run(namespace); // Delete vectors if applicable if (this.vectorAvailable && rowids.length > 0) { const placeholders = rowids.map(() => '?').join(', '); this.db.prepare(`DELETE FROM memory_vectors WHERE rowid IN (${placeholders})`).run(...rowids); } return result.changes; } /** * Get backend statistics */ async getStats(): Promise { if (!this.db) throw new Error('Database not initialized'); const totalEntries = await this.count(); const namespaces = await this.listNamespaces(); const entriesByNamespace: Record = {}; for (const ns of namespaces) { entriesByNamespace[ns] = await this.count(ns); } // Get database size const pageCount = this.db.pragma('page_count', { simple: true }) as number; const pageSize = this.db.pragma('page_size', { simple: true }) as number; const dbSizeBytes = pageCount * pageSize; // Get type breakdown const typeRows = this.db.prepare(` SELECT type, COUNT(*) as count FROM memory_entries GROUP BY type `).all() as { type: MemoryType; count: number }[]; const entriesByType: Record = { episodic: 0, semantic: 0, procedural: 0, working: 0, cache: 0, }; for (const row of typeRows) { entriesByType[row.type] = row.count; } return { totalEntries, entriesByNamespace, entriesByType, memoryUsage: dbSizeBytes, avgQueryTime: this.stats.queryCount > 0 ? this.stats.totalQueryTime / this.stats.queryCount : 0, avgSearchTime: this.stats.queryCount > 0 ? this.stats.totalQueryTime / this.stats.queryCount : 0, }; } /** * Perform health check */ async healthCheck(): Promise { const issues: string[] = []; const recommendations: string[] = []; // Check database (storage) let storageHealth: ComponentHealth; const storageStart = Date.now(); try { if (this.db) { this.db.prepare('SELECT 1').get(); storageHealth = { status: 'healthy', latency: Date.now() - storageStart, }; } else { storageHealth = { status: 'unhealthy', latency: 0, message: 'Database not initialized', }; issues.push('Database not initialized'); } } catch (error) { storageHealth = { status: 'unhealthy', latency: Date.now() - storageStart, message: String(error), }; issues.push(`Database error: ${error}`); } // Check FTS5 (index) const indexHealth: ComponentHealth = { status: this.ftsAvailable ? 'healthy' : 'degraded', latency: 0, message: this.ftsAvailable ? `Tokenizer: ${this.getActiveTokenizer()}` : 'FTS5 not available, using LIKE fallback', }; if (!this.ftsAvailable) { recommendations.push('Enable FTS5 for better search performance'); } // Check vector search (sqlite-vec) const cacheHealth: ComponentHealth = { status: this.vectorAvailable ? 'healthy' : 'degraded', latency: 0, message: this.vectorAvailable ? `sqlite-vec active (${this.config.vectorDimensions || 384} dimensions)` : 'sqlite-vec not available, using brute-force search', }; if (!this.vectorAvailable) { recommendations.push('Install sqlite-vec for faster vector search'); } // Determine overall status let status: 'healthy' | 'degraded' | 'unhealthy'; if (storageHealth.status === 'unhealthy') { status = 'unhealthy'; } else if (indexHealth.status === 'degraded' || cacheHealth.status === 'degraded') { status = 'degraded'; } else { status = 'healthy'; } return { status, timestamp: Date.now(), components: { storage: storageHealth, index: indexHealth, cache: cacheHealth, }, issues, recommendations, }; } /** * Get the underlying database for advanced operations */ getDatabase(): Database.Database | null { return this.db; } /** * Rebuild FTS index */ async rebuildFtsIndex(): Promise { if (!this.db || !this.ftsAvailable) return; this.db.exec("INSERT INTO memory_fts(memory_fts) VALUES('rebuild')"); if (this.config.verbose) { console.log('[BetterSqlite3] FTS index rebuilt'); } } /** * Convert database row to MemoryEntry */ private rowToEntry(row: Record): MemoryEntry { let embedding: Float32Array | undefined; if (row.embedding) { const buffer = row.embedding as Buffer; embedding = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4); } return { id: row.id as string, key: row.key as string, content: row.content as string, embedding, type: row.type as MemoryType, namespace: row.namespace as string, tags: JSON.parse((row.tags as string) || '[]'), metadata: JSON.parse((row.metadata as string) || '{}'), sessionId: row.session_id as string | undefined, ownerId: row.owner_id as string | undefined, accessLevel: row.access_level as MemoryEntry['accessLevel'], createdAt: row.created_at as number, updatedAt: row.updated_at as number, expiresAt: row.expires_at as number | undefined, version: row.version as number, references: JSON.parse((row.references as string) || '[]'), accessCount: row.access_count as number, lastAccessedAt: row.last_accessed_at as number, }; } /** * Migrate existing embeddings to sqlite-vec index. * Call this to index entries that have embeddings but are not yet in the vector index. * * @returns Number of entries migrated */ async migrateEmbeddingsToVec(): Promise<{ migrated: number; skipped: number; errors: number }> { if (!this.db) throw new Error('Database not initialized'); if (!this.vectorAvailable) { console.warn('[Migration] sqlite-vec not available, skipping migration'); return { migrated: 0, skipped: 0, errors: 0 }; } let migrated = 0; let skipped = 0; let errors = 0; // Find entries with embeddings that are not in memory_vectors const entriesWithEmbeddings = this.db.prepare(` SELECT e.rowid, e.id, e.embedding FROM memory_entries e LEFT JOIN memory_vectors v ON e.rowid = v.rowid WHERE e.embedding IS NOT NULL AND v.rowid IS NULL `).all() as Array<{ rowid: number | bigint; id: string; embedding: Buffer | null }>; console.log(`[Migration] Found ${entriesWithEmbeddings.length} entries to migrate to sqlite-vec`); for (const row of entriesWithEmbeddings) { try { if (!row.embedding) { skipped++; continue; } // Parse embedding from BLOB const float32 = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4); const vectorJson = JSON.stringify(Array.from(float32)); const rowid = Number(row.rowid); // Insert into sqlite-vec this.db.exec(`INSERT OR REPLACE INTO memory_vectors(rowid, embedding) VALUES (${rowid}, '${vectorJson}')`); migrated++; // Log progress every 100 entries if (migrated % 100 === 0) { console.log(`[Migration] Migrated ${migrated}/${entriesWithEmbeddings.length} entries...`); } } catch (err) { console.error(`[Migration] Error migrating entry ${row.id}:`, err); errors++; } } console.log(`[Migration] Complete: ${migrated} migrated, ${skipped} skipped, ${errors} errors`); return { migrated, skipped, errors }; } /** * Check if migration is needed (entries with embeddings not in sqlite-vec) */ async needsMigration(): Promise<{ needed: boolean; count: number }> { if (!this.db) throw new Error('Database not initialized'); if (!this.vectorAvailable) { return { needed: false, count: 0 }; } const result = this.db.prepare(` SELECT COUNT(*) as count FROM memory_entries e LEFT JOIN memory_vectors v ON e.rowid = v.rowid WHERE e.embedding IS NOT NULL AND v.rowid IS NULL `).get() as { count: number }; return { needed: result.count > 0, count: result.count }; } } /** * Create a BetterSqlite3 backend with default CJK support */ export function createBetterSqlite3Backend( config?: Partial ): BetterSqlite3Backend { return new BetterSqlite3Backend({ ...config, ftsTokenizer: config?.ftsTokenizer || 'trigram', // Default to trigram for CJK }); } /** * Create a BetterSqlite3 backend with lindera extension for advanced Japanese */ export function createJapaneseOptimizedBackend( config: Partial & { linderaPath: string } ): BetterSqlite3Backend { return new BetterSqlite3Backend({ ...config, extensionPath: config.linderaPath, customTokenizer: 'lindera_tokenizer', }); } export default BetterSqlite3Backend;