@aitytech/agentkits-memory 1.0.1 → 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 (105) hide show
  1. package/README.md +54 -5
  2. package/dist/better-sqlite3-backend.d.ts +192 -0
  3. package/dist/better-sqlite3-backend.d.ts.map +1 -0
  4. package/dist/better-sqlite3-backend.js +801 -0
  5. package/dist/better-sqlite3-backend.js.map +1 -0
  6. package/dist/cli/save.js +0 -0
  7. package/dist/cli/setup.d.ts +6 -2
  8. package/dist/cli/setup.d.ts.map +1 -1
  9. package/dist/cli/setup.js +289 -42
  10. package/dist/cli/setup.js.map +1 -1
  11. package/dist/cli/viewer.js +25 -56
  12. package/dist/cli/viewer.js.map +1 -1
  13. package/dist/cli/web-viewer.d.ts +2 -1
  14. package/dist/cli/web-viewer.d.ts.map +1 -1
  15. package/dist/cli/web-viewer.js +791 -141
  16. package/dist/cli/web-viewer.js.map +1 -1
  17. package/dist/embeddings/embedding-cache.d.ts +131 -0
  18. package/dist/embeddings/embedding-cache.d.ts.map +1 -0
  19. package/dist/embeddings/embedding-cache.js +217 -0
  20. package/dist/embeddings/embedding-cache.js.map +1 -0
  21. package/dist/embeddings/index.d.ts +11 -0
  22. package/dist/embeddings/index.d.ts.map +1 -0
  23. package/dist/embeddings/index.js +11 -0
  24. package/dist/embeddings/index.js.map +1 -0
  25. package/dist/embeddings/local-embeddings.d.ts +140 -0
  26. package/dist/embeddings/local-embeddings.d.ts.map +1 -0
  27. package/dist/embeddings/local-embeddings.js +293 -0
  28. package/dist/embeddings/local-embeddings.js.map +1 -0
  29. package/dist/hooks/context.d.ts +6 -1
  30. package/dist/hooks/context.d.ts.map +1 -1
  31. package/dist/hooks/context.js +12 -2
  32. package/dist/hooks/context.js.map +1 -1
  33. package/dist/hooks/observation.d.ts +6 -1
  34. package/dist/hooks/observation.d.ts.map +1 -1
  35. package/dist/hooks/observation.js +12 -2
  36. package/dist/hooks/observation.js.map +1 -1
  37. package/dist/hooks/service.d.ts +1 -6
  38. package/dist/hooks/service.d.ts.map +1 -1
  39. package/dist/hooks/service.js +33 -85
  40. package/dist/hooks/service.js.map +1 -1
  41. package/dist/hooks/session-init.d.ts +6 -1
  42. package/dist/hooks/session-init.d.ts.map +1 -1
  43. package/dist/hooks/session-init.js +12 -2
  44. package/dist/hooks/session-init.js.map +1 -1
  45. package/dist/hooks/summarize.d.ts +6 -1
  46. package/dist/hooks/summarize.d.ts.map +1 -1
  47. package/dist/hooks/summarize.js +12 -2
  48. package/dist/hooks/summarize.js.map +1 -1
  49. package/dist/index.d.ts +10 -17
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +172 -94
  52. package/dist/index.js.map +1 -1
  53. package/dist/mcp/server.js +17 -3
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/migration.js +3 -3
  56. package/dist/migration.js.map +1 -1
  57. package/dist/search/hybrid-search.d.ts +262 -0
  58. package/dist/search/hybrid-search.d.ts.map +1 -0
  59. package/dist/search/hybrid-search.js +688 -0
  60. package/dist/search/hybrid-search.js.map +1 -0
  61. package/dist/search/index.d.ts +13 -0
  62. package/dist/search/index.d.ts.map +1 -0
  63. package/dist/search/index.js +13 -0
  64. package/dist/search/index.js.map +1 -0
  65. package/dist/search/token-economics.d.ts +161 -0
  66. package/dist/search/token-economics.d.ts.map +1 -0
  67. package/dist/search/token-economics.js +239 -0
  68. package/dist/search/token-economics.js.map +1 -0
  69. package/dist/types.d.ts +0 -68
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js.map +1 -1
  72. package/package.json +5 -3
  73. package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
  74. package/src/__tests__/cache-manager.test.ts +499 -0
  75. package/src/__tests__/embedding-integration.test.ts +481 -0
  76. package/src/__tests__/hnsw-index.test.ts +727 -0
  77. package/src/__tests__/index.test.ts +432 -0
  78. package/src/better-sqlite3-backend.ts +1000 -0
  79. package/src/cli/setup.ts +358 -47
  80. package/src/cli/viewer.ts +28 -63
  81. package/src/cli/web-viewer.ts +936 -182
  82. package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
  83. package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
  84. package/src/embeddings/embedding-cache.ts +318 -0
  85. package/src/embeddings/index.ts +20 -0
  86. package/src/embeddings/local-embeddings.ts +419 -0
  87. package/src/hooks/__tests__/handlers.test.ts +58 -17
  88. package/src/hooks/__tests__/integration.test.ts +77 -26
  89. package/src/hooks/context.ts +13 -2
  90. package/src/hooks/observation.ts +13 -2
  91. package/src/hooks/service.ts +39 -100
  92. package/src/hooks/session-init.ts +13 -2
  93. package/src/hooks/summarize.ts +13 -2
  94. package/src/index.ts +210 -116
  95. package/src/mcp/server.ts +20 -3
  96. package/src/search/__tests__/hybrid-search.test.ts +669 -0
  97. package/src/search/__tests__/token-economics.test.ts +276 -0
  98. package/src/search/hybrid-search.ts +968 -0
  99. package/src/search/index.ts +29 -0
  100. package/src/search/token-economics.ts +367 -0
  101. package/src/types.ts +0 -96
  102. package/src/__tests__/sqljs-backend.test.ts +0 -410
  103. package/src/migration.ts +0 -574
  104. package/src/sql.js.d.ts +0 -70
  105. package/src/sqljs-backend.ts +0 -789
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * AgentKits Memory Web Viewer
4
4
  *
5
- * Web-based viewer for memory database with CRUD support.
5
+ * Web-based viewer for memory database with hybrid search support.
6
+ * Uses ProjectMemoryService for vector + text search.
6
7
  *
7
8
  * Usage:
8
9
  * npx agentkits-memory-web [--port=1905]
@@ -11,14 +12,17 @@
11
12
  */
12
13
 
13
14
  import * as http from 'node:http';
14
- import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
- import { createRequire } from 'node:module';
17
- import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
16
+ import Database from 'better-sqlite3';
17
+ import type { Database as BetterDatabase } from 'better-sqlite3';
18
+ import { HybridSearchEngine, LocalEmbeddingsService } from '../index.js';
18
19
 
19
20
  const args = process.argv.slice(2);
20
21
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
21
22
 
23
+ // Embeddings service singleton
24
+ let _embeddingsService: LocalEmbeddingsService | null = null;
25
+
22
26
  function parseArgs(): Record<string, string | boolean> {
23
27
  const parsed: Record<string, string | boolean> = {};
24
28
  for (const arg of args) {
@@ -36,106 +40,112 @@ const PORT = parseInt(options.port as string, 10) || 1905;
36
40
  const dbDir = path.join(projectDir, '.claude/memory');
37
41
  const dbPath = path.join(dbDir, 'memory.db');
38
42
 
39
- let SQL: Awaited<ReturnType<typeof initSqlJs>>;
43
+ // Singleton database and search engine
44
+ let _searchEngine: HybridSearchEngine | null = null;
45
+ let _db: BetterDatabase | null = null;
40
46
 
41
- async function initSQL(): Promise<void> {
42
- if (!SQL) {
43
- const require = createRequire(import.meta.url);
44
- const sqlJsPath = require.resolve('sql.js');
45
- SQL = await initSqlJs({
46
- locateFile: (file: string) => path.join(path.dirname(sqlJsPath), file),
47
- });
48
- }
47
+ /**
48
+ * Get direct database access
49
+ */
50
+ function getDatabase(): BetterDatabase {
51
+ if (_db) return _db;
52
+ _db = new Database(dbPath);
53
+ _db.pragma('journal_mode = WAL');
54
+ return _db;
49
55
  }
50
56
 
51
- async function loadOrCreateDatabase(): Promise<SqlJsDatabase> {
52
- await initSQL();
53
-
54
- // Ensure directory exists
55
- if (!fs.existsSync(dbDir)) {
56
- fs.mkdirSync(dbDir, { recursive: true });
57
- }
57
+ /**
58
+ * Get or initialize embeddings service
59
+ */
60
+ async function getEmbeddingsService(): Promise<LocalEmbeddingsService> {
61
+ if (_embeddingsService) return _embeddingsService;
58
62
 
59
- let db: SqlJsDatabase;
63
+ _embeddingsService = new LocalEmbeddingsService({
64
+ cacheDir: path.join(dbDir, 'embeddings-cache'),
65
+ });
66
+ await _embeddingsService.initialize();
67
+ return _embeddingsService;
68
+ }
60
69
 
61
- if (fs.existsSync(dbPath)) {
62
- const buffer = fs.readFileSync(dbPath);
63
- db = new SQL.Database(new Uint8Array(buffer));
64
- } else {
65
- db = new SQL.Database();
66
- }
70
+ /**
71
+ * Get or initialize the HybridSearchEngine with embeddings
72
+ */
73
+ async function getSearchEngine(): Promise<HybridSearchEngine> {
74
+ if (_searchEngine) return _searchEngine;
67
75
 
68
- // Create table if not exists
69
- db.run(`
70
- CREATE TABLE IF NOT EXISTS memory_entries (
71
- id TEXT PRIMARY KEY,
72
- key TEXT NOT NULL,
73
- content TEXT NOT NULL,
74
- type TEXT DEFAULT 'semantic',
75
- namespace TEXT DEFAULT 'general',
76
- tags TEXT DEFAULT '[]',
77
- metadata TEXT DEFAULT '{}',
78
- embedding BLOB,
79
- created_at INTEGER NOT NULL,
80
- updated_at INTEGER NOT NULL,
81
- accessed_at INTEGER,
82
- access_count INTEGER DEFAULT 0,
83
- importance REAL DEFAULT 0.5,
84
- decay_rate REAL DEFAULT 0.1
85
- )
86
- `);
87
-
88
- db.run(`CREATE INDEX IF NOT EXISTS idx_namespace ON memory_entries(namespace)`);
89
- db.run(`CREATE INDEX IF NOT EXISTS idx_key ON memory_entries(key)`);
90
- db.run(`CREATE INDEX IF NOT EXISTS idx_created ON memory_entries(created_at)`);
91
-
92
- return db;
93
- }
76
+ const db = getDatabase();
77
+ const embeddings = await getEmbeddingsService();
94
78
 
95
- function saveDatabase(db: SqlJsDatabase): void {
96
- const data = db.export();
97
- const buffer = Buffer.from(data);
98
- fs.writeFileSync(dbPath, buffer);
99
- }
79
+ // Create embedding generator function
80
+ const embeddingGenerator = async (text: string): Promise<Float32Array> => {
81
+ const result = await embeddings.embed(text);
82
+ return result.embedding;
83
+ };
100
84
 
101
- function generateId(): string {
102
- return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
85
+ _searchEngine = new HybridSearchEngine(db, {}, embeddingGenerator);
86
+ await _searchEngine.initialize();
87
+ return _searchEngine;
103
88
  }
104
89
 
105
- function getStats(db: SqlJsDatabase): {
90
+ /**
91
+ * Get database statistics using direct SQL (faster for stats queries)
92
+ */
93
+ function getStats(db: BetterDatabase): {
106
94
  total: number;
107
95
  byNamespace: Record<string, number>;
108
96
  byType: Record<string, number>;
97
+ tokenEconomics: {
98
+ totalTokens: number;
99
+ avgTokensPerEntry: number;
100
+ totalCharacters: number;
101
+ estimatedSavings: number;
102
+ };
109
103
  } {
110
- const totalResult = db.exec('SELECT COUNT(*) as count FROM memory_entries');
111
- const total = (totalResult[0]?.values[0]?.[0] as number) || 0;
104
+ const totalRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries').get() as { count: number };
105
+ const total = totalRow?.count || 0;
112
106
 
113
- const nsResult = db.exec('SELECT namespace, COUNT(*) FROM memory_entries GROUP BY namespace');
107
+ const nsRows = db.prepare('SELECT namespace, COUNT(*) as count FROM memory_entries GROUP BY namespace').all() as { namespace: string; count: number }[];
114
108
  const byNamespace: Record<string, number> = {};
115
- if (nsResult[0]) {
116
- for (const row of nsResult[0].values) {
117
- byNamespace[row[0] as string] = row[1] as number;
118
- }
109
+ for (const row of nsRows) {
110
+ byNamespace[row.namespace] = row.count;
119
111
  }
120
112
 
121
- const typeResult = db.exec('SELECT type, COUNT(*) FROM memory_entries GROUP BY type');
113
+ const typeRows = db.prepare('SELECT type, COUNT(*) as count FROM memory_entries GROUP BY type').all() as { type: string; count: number }[];
122
114
  const byType: Record<string, number> = {};
123
- if (typeResult[0]) {
124
- for (const row of typeResult[0].values) {
125
- byType[row[0] as string] = row[1] as number;
126
- }
115
+ for (const row of typeRows) {
116
+ byType[row.type] = row.count;
127
117
  }
128
118
 
129
- return { total, byNamespace, byType };
119
+ // Calculate token economics
120
+ const contentRow = db.prepare('SELECT SUM(LENGTH(content)) as total_chars, COUNT(*) as count FROM memory_entries').get() as { total_chars: number; count: number };
121
+ const totalCharacters = contentRow?.total_chars || 0;
122
+ const entryCount = contentRow?.count || 0;
123
+
124
+ // Estimate tokens (~4 chars per token)
125
+ const totalTokens = Math.ceil(totalCharacters / 4);
126
+ const avgTokensPerEntry = entryCount > 0 ? Math.ceil(totalTokens / entryCount) : 0;
127
+
128
+ // Estimated savings: if you had to rediscover this info each time
129
+ // Assume 5x overhead for discovery vs recall
130
+ const estimatedSavings = totalTokens * 5;
131
+
132
+ return {
133
+ total,
134
+ byNamespace,
135
+ byType,
136
+ tokenEconomics: {
137
+ totalTokens,
138
+ avgTokensPerEntry,
139
+ totalCharacters,
140
+ estimatedSavings,
141
+ },
142
+ };
130
143
  }
131
144
 
132
- function getEntries(
133
- db: SqlJsDatabase,
134
- namespace?: string,
135
- limit = 50,
136
- offset = 0,
137
- search?: string
138
- ): Array<{
145
+ /**
146
+ * Result type for getEntries with optional score and embedding info
147
+ */
148
+ interface EntryResult {
139
149
  id: string;
140
150
  key: string;
141
151
  content: string;
@@ -144,57 +154,208 @@ function getEntries(
144
154
  tags: string[];
145
155
  created_at: number;
146
156
  updated_at: number;
147
- }> {
148
- let query = 'SELECT id, key, content, type, namespace, tags, created_at, updated_at FROM memory_entries';
149
- const conditions: string[] = [];
150
- const params: (string | number)[] = [];
157
+ score?: number;
158
+ hasEmbedding?: boolean;
159
+ }
160
+
161
+ /**
162
+ * Get entries with optional search (standard listing)
163
+ */
164
+ function getEntries(
165
+ db: BetterDatabase,
166
+ namespace?: string,
167
+ limit = 50,
168
+ offset = 0,
169
+ search?: string
170
+ ): EntryResult[] {
171
+ // Standard query without search
172
+ if (!search || !search.trim()) {
173
+ let query = 'SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at FROM memory_entries';
174
+ const conditions: string[] = [];
175
+ const params: (string | number)[] = [];
176
+
177
+ if (namespace) {
178
+ conditions.push('namespace = ?');
179
+ params.push(namespace);
180
+ }
181
+
182
+ if (conditions.length > 0) {
183
+ query += ' WHERE ' + conditions.join(' AND ');
184
+ }
185
+
186
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
187
+ params.push(limit, offset);
188
+
189
+ const rows = db.prepare(query).all(...params) as {
190
+ id: string;
191
+ key: string;
192
+ content: string;
193
+ type: string;
194
+ namespace: string;
195
+ tags: string;
196
+ embedding: Buffer | null;
197
+ created_at: number;
198
+ updated_at: number;
199
+ }[];
200
+
201
+ return rows.map((row) => ({
202
+ id: row.id,
203
+ key: row.key,
204
+ content: row.content,
205
+ type: row.type,
206
+ namespace: row.namespace,
207
+ tags: JSON.parse(row.tags || '[]'),
208
+ created_at: row.created_at,
209
+ updated_at: row.updated_at,
210
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
211
+ }));
212
+ }
213
+
214
+ // Use FTS5 search for better CJK support
215
+ const sanitizedSearch = search.trim().replace(/"/g, '""');
216
+ let ftsQuery = `
217
+ SELECT m.id, m.key, m.content, m.type, m.namespace, m.tags, m.embedding, m.created_at, m.updated_at
218
+ FROM memory_entries m
219
+ INNER JOIN memory_fts f ON m.id = f.id
220
+ WHERE memory_fts MATCH '"${sanitizedSearch}"'
221
+ `;
151
222
 
152
223
  if (namespace) {
153
- conditions.push('namespace = ?');
154
- params.push(namespace);
224
+ ftsQuery += ` AND m.namespace = ?`;
155
225
  }
156
226
 
157
- if (search) {
227
+ ftsQuery += ` ORDER BY m.created_at DESC LIMIT ? OFFSET ?`;
228
+
229
+ try {
230
+ const params = namespace ? [namespace, limit, offset] : [limit, offset];
231
+ const rows = db.prepare(ftsQuery).all(...params) as {
232
+ id: string;
233
+ key: string;
234
+ content: string;
235
+ type: string;
236
+ namespace: string;
237
+ tags: string;
238
+ embedding: Buffer | null;
239
+ created_at: number;
240
+ updated_at: number;
241
+ }[];
242
+
243
+ return rows.map((row) => ({
244
+ id: row.id,
245
+ key: row.key,
246
+ content: row.content,
247
+ type: row.type,
248
+ namespace: row.namespace,
249
+ tags: JSON.parse(row.tags || '[]'),
250
+ created_at: row.created_at,
251
+ updated_at: row.updated_at,
252
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
253
+ }));
254
+ } catch {
255
+ // Fallback to LIKE if FTS fails
256
+ console.warn('[WebViewer] FTS search failed, falling back to LIKE');
257
+
258
+ let query = 'SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at FROM memory_entries';
259
+ const conditions: string[] = [];
260
+ const params: (string | number)[] = [];
261
+
262
+ if (namespace) {
263
+ conditions.push('namespace = ?');
264
+ params.push(namespace);
265
+ }
266
+
158
267
  conditions.push('(content LIKE ? OR key LIKE ? OR tags LIKE ?)');
159
268
  const searchPattern = `%${search}%`;
160
269
  params.push(searchPattern, searchPattern, searchPattern);
161
- }
162
270
 
163
- if (conditions.length > 0) {
164
271
  query += ' WHERE ' + conditions.join(' AND ');
272
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
273
+ params.push(limit, offset);
274
+
275
+ const rows = db.prepare(query).all(...params) as {
276
+ id: string;
277
+ key: string;
278
+ content: string;
279
+ type: string;
280
+ namespace: string;
281
+ tags: string;
282
+ embedding: Buffer | null;
283
+ created_at: number;
284
+ updated_at: number;
285
+ }[];
286
+
287
+ return rows.map((row) => ({
288
+ id: row.id,
289
+ key: row.key,
290
+ content: row.content,
291
+ type: row.type,
292
+ namespace: row.namespace,
293
+ tags: JSON.parse(row.tags || '[]'),
294
+ created_at: row.created_at,
295
+ updated_at: row.updated_at,
296
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
297
+ }));
165
298
  }
299
+ }
166
300
 
167
- query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
168
- params.push(limit, offset);
169
-
170
- const stmt = db.prepare(query);
171
- stmt.bind(params);
172
-
173
- const entries: Array<{
174
- id: string;
175
- key: string;
176
- content: string;
177
- type: string;
178
- namespace: string;
179
- tags: string[];
180
- created_at: number;
181
- updated_at: number;
182
- }> = [];
183
-
184
- while (stmt.step()) {
185
- const row = stmt.getAsObject();
186
- entries.push({
187
- id: row.id as string,
188
- key: row.key as string,
189
- content: row.content as string,
190
- type: row.type as string,
191
- namespace: row.namespace as string,
192
- tags: JSON.parse((row.tags as string) || '[]'),
193
- created_at: row.created_at as number,
194
- updated_at: row.updated_at as number,
195
- });
301
+ /**
302
+ * Search entries using HybridSearchEngine
303
+ * Supports hybrid (text + vector), text-only, or vector-only search
304
+ */
305
+ async function searchEntries(
306
+ searchEngine: HybridSearchEngine,
307
+ query: string,
308
+ options: {
309
+ type?: 'hybrid' | 'text' | 'vector';
310
+ namespace?: string;
311
+ limit?: number;
312
+ } = {}
313
+ ): Promise<EntryResult[]> {
314
+ const { type = 'hybrid', namespace, limit = 20 } = options;
315
+
316
+ // Use searchCompact for efficient search with scores
317
+ const results = await searchEngine.searchCompact(query, {
318
+ limit,
319
+ namespace,
320
+ includeKeyword: type === 'hybrid' || type === 'text',
321
+ includeSemantic: type === 'hybrid' || type === 'vector',
322
+ });
323
+
324
+ // Fetch full entries for the results
325
+ const db = getDatabase();
326
+ const entries: EntryResult[] = [];
327
+
328
+ for (const result of results) {
329
+ const row = db.prepare(`
330
+ SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at
331
+ FROM memory_entries WHERE id = ?
332
+ `).get(result.id) as {
333
+ id: string;
334
+ key: string;
335
+ content: string;
336
+ type: string;
337
+ namespace: string;
338
+ tags: string;
339
+ embedding: Buffer | null;
340
+ created_at: number;
341
+ updated_at: number;
342
+ } | undefined;
343
+
344
+ if (row) {
345
+ entries.push({
346
+ id: row.id,
347
+ key: row.key,
348
+ content: row.content,
349
+ type: row.type,
350
+ namespace: row.namespace,
351
+ tags: JSON.parse(row.tags || '[]'),
352
+ created_at: row.created_at,
353
+ updated_at: row.updated_at,
354
+ score: result.score,
355
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
356
+ });
357
+ }
196
358
  }
197
- stmt.free();
198
359
 
199
360
  return entries;
200
361
  }
@@ -206,6 +367,7 @@ function getHTML(): string {
206
367
  <meta charset="UTF-8">
207
368
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
208
369
  <title>AgentKits Memory Viewer</title>
370
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%233B82F6'/%3E%3Cstop offset='100%25' stop-color='%238B5CF6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='12' cy='12' r='10' fill='url(%23g)'/%3E%3Cpath d='M10 14.17l-3.17-3.17-1.42 1.41L10 17l8-8-1.41-1.41z' fill='white'/%3E%3C/svg%3E">
209
371
  <style>
210
372
  :root {
211
373
  --bg-primary: #0F172A;
@@ -316,6 +478,202 @@ function getHTML(): string {
316
478
  fill: var(--text-muted);
317
479
  }
318
480
 
481
+ .search-type-select {
482
+ padding: 12px 16px;
483
+ background: var(--bg-secondary);
484
+ border: 1px solid var(--border);
485
+ border-radius: 8px;
486
+ color: var(--text-primary);
487
+ font-size: 14px;
488
+ cursor: pointer;
489
+ min-width: 180px;
490
+ transition: border-color 0.2s;
491
+ }
492
+
493
+ .search-type-select:focus {
494
+ outline: none;
495
+ border-color: var(--accent);
496
+ }
497
+
498
+ .search-type-select:hover { border-color: var(--accent); }
499
+
500
+ .score-badge {
501
+ font-size: 11px;
502
+ padding: 3px 8px;
503
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
504
+ color: var(--accent);
505
+ border-radius: 4px;
506
+ font-weight: 600;
507
+ white-space: nowrap;
508
+ }
509
+
510
+ .vector-badge {
511
+ display: inline-flex;
512
+ align-items: center;
513
+ font-size: 10px;
514
+ font-weight: 600;
515
+ padding: 2px 6px;
516
+ border-radius: 4px;
517
+ text-transform: uppercase;
518
+ letter-spacing: 0.5px;
519
+ }
520
+
521
+ .vector-badge.has-vector {
522
+ background: rgba(34, 197, 94, 0.15);
523
+ color: #22C55E;
524
+ }
525
+
526
+ .vector-badge.no-vector {
527
+ background: rgba(100, 116, 139, 0.1);
528
+ color: var(--text-muted);
529
+ }
530
+
531
+ .embedding-stats {
532
+ display: flex;
533
+ gap: 16px;
534
+ margin-bottom: 20px;
535
+ padding: 16px;
536
+ background: var(--bg-card);
537
+ border-radius: 8px;
538
+ }
539
+
540
+ .embedding-stat {
541
+ text-align: center;
542
+ flex: 1;
543
+ }
544
+
545
+ .embedding-stat-value {
546
+ font-size: 24px;
547
+ font-weight: 700;
548
+ color: var(--text-primary);
549
+ }
550
+
551
+ .embedding-stat-label {
552
+ font-size: 12px;
553
+ color: var(--text-muted);
554
+ text-transform: uppercase;
555
+ }
556
+
557
+ .embedding-stat-value.success { color: var(--success); }
558
+ .embedding-stat-value.warning { color: var(--warning); }
559
+
560
+ .progress-bar {
561
+ height: 8px;
562
+ background: var(--bg-card);
563
+ border-radius: 4px;
564
+ overflow: hidden;
565
+ margin: 16px 0;
566
+ }
567
+
568
+ .progress-bar-fill {
569
+ height: 100%;
570
+ background: linear-gradient(90deg, var(--accent), #8B5CF6);
571
+ border-radius: 4px;
572
+ transition: width 0.3s ease;
573
+ }
574
+
575
+ .progress-text {
576
+ text-align: center;
577
+ font-size: 14px;
578
+ color: var(--text-secondary);
579
+ margin-top: 8px;
580
+ }
581
+
582
+ .btn-icon {
583
+ padding: 10px;
584
+ min-width: auto;
585
+ }
586
+
587
+ .embedding-section {
588
+ margin-top: 20px;
589
+ padding: 16px;
590
+ background: var(--bg-card);
591
+ border-radius: 8px;
592
+ border: 1px solid var(--border);
593
+ }
594
+
595
+ .embedding-header {
596
+ display: flex;
597
+ align-items: center;
598
+ gap: 12px;
599
+ margin-bottom: 12px;
600
+ }
601
+
602
+ .embedding-status {
603
+ display: inline-flex;
604
+ align-items: center;
605
+ gap: 6px;
606
+ font-size: 13px;
607
+ padding: 4px 10px;
608
+ border-radius: 6px;
609
+ }
610
+
611
+ .embedding-status.has-embedding {
612
+ background: rgba(34, 197, 94, 0.15);
613
+ color: #22C55E;
614
+ }
615
+
616
+ .embedding-status.no-embedding {
617
+ background: rgba(239, 68, 68, 0.15);
618
+ color: #EF4444;
619
+ }
620
+
621
+ .embedding-status svg {
622
+ width: 14px;
623
+ height: 14px;
624
+ fill: currentColor;
625
+ }
626
+
627
+ .embedding-dims {
628
+ font-size: 12px;
629
+ color: var(--text-muted);
630
+ }
631
+
632
+ .embedding-viz {
633
+ display: flex;
634
+ align-items: flex-end;
635
+ gap: 2px;
636
+ height: 40px;
637
+ margin-top: 12px;
638
+ padding: 8px;
639
+ background: var(--bg-primary);
640
+ border-radius: 6px;
641
+ overflow: hidden;
642
+ }
643
+
644
+ .embedding-bar {
645
+ flex: 1;
646
+ min-width: 4px;
647
+ border-radius: 2px 2px 0 0;
648
+ transition: height 0.3s ease;
649
+ }
650
+
651
+ .embedding-bar.positive { background: linear-gradient(to top, #3B82F6, #60A5FA); }
652
+ .embedding-bar.negative { background: linear-gradient(to top, #8B5CF6, #A78BFA); }
653
+
654
+ .embedding-legend {
655
+ display: flex;
656
+ justify-content: space-between;
657
+ margin-top: 8px;
658
+ font-size: 11px;
659
+ color: var(--text-muted);
660
+ }
661
+
662
+ .embedding-legend-item {
663
+ display: flex;
664
+ align-items: center;
665
+ gap: 4px;
666
+ }
667
+
668
+ .legend-dot {
669
+ width: 8px;
670
+ height: 8px;
671
+ border-radius: 50%;
672
+ }
673
+
674
+ .legend-dot.positive { background: #3B82F6; }
675
+ .legend-dot.negative { background: #8B5CF6; }
676
+
319
677
  .entries-list { display: flex; flex-direction: column; gap: 12px; }
320
678
 
321
679
  .entry-card {
@@ -341,7 +699,14 @@ function getHTML(): string {
341
699
  gap: 12px;
342
700
  }
343
701
 
344
- .entry-key { font-weight: 600; font-size: 15px; color: var(--text-primary); word-break: break-word; }
702
+ .entry-key { font-weight: 600; font-size: 15px; color: var(--text-primary); word-break: break-word; flex: 1; }
703
+
704
+ .entry-badges {
705
+ display: flex;
706
+ align-items: center;
707
+ gap: 8px;
708
+ flex-shrink: 0;
709
+ }
345
710
 
346
711
  .entry-namespace {
347
712
  font-size: 12px;
@@ -624,6 +989,10 @@ function getHTML(): string {
624
989
  <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
625
990
  Add Memory
626
991
  </button>
992
+ <button class="btn" onclick="openEmbeddingModal()">
993
+ <svg viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
994
+ Embeddings
995
+ </button>
627
996
  <button class="btn" onclick="loadData()">
628
997
  <svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
629
998
  Refresh
@@ -639,6 +1008,11 @@ function getHTML(): string {
639
1008
  <svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
640
1009
  <input type="text" id="search-input" placeholder="Search memories..." oninput="debounceSearch()">
641
1010
  </div>
1011
+ <select id="search-type" class="search-type-select" onchange="debounceSearch()">
1012
+ <option value="hybrid">Hybrid (Text + Semantic)</option>
1013
+ <option value="text">Text Only (FTS5)</option>
1014
+ <option value="vector">Semantic Only (Vector)</option>
1015
+ </select>
642
1016
  </div>
643
1017
 
644
1018
  <div id="entries-container" class="entries-list">
@@ -733,6 +1107,57 @@ function getHTML(): string {
733
1107
  </div>
734
1108
  </div>
735
1109
 
1110
+ <!-- Embedding Management Modal -->
1111
+ <div id="embedding-modal" class="modal-overlay" onclick="closeEmbeddingModal(event)">
1112
+ <div class="modal" style="max-width: 500px;" onclick="event.stopPropagation()">
1113
+ <div class="modal-header">
1114
+ <span class="modal-title">Manage Embeddings</span>
1115
+ <button class="modal-close" onclick="closeEmbeddingModal()">
1116
+ <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
1117
+ </button>
1118
+ </div>
1119
+ <div class="modal-body" id="embedding-modal-body">
1120
+ <div class="embedding-stats" id="embedding-stats">
1121
+ <div class="embedding-stat">
1122
+ <div class="embedding-stat-value" id="stat-total">-</div>
1123
+ <div class="embedding-stat-label">Total</div>
1124
+ </div>
1125
+ <div class="embedding-stat">
1126
+ <div class="embedding-stat-value success" id="stat-with">-</div>
1127
+ <div class="embedding-stat-label">With Embedding</div>
1128
+ </div>
1129
+ <div class="embedding-stat">
1130
+ <div class="embedding-stat-value warning" id="stat-without">-</div>
1131
+ <div class="embedding-stat-label">Without</div>
1132
+ </div>
1133
+ </div>
1134
+ <p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 12px;">
1135
+ Vector embeddings enable semantic search - finding memories by meaning, not just keywords.
1136
+ </p>
1137
+ <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 20px; padding: 10px; background: var(--bg-card); border-radius: 6px;">
1138
+ <strong>Model:</strong> multilingual-e5-small (100+ languages, optimized for retrieval)
1139
+ </p>
1140
+ <div id="embedding-progress" style="display: none;">
1141
+ <div class="progress-bar">
1142
+ <div class="progress-bar-fill" id="progress-fill" style="width: 0%"></div>
1143
+ </div>
1144
+ <div class="progress-text" id="progress-text">Processing...</div>
1145
+ </div>
1146
+ </div>
1147
+ <div class="modal-footer" id="embedding-modal-footer">
1148
+ <button class="btn" onclick="closeEmbeddingModal()">Close</button>
1149
+ <button class="btn btn-primary" id="btn-generate-missing" onclick="generateEmbeddings('missing')">
1150
+ <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
1151
+ Generate Missing
1152
+ </button>
1153
+ <button class="btn btn-success" id="btn-regenerate-all" onclick="generateEmbeddings('all')">
1154
+ <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
1155
+ Re-generate All
1156
+ </button>
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+
736
1161
  <script>
737
1162
  let currentNamespace = '';
738
1163
  let currentSearch = '';
@@ -765,10 +1190,6 @@ function getHTML(): string {
765
1190
  <div class="stat-label">Namespaces</div>
766
1191
  <div class="stat-value">\${Object.keys(stats.byNamespace || {}).length}</div>
767
1192
  </div>
768
- <div class="stat-card">
769
- <div class="stat-label">Types</div>
770
- <div class="stat-value">\${Object.keys(stats.byType || {}).length}</div>
771
- </div>
772
1193
  \`;
773
1194
  }
774
1195
 
@@ -787,13 +1208,32 @@ function getHTML(): string {
787
1208
  const container = document.getElementById('entries-container');
788
1209
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
789
1210
 
790
- const params = new URLSearchParams({ limit: pageSize, offset: currentPage * pageSize });
791
- if (currentNamespace) params.set('namespace', currentNamespace);
792
- if (currentSearch) params.set('search', currentSearch);
793
-
794
1211
  try {
795
- const res = await fetch('/api/entries?' + params);
796
- const entries = await res.json();
1212
+ let entries;
1213
+
1214
+ if (currentSearch && currentSearch.trim()) {
1215
+ // Use hybrid search endpoint when searching
1216
+ const searchType = document.getElementById('search-type').value;
1217
+ const params = new URLSearchParams({
1218
+ q: currentSearch,
1219
+ type: searchType,
1220
+ limit: String(pageSize)
1221
+ });
1222
+ if (currentNamespace) params.set('namespace', currentNamespace);
1223
+
1224
+ const res = await fetch('/api/search?' + params);
1225
+ entries = await res.json();
1226
+ } else {
1227
+ // Use standard entries endpoint for listing
1228
+ const params = new URLSearchParams({
1229
+ limit: String(pageSize),
1230
+ offset: String(currentPage * pageSize)
1231
+ });
1232
+ if (currentNamespace) params.set('namespace', currentNamespace);
1233
+
1234
+ const res = await fetch('/api/entries?' + params);
1235
+ entries = await res.json();
1236
+ }
797
1237
 
798
1238
  if (!Array.isArray(entries) || entries.length === 0) {
799
1239
  container.innerHTML = \`
@@ -808,7 +1248,14 @@ function getHTML(): string {
808
1248
  <div class="entry-card" onclick="showDetail('\${entry.id}')">
809
1249
  <div class="entry-header">
810
1250
  <span class="entry-key">\${escapeHtml(entry.key)}</span>
811
- <span class="entry-namespace">\${entry.namespace}</span>
1251
+ <div class="entry-badges">
1252
+ \${entry.score !== undefined ? \`<span class="score-badge">\${(entry.score * 100).toFixed(1)}%</span>\` : ''}
1253
+ \${entry.hasEmbedding ?
1254
+ \`<span class="vector-badge has-vector" title="Vector embedding enabled">Vec</span>\` :
1255
+ \`<span class="vector-badge no-vector" title="No vector embedding">--</span>\`
1256
+ }
1257
+ <span class="entry-namespace">\${entry.namespace}</span>
1258
+ </div>
812
1259
  </div>
813
1260
  <div class="entry-content truncated">\${escapeHtml(entry.content)}</div>
814
1261
  <div class="entry-footer">
@@ -857,6 +1304,59 @@ function getHTML(): string {
857
1304
  function prevPage() { if (currentPage > 0) { currentPage--; loadEntries(); } }
858
1305
  function nextPage() { currentPage++; loadEntries(); }
859
1306
 
1307
+ function renderEmbeddingViz(embedding) {
1308
+ if (!embedding || !embedding.hasEmbedding) {
1309
+ return \`
1310
+ <div class="embedding-section">
1311
+ <div class="embedding-header">
1312
+ <span class="embedding-status no-embedding">
1313
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
1314
+ No Embedding
1315
+ </span>
1316
+ </div>
1317
+ <p style="font-size: 12px; color: var(--text-muted); margin: 0;">
1318
+ This entry doesn't have a vector embedding. Edit and save to generate one.
1319
+ </p>
1320
+ </div>
1321
+ \`;
1322
+ }
1323
+
1324
+ const preview = embedding.preview || [];
1325
+ const maxVal = Math.max(...preview.map(Math.abs), 0.001);
1326
+
1327
+ const bars = preview.map(val => {
1328
+ const height = Math.abs(val) / maxVal * 100;
1329
+ const isPositive = val >= 0;
1330
+ return \`<div class="embedding-bar \${isPositive ? 'positive' : 'negative'}" style="height: \${height}%" title="\${val.toFixed(4)}"></div>\`;
1331
+ }).join('');
1332
+
1333
+ return \`
1334
+ <div class="embedding-section">
1335
+ <div class="embedding-header">
1336
+ <span class="embedding-status has-embedding">
1337
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
1338
+ Vector Enabled
1339
+ </span>
1340
+ <span class="embedding-dims">\${embedding.dimensions}D</span>
1341
+ </div>
1342
+ <div class="embedding-viz">
1343
+ \${bars}
1344
+ </div>
1345
+ <div class="embedding-legend">
1346
+ <div class="embedding-legend-item">
1347
+ <span class="legend-dot positive"></span>
1348
+ Positive values
1349
+ </div>
1350
+ <div class="embedding-legend-item">
1351
+ <span class="legend-dot negative"></span>
1352
+ Negative values
1353
+ </div>
1354
+ <span>First 20 dimensions</span>
1355
+ </div>
1356
+ </div>
1357
+ \`;
1358
+ }
1359
+
860
1360
  async function showDetail(id) {
861
1361
  try {
862
1362
  const res = await fetch('/api/entry/' + id);
@@ -887,6 +1387,7 @@ function getHTML(): string {
887
1387
  <div class="detail-label">Created</div>
888
1388
  <div class="detail-value">\${new Date(entry.created_at).toLocaleString()}</div>
889
1389
  </div>
1390
+ \${renderEmbeddingViz(entry.embedding)}
890
1391
  <div class="detail-actions">
891
1392
  <button class="btn btn-primary" onclick="openEditModal('\${entry.id}')">
892
1393
  <svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
@@ -1008,6 +1509,85 @@ function getHTML(): string {
1008
1509
  }
1009
1510
  }
1010
1511
 
1512
+ async function openEmbeddingModal() {
1513
+ document.getElementById('embedding-modal').classList.add('active');
1514
+ document.getElementById('embedding-progress').style.display = 'none';
1515
+ document.getElementById('embedding-modal-footer').style.display = 'flex';
1516
+
1517
+ // Load embedding stats
1518
+ try {
1519
+ const res = await fetch('/api/embeddings/stats');
1520
+ const embeddingStats = await res.json();
1521
+ document.getElementById('stat-total').textContent = embeddingStats.total;
1522
+ document.getElementById('stat-with').textContent = embeddingStats.withEmbedding;
1523
+ document.getElementById('stat-without').textContent = embeddingStats.withoutEmbedding;
1524
+
1525
+ // Disable buttons if nothing to do
1526
+ document.getElementById('btn-generate-missing').disabled = embeddingStats.withoutEmbedding === 0;
1527
+ document.getElementById('btn-regenerate-all').disabled = embeddingStats.total === 0;
1528
+ } catch (error) {
1529
+ console.error('Failed to load embedding stats:', error);
1530
+ }
1531
+ }
1532
+
1533
+ function closeEmbeddingModal(event) {
1534
+ if (!event || event.target.id === 'embedding-modal') {
1535
+ document.getElementById('embedding-modal').classList.remove('active');
1536
+ }
1537
+ }
1538
+
1539
+ async function generateEmbeddings(mode) {
1540
+ const progressEl = document.getElementById('embedding-progress');
1541
+ const progressFill = document.getElementById('progress-fill');
1542
+ const progressText = document.getElementById('progress-text');
1543
+ const footerEl = document.getElementById('embedding-modal-footer');
1544
+
1545
+ // Show progress, hide buttons
1546
+ progressEl.style.display = 'block';
1547
+ footerEl.style.display = 'none';
1548
+ progressFill.style.width = '0%';
1549
+ progressText.textContent = mode === 'missing' ? 'Generating missing embeddings...' : 'Re-generating all embeddings...';
1550
+
1551
+ // Animate progress bar while waiting
1552
+ let progress = 0;
1553
+ const interval = setInterval(() => {
1554
+ progress += Math.random() * 10;
1555
+ if (progress > 90) progress = 90;
1556
+ progressFill.style.width = progress + '%';
1557
+ }, 200);
1558
+
1559
+ try {
1560
+ const res = await fetch('/api/embeddings/generate', {
1561
+ method: 'POST',
1562
+ headers: { 'Content-Type': 'application/json' },
1563
+ body: JSON.stringify({ mode }),
1564
+ });
1565
+
1566
+ clearInterval(interval);
1567
+ progressFill.style.width = '100%';
1568
+
1569
+ const result = await res.json();
1570
+ progressText.textContent = result.message || 'Done!';
1571
+
1572
+ setTimeout(() => {
1573
+ showToast(result.message || 'Embeddings generated', 'success');
1574
+ closeEmbeddingModal();
1575
+ loadData();
1576
+ }, 1000);
1577
+ } catch (error) {
1578
+ clearInterval(interval);
1579
+ progressFill.style.width = '0%';
1580
+ progressText.textContent = 'Failed to generate embeddings';
1581
+ showToast('Failed to generate embeddings', 'error');
1582
+
1583
+ // Re-show buttons after error
1584
+ setTimeout(() => {
1585
+ progressEl.style.display = 'none';
1586
+ footerEl.style.display = 'flex';
1587
+ }, 2000);
1588
+ }
1589
+ }
1590
+
1011
1591
  function showToast(message, type = 'success') {
1012
1592
  const toast = document.createElement('div');
1013
1593
  toast.className = 'toast ' + type;
@@ -1042,6 +1622,7 @@ function getHTML(): string {
1042
1622
  closeDetailModal();
1043
1623
  closeFormModal();
1044
1624
  closeDeleteModal();
1625
+ closeEmbeddingModal();
1045
1626
  }
1046
1627
  });
1047
1628
 
@@ -1060,24 +1641,23 @@ async function readBody(req: http.IncomingMessage): Promise<string> {
1060
1641
  });
1061
1642
  }
1062
1643
 
1063
- async function handleRequest(
1644
+ function handleRequest(
1064
1645
  req: http.IncomingMessage,
1065
1646
  res: http.ServerResponse
1066
- ): Promise<void> {
1647
+ ): void {
1067
1648
  const url = new URL(req.url || '/', `http://localhost:${PORT}`);
1068
1649
  const method = req.method || 'GET';
1069
1650
 
1070
1651
  res.setHeader('Content-Type', 'application/json');
1071
1652
 
1072
1653
  try {
1073
- const db = await loadOrCreateDatabase();
1654
+ const db = getDatabase();
1074
1655
 
1075
1656
  // Serve HTML
1076
1657
  if (url.pathname === '/' && method === 'GET') {
1077
1658
  res.setHeader('Content-Type', 'text/html');
1078
1659
  res.writeHead(200);
1079
1660
  res.end(getHTML());
1080
- db.close();
1081
1661
  return;
1082
1662
  }
1083
1663
 
@@ -1086,11 +1666,10 @@ async function handleRequest(
1086
1666
  const stats = getStats(db);
1087
1667
  res.writeHead(200);
1088
1668
  res.end(JSON.stringify(stats));
1089
- db.close();
1090
1669
  return;
1091
1670
  }
1092
1671
 
1093
- // GET entries
1672
+ // GET entries (standard listing with optional FTS search)
1094
1673
  if (url.pathname === '/api/entries' && method === 'GET') {
1095
1674
  const namespace = url.searchParams.get('namespace') || undefined;
1096
1675
  const limit = parseInt(url.searchParams.get('limit') || '50', 10);
@@ -1100,38 +1679,107 @@ async function handleRequest(
1100
1679
  const entries = getEntries(db, namespace, limit, offset, search);
1101
1680
  res.writeHead(200);
1102
1681
  res.end(JSON.stringify(entries));
1103
- db.close();
1104
1682
  return;
1105
1683
  }
1106
1684
 
1107
- // POST create entry
1685
+ // GET hybrid search (new endpoint with vector support)
1686
+ if (url.pathname === '/api/search' && method === 'GET') {
1687
+ const query = url.searchParams.get('q') || '';
1688
+ const searchType = (url.searchParams.get('type') || 'hybrid') as 'hybrid' | 'text' | 'vector';
1689
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
1690
+ const namespace = url.searchParams.get('namespace') || undefined;
1691
+
1692
+ getSearchEngine()
1693
+ .then((searchEngine) => searchEntries(searchEngine, query, { type: searchType, namespace, limit }))
1694
+ .then((results) => {
1695
+ res.writeHead(200);
1696
+ res.end(JSON.stringify(results));
1697
+ })
1698
+ .catch((error) => {
1699
+ res.writeHead(500);
1700
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Search failed' }));
1701
+ });
1702
+ return;
1703
+ }
1704
+
1705
+ // POST create entry (direct DB for compatibility with existing schema)
1108
1706
  if (url.pathname === '/api/entries' && method === 'POST') {
1109
- const body = await readBody(req);
1110
- const data = JSON.parse(body);
1111
- const now = Date.now();
1112
- const id = generateId();
1113
-
1114
- db.run(
1115
- `INSERT INTO memory_entries (id, key, content, type, namespace, tags, created_at, updated_at)
1116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1117
- [id, data.key, data.content, data.type || 'semantic', data.namespace || 'general', JSON.stringify(data.tags || []), now, now]
1118
- );
1119
-
1120
- saveDatabase(db);
1121
- res.writeHead(201);
1122
- res.end(JSON.stringify({ id, success: true }));
1123
- db.close();
1707
+ readBody(req)
1708
+ .then(async (body) => {
1709
+ const data = JSON.parse(body) as {
1710
+ key: string;
1711
+ content: string;
1712
+ type?: string;
1713
+ namespace?: string;
1714
+ tags?: string[];
1715
+ };
1716
+
1717
+ const now = Date.now();
1718
+ const id = `mem_${now}_${Math.random().toString(36).slice(2, 10)}`;
1719
+ const tags = JSON.stringify(data.tags || []);
1720
+
1721
+ // Generate embedding for the content
1722
+ let embeddingBuffer: Buffer | null = null;
1723
+ try {
1724
+ const embeddingsService = await getEmbeddingsService();
1725
+ const result = await embeddingsService.embed(data.content);
1726
+ embeddingBuffer = Buffer.from(result.embedding.buffer);
1727
+ } catch (e) {
1728
+ console.warn('[WebViewer] Failed to generate embedding:', e);
1729
+ }
1730
+
1731
+ db.prepare(
1732
+ `INSERT INTO memory_entries (id, key, content, type, namespace, tags, embedding, created_at, updated_at)
1733
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
1734
+ ).run(id, data.key, data.content, data.type || 'semantic', data.namespace || 'general', tags, embeddingBuffer, now, now);
1735
+
1736
+ res.writeHead(201);
1737
+ res.end(JSON.stringify({ id, success: true }));
1738
+ })
1739
+ .catch((error) => {
1740
+ res.writeHead(500);
1741
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1742
+ });
1124
1743
  return;
1125
1744
  }
1126
1745
 
1127
1746
  // GET single entry
1128
1747
  if (url.pathname.startsWith('/api/entry/') && method === 'GET') {
1129
1748
  const id = url.pathname.split('/').pop();
1130
- const stmt = db.prepare('SELECT * FROM memory_entries WHERE id = ?');
1131
- stmt.bind([id]);
1749
+ const row = db.prepare('SELECT * FROM memory_entries WHERE id = ?').get(id) as {
1750
+ id: string;
1751
+ key: string;
1752
+ content: string;
1753
+ type: string;
1754
+ namespace: string;
1755
+ tags: string;
1756
+ embedding: Buffer | null;
1757
+ created_at: number;
1758
+ updated_at: number;
1759
+ } | undefined;
1760
+
1761
+ if (row) {
1762
+ // Extract embedding info for visualization
1763
+ let embeddingInfo: { hasEmbedding: boolean; dimensions?: number; preview?: number[] } = {
1764
+ hasEmbedding: false,
1765
+ };
1766
+
1767
+ if (row.embedding && row.embedding.length > 0) {
1768
+ const embedding = new Float32Array(
1769
+ row.embedding.buffer.slice(
1770
+ row.embedding.byteOffset,
1771
+ row.embedding.byteOffset + row.embedding.byteLength
1772
+ )
1773
+ );
1774
+ // Get first 20 values for preview visualization
1775
+ const preview = Array.from(embedding.slice(0, 20));
1776
+ embeddingInfo = {
1777
+ hasEmbedding: true,
1778
+ dimensions: embedding.length,
1779
+ preview,
1780
+ };
1781
+ }
1132
1782
 
1133
- if (stmt.step()) {
1134
- const row = stmt.getAsObject();
1135
1783
  res.writeHead(200);
1136
1784
  res.end(JSON.stringify({
1137
1785
  id: row.id,
@@ -1139,53 +1787,159 @@ async function handleRequest(
1139
1787
  content: row.content,
1140
1788
  type: row.type,
1141
1789
  namespace: row.namespace,
1142
- tags: JSON.parse((row.tags as string) || '[]'),
1790
+ tags: JSON.parse(row.tags || '[]'),
1143
1791
  created_at: row.created_at,
1144
1792
  updated_at: row.updated_at,
1793
+ embedding: embeddingInfo,
1145
1794
  }));
1146
1795
  } else {
1147
1796
  res.writeHead(404);
1148
1797
  res.end(JSON.stringify({ error: 'Entry not found' }));
1149
1798
  }
1150
- stmt.free();
1151
- db.close();
1152
1799
  return;
1153
1800
  }
1154
1801
 
1155
- // PUT update entry
1802
+ // PUT update entry (direct DB for full field updates)
1156
1803
  if (url.pathname.startsWith('/api/entry/') && method === 'PUT') {
1157
1804
  const id = url.pathname.split('/').pop();
1158
- const body = await readBody(req);
1159
- const data = JSON.parse(body);
1160
- const now = Date.now();
1161
-
1162
- db.run(
1163
- `UPDATE memory_entries SET key = ?, content = ?, type = ?, namespace = ?, tags = ?, updated_at = ?
1164
- WHERE id = ?`,
1165
- [data.key, data.content, data.type, data.namespace, JSON.stringify(data.tags || []), now, id]
1166
- );
1805
+ if (!id) {
1806
+ res.writeHead(400);
1807
+ res.end(JSON.stringify({ error: 'Missing entry ID' }));
1808
+ return;
1809
+ }
1167
1810
 
1168
- saveDatabase(db);
1169
- res.writeHead(200);
1170
- res.end(JSON.stringify({ success: true }));
1171
- db.close();
1811
+ readBody(req)
1812
+ .then(async (body) => {
1813
+ const data = JSON.parse(body) as {
1814
+ key: string;
1815
+ content: string;
1816
+ type: string;
1817
+ namespace: string;
1818
+ tags?: string[];
1819
+ };
1820
+ const now = Date.now();
1821
+ const tags = JSON.stringify(data.tags || []);
1822
+
1823
+ // Generate embedding for the updated content
1824
+ let embeddingBuffer: Buffer | null = null;
1825
+ try {
1826
+ const embeddingsService = await getEmbeddingsService();
1827
+ const result = await embeddingsService.embed(data.content);
1828
+ embeddingBuffer = Buffer.from(result.embedding.buffer);
1829
+ } catch (e) {
1830
+ console.warn('[WebViewer] Failed to generate embedding:', e);
1831
+ }
1832
+
1833
+ // Update with embedding
1834
+ const result = db.prepare(
1835
+ `UPDATE memory_entries SET key = ?, content = ?, type = ?, namespace = ?, tags = ?, embedding = ?, updated_at = ?
1836
+ WHERE id = ?`
1837
+ ).run(data.key, data.content, data.type, data.namespace, tags, embeddingBuffer, now, id);
1838
+
1839
+ if (result.changes > 0) {
1840
+ res.writeHead(200);
1841
+ res.end(JSON.stringify({ success: true }));
1842
+ } else {
1843
+ res.writeHead(404);
1844
+ res.end(JSON.stringify({ error: 'Entry not found' }));
1845
+ }
1846
+ })
1847
+ .catch((error) => {
1848
+ res.writeHead(500);
1849
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1850
+ });
1172
1851
  return;
1173
1852
  }
1174
1853
 
1175
- // DELETE entry
1854
+ // DELETE entry (direct DB for compatibility)
1176
1855
  if (url.pathname.startsWith('/api/entry/') && method === 'DELETE') {
1177
1856
  const id = url.pathname.split('/').pop();
1178
- db.run('DELETE FROM memory_entries WHERE id = ?', [id]);
1179
- saveDatabase(db);
1857
+ if (!id) {
1858
+ res.writeHead(400);
1859
+ res.end(JSON.stringify({ error: 'Missing entry ID' }));
1860
+ return;
1861
+ }
1862
+
1863
+ const result = db.prepare('DELETE FROM memory_entries WHERE id = ?').run(id);
1864
+ if (result.changes > 0) {
1865
+ res.writeHead(200);
1866
+ res.end(JSON.stringify({ success: true }));
1867
+ } else {
1868
+ res.writeHead(404);
1869
+ res.end(JSON.stringify({ error: 'Entry not found' }));
1870
+ }
1871
+ return;
1872
+ }
1873
+
1874
+ // GET embedding stats
1875
+ if (url.pathname === '/api/embeddings/stats' && method === 'GET') {
1876
+ const totalRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries').get() as { count: number };
1877
+ const withEmbeddingRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0').get() as { count: number };
1878
+
1180
1879
  res.writeHead(200);
1181
- res.end(JSON.stringify({ success: true }));
1182
- db.close();
1880
+ res.end(JSON.stringify({
1881
+ total: totalRow?.count || 0,
1882
+ withEmbedding: withEmbeddingRow?.count || 0,
1883
+ withoutEmbedding: (totalRow?.count || 0) - (withEmbeddingRow?.count || 0),
1884
+ }));
1885
+ return;
1886
+ }
1887
+
1888
+ // POST batch generate embeddings
1889
+ if (url.pathname === '/api/embeddings/generate' && method === 'POST') {
1890
+ readBody(req)
1891
+ .then(async (body) => {
1892
+ const options = JSON.parse(body || '{}') as { mode?: 'missing' | 'all' };
1893
+ const mode = options.mode || 'missing';
1894
+
1895
+ // Get entries to process
1896
+ const query = mode === 'missing'
1897
+ ? 'SELECT id, content FROM memory_entries WHERE embedding IS NULL OR LENGTH(embedding) = 0'
1898
+ : 'SELECT id, content FROM memory_entries';
1899
+
1900
+ const entries = db.prepare(query).all() as { id: string; content: string }[];
1901
+
1902
+ if (entries.length === 0) {
1903
+ res.writeHead(200);
1904
+ res.end(JSON.stringify({ processed: 0, success: 0, failed: 0, message: 'No entries to process' }));
1905
+ return;
1906
+ }
1907
+
1908
+ const embeddingsService = await getEmbeddingsService();
1909
+ let success = 0;
1910
+ let failed = 0;
1911
+
1912
+ const updateStmt = db.prepare('UPDATE memory_entries SET embedding = ?, updated_at = ? WHERE id = ?');
1913
+
1914
+ for (const entry of entries) {
1915
+ try {
1916
+ const result = await embeddingsService.embed(entry.content);
1917
+ const embeddingBuffer = Buffer.from(result.embedding.buffer);
1918
+ updateStmt.run(embeddingBuffer, Date.now(), entry.id);
1919
+ success++;
1920
+ } catch (e) {
1921
+ console.warn(`[WebViewer] Failed to generate embedding for ${entry.id}:`, e);
1922
+ failed++;
1923
+ }
1924
+ }
1925
+
1926
+ res.writeHead(200);
1927
+ res.end(JSON.stringify({
1928
+ processed: entries.length,
1929
+ success,
1930
+ failed,
1931
+ message: `Generated embeddings for ${success} entries${failed > 0 ? `, ${failed} failed` : ''}`,
1932
+ }));
1933
+ })
1934
+ .catch((error) => {
1935
+ res.writeHead(500);
1936
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1937
+ });
1183
1938
  return;
1184
1939
  }
1185
1940
 
1186
1941
  res.writeHead(404);
1187
1942
  res.end(JSON.stringify({ error: 'Not found' }));
1188
- db.close();
1189
1943
  } catch (error) {
1190
1944
  res.writeHead(500);
1191
1945
  res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));