@aitytech/agentkits-memory 1.0.1 → 2.0.1
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.
- package/README.md +54 -5
- package/dist/better-sqlite3-backend.d.ts +192 -0
- package/dist/better-sqlite3-backend.d.ts.map +1 -0
- package/dist/better-sqlite3-backend.js +801 -0
- package/dist/better-sqlite3-backend.js.map +1 -0
- package/dist/cli/save.js +0 -0
- package/dist/cli/setup.d.ts +6 -2
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +289 -42
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/viewer.js +25 -56
- package/dist/cli/viewer.js.map +1 -1
- package/dist/cli/web-viewer.d.ts +2 -1
- package/dist/cli/web-viewer.d.ts.map +1 -1
- package/dist/cli/web-viewer.js +791 -141
- package/dist/cli/web-viewer.js.map +1 -1
- package/dist/embeddings/embedding-cache.d.ts +131 -0
- package/dist/embeddings/embedding-cache.d.ts.map +1 -0
- package/dist/embeddings/embedding-cache.js +217 -0
- package/dist/embeddings/embedding-cache.js.map +1 -0
- package/dist/embeddings/index.d.ts +11 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +11 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/local-embeddings.d.ts +140 -0
- package/dist/embeddings/local-embeddings.d.ts.map +1 -0
- package/dist/embeddings/local-embeddings.js +293 -0
- package/dist/embeddings/local-embeddings.js.map +1 -0
- package/dist/hooks/context.d.ts +6 -1
- package/dist/hooks/context.d.ts.map +1 -1
- package/dist/hooks/context.js +12 -2
- package/dist/hooks/context.js.map +1 -1
- package/dist/hooks/observation.d.ts +6 -1
- package/dist/hooks/observation.d.ts.map +1 -1
- package/dist/hooks/observation.js +12 -2
- package/dist/hooks/observation.js.map +1 -1
- package/dist/hooks/service.d.ts +1 -6
- package/dist/hooks/service.d.ts.map +1 -1
- package/dist/hooks/service.js +33 -85
- package/dist/hooks/service.js.map +1 -1
- package/dist/hooks/session-init.d.ts +6 -1
- package/dist/hooks/session-init.d.ts.map +1 -1
- package/dist/hooks/session-init.js +12 -2
- package/dist/hooks/session-init.js.map +1 -1
- package/dist/hooks/summarize.d.ts +6 -1
- package/dist/hooks/summarize.d.ts.map +1 -1
- package/dist/hooks/summarize.js +12 -2
- package/dist/hooks/summarize.js.map +1 -1
- package/dist/index.d.ts +10 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -94
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +17 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/migration.js +3 -3
- package/dist/migration.js.map +1 -1
- package/dist/search/hybrid-search.d.ts +262 -0
- package/dist/search/hybrid-search.d.ts.map +1 -0
- package/dist/search/hybrid-search.js +688 -0
- package/dist/search/hybrid-search.js.map +1 -0
- package/dist/search/index.d.ts +13 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +13 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/token-economics.d.ts +161 -0
- package/dist/search/token-economics.d.ts.map +1 -0
- package/dist/search/token-economics.js +239 -0
- package/dist/search/token-economics.js.map +1 -0
- package/dist/types.d.ts +0 -68
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +6 -4
- package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
- package/src/__tests__/cache-manager.test.ts +499 -0
- package/src/__tests__/embedding-integration.test.ts +481 -0
- package/src/__tests__/hnsw-index.test.ts +727 -0
- package/src/__tests__/index.test.ts +432 -0
- package/src/better-sqlite3-backend.ts +1000 -0
- package/src/cli/setup.ts +358 -47
- package/src/cli/viewer.ts +28 -63
- package/src/cli/web-viewer.ts +936 -182
- package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
- package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
- package/src/embeddings/embedding-cache.ts +318 -0
- package/src/embeddings/index.ts +20 -0
- package/src/embeddings/local-embeddings.ts +419 -0
- package/src/hooks/__tests__/handlers.test.ts +58 -17
- package/src/hooks/__tests__/integration.test.ts +77 -26
- package/src/hooks/context.ts +13 -2
- package/src/hooks/observation.ts +13 -2
- package/src/hooks/service.ts +39 -100
- package/src/hooks/session-init.ts +13 -2
- package/src/hooks/summarize.ts +13 -2
- package/src/index.ts +210 -116
- package/src/mcp/server.ts +20 -3
- package/src/search/__tests__/hybrid-search.test.ts +669 -0
- package/src/search/__tests__/token-economics.test.ts +276 -0
- package/src/search/hybrid-search.ts +968 -0
- package/src/search/index.ts +29 -0
- package/src/search/token-economics.ts +367 -0
- package/src/types.ts +0 -96
- package/src/__tests__/sqljs-backend.test.ts +0 -410
- package/src/migration.ts +0 -574
- package/src/sql.js.d.ts +0 -70
- 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;
|