@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.
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 +6 -4
  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]
@@ -10,12 +11,13 @@
10
11
  * @module @aitytech/agentkits-memory/cli/web-viewer
11
12
  */
12
13
  import * as http from 'node:http';
13
- import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
- import { createRequire } from 'node:module';
16
- import initSqlJs from 'sql.js';
15
+ import Database from 'better-sqlite3';
16
+ import { HybridSearchEngine, LocalEmbeddingsService } from '../index.js';
17
17
  const args = process.argv.slice(2);
18
18
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
19
+ // Embeddings service singleton
20
+ let _embeddingsService = null;
19
21
  function parseArgs() {
20
22
  const parsed = {};
21
23
  for (const arg of args) {
@@ -30,105 +32,162 @@ const options = parseArgs();
30
32
  const PORT = parseInt(options.port, 10) || 1905;
31
33
  const dbDir = path.join(projectDir, '.claude/memory');
32
34
  const dbPath = path.join(dbDir, 'memory.db');
33
- let SQL;
34
- async function initSQL() {
35
- if (!SQL) {
36
- const require = createRequire(import.meta.url);
37
- const sqlJsPath = require.resolve('sql.js');
38
- SQL = await initSqlJs({
39
- locateFile: (file) => path.join(path.dirname(sqlJsPath), file),
40
- });
41
- }
42
- }
43
- async function loadOrCreateDatabase() {
44
- await initSQL();
45
- // Ensure directory exists
46
- if (!fs.existsSync(dbDir)) {
47
- fs.mkdirSync(dbDir, { recursive: true });
48
- }
49
- let db;
50
- if (fs.existsSync(dbPath)) {
51
- const buffer = fs.readFileSync(dbPath);
52
- db = new SQL.Database(new Uint8Array(buffer));
53
- }
54
- else {
55
- db = new SQL.Database();
56
- }
57
- // Create table if not exists
58
- db.run(`
59
- CREATE TABLE IF NOT EXISTS memory_entries (
60
- id TEXT PRIMARY KEY,
61
- key TEXT NOT NULL,
62
- content TEXT NOT NULL,
63
- type TEXT DEFAULT 'semantic',
64
- namespace TEXT DEFAULT 'general',
65
- tags TEXT DEFAULT '[]',
66
- metadata TEXT DEFAULT '{}',
67
- embedding BLOB,
68
- created_at INTEGER NOT NULL,
69
- updated_at INTEGER NOT NULL,
70
- accessed_at INTEGER,
71
- access_count INTEGER DEFAULT 0,
72
- importance REAL DEFAULT 0.5,
73
- decay_rate REAL DEFAULT 0.1
74
- )
75
- `);
76
- db.run(`CREATE INDEX IF NOT EXISTS idx_namespace ON memory_entries(namespace)`);
77
- db.run(`CREATE INDEX IF NOT EXISTS idx_key ON memory_entries(key)`);
78
- db.run(`CREATE INDEX IF NOT EXISTS idx_created ON memory_entries(created_at)`);
79
- return db;
35
+ // Singleton database and search engine
36
+ let _searchEngine = null;
37
+ let _db = null;
38
+ /**
39
+ * Get direct database access
40
+ */
41
+ function getDatabase() {
42
+ if (_db)
43
+ return _db;
44
+ _db = new Database(dbPath);
45
+ _db.pragma('journal_mode = WAL');
46
+ return _db;
80
47
  }
81
- function saveDatabase(db) {
82
- const data = db.export();
83
- const buffer = Buffer.from(data);
84
- fs.writeFileSync(dbPath, buffer);
48
+ /**
49
+ * Get or initialize embeddings service
50
+ */
51
+ async function getEmbeddingsService() {
52
+ if (_embeddingsService)
53
+ return _embeddingsService;
54
+ _embeddingsService = new LocalEmbeddingsService({
55
+ cacheDir: path.join(dbDir, 'embeddings-cache'),
56
+ });
57
+ await _embeddingsService.initialize();
58
+ return _embeddingsService;
85
59
  }
86
- function generateId() {
87
- return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
60
+ /**
61
+ * Get or initialize the HybridSearchEngine with embeddings
62
+ */
63
+ async function getSearchEngine() {
64
+ if (_searchEngine)
65
+ return _searchEngine;
66
+ const db = getDatabase();
67
+ const embeddings = await getEmbeddingsService();
68
+ // Create embedding generator function
69
+ const embeddingGenerator = async (text) => {
70
+ const result = await embeddings.embed(text);
71
+ return result.embedding;
72
+ };
73
+ _searchEngine = new HybridSearchEngine(db, {}, embeddingGenerator);
74
+ await _searchEngine.initialize();
75
+ return _searchEngine;
88
76
  }
77
+ /**
78
+ * Get database statistics using direct SQL (faster for stats queries)
79
+ */
89
80
  function getStats(db) {
90
- const totalResult = db.exec('SELECT COUNT(*) as count FROM memory_entries');
91
- const total = totalResult[0]?.values[0]?.[0] || 0;
92
- const nsResult = db.exec('SELECT namespace, COUNT(*) FROM memory_entries GROUP BY namespace');
81
+ const totalRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries').get();
82
+ const total = totalRow?.count || 0;
83
+ const nsRows = db.prepare('SELECT namespace, COUNT(*) as count FROM memory_entries GROUP BY namespace').all();
93
84
  const byNamespace = {};
94
- if (nsResult[0]) {
95
- for (const row of nsResult[0].values) {
96
- byNamespace[row[0]] = row[1];
97
- }
85
+ for (const row of nsRows) {
86
+ byNamespace[row.namespace] = row.count;
98
87
  }
99
- const typeResult = db.exec('SELECT type, COUNT(*) FROM memory_entries GROUP BY type');
88
+ const typeRows = db.prepare('SELECT type, COUNT(*) as count FROM memory_entries GROUP BY type').all();
100
89
  const byType = {};
101
- if (typeResult[0]) {
102
- for (const row of typeResult[0].values) {
103
- byType[row[0]] = row[1];
104
- }
105
- }
106
- return { total, byNamespace, byType };
90
+ for (const row of typeRows) {
91
+ byType[row.type] = row.count;
92
+ }
93
+ // Calculate token economics
94
+ const contentRow = db.prepare('SELECT SUM(LENGTH(content)) as total_chars, COUNT(*) as count FROM memory_entries').get();
95
+ const totalCharacters = contentRow?.total_chars || 0;
96
+ const entryCount = contentRow?.count || 0;
97
+ // Estimate tokens (~4 chars per token)
98
+ const totalTokens = Math.ceil(totalCharacters / 4);
99
+ const avgTokensPerEntry = entryCount > 0 ? Math.ceil(totalTokens / entryCount) : 0;
100
+ // Estimated savings: if you had to rediscover this info each time
101
+ // Assume 5x overhead for discovery vs recall
102
+ const estimatedSavings = totalTokens * 5;
103
+ return {
104
+ total,
105
+ byNamespace,
106
+ byType,
107
+ tokenEconomics: {
108
+ totalTokens,
109
+ avgTokensPerEntry,
110
+ totalCharacters,
111
+ estimatedSavings,
112
+ },
113
+ };
107
114
  }
115
+ /**
116
+ * Get entries with optional search (standard listing)
117
+ */
108
118
  function getEntries(db, namespace, limit = 50, offset = 0, search) {
109
- let query = 'SELECT id, key, content, type, namespace, tags, created_at, updated_at FROM memory_entries';
110
- const conditions = [];
111
- const params = [];
119
+ // Standard query without search
120
+ if (!search || !search.trim()) {
121
+ let query = 'SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at FROM memory_entries';
122
+ const conditions = [];
123
+ const params = [];
124
+ if (namespace) {
125
+ conditions.push('namespace = ?');
126
+ params.push(namespace);
127
+ }
128
+ if (conditions.length > 0) {
129
+ query += ' WHERE ' + conditions.join(' AND ');
130
+ }
131
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
132
+ params.push(limit, offset);
133
+ const rows = db.prepare(query).all(...params);
134
+ return rows.map((row) => ({
135
+ id: row.id,
136
+ key: row.key,
137
+ content: row.content,
138
+ type: row.type,
139
+ namespace: row.namespace,
140
+ tags: JSON.parse(row.tags || '[]'),
141
+ created_at: row.created_at,
142
+ updated_at: row.updated_at,
143
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
144
+ }));
145
+ }
146
+ // Use FTS5 search for better CJK support
147
+ const sanitizedSearch = search.trim().replace(/"/g, '""');
148
+ let ftsQuery = `
149
+ SELECT m.id, m.key, m.content, m.type, m.namespace, m.tags, m.embedding, m.created_at, m.updated_at
150
+ FROM memory_entries m
151
+ INNER JOIN memory_fts f ON m.id = f.id
152
+ WHERE memory_fts MATCH '"${sanitizedSearch}"'
153
+ `;
112
154
  if (namespace) {
113
- conditions.push('namespace = ?');
114
- params.push(namespace);
155
+ ftsQuery += ` AND m.namespace = ?`;
115
156
  }
116
- if (search) {
157
+ ftsQuery += ` ORDER BY m.created_at DESC LIMIT ? OFFSET ?`;
158
+ try {
159
+ const params = namespace ? [namespace, limit, offset] : [limit, offset];
160
+ const rows = db.prepare(ftsQuery).all(...params);
161
+ return rows.map((row) => ({
162
+ id: row.id,
163
+ key: row.key,
164
+ content: row.content,
165
+ type: row.type,
166
+ namespace: row.namespace,
167
+ tags: JSON.parse(row.tags || '[]'),
168
+ created_at: row.created_at,
169
+ updated_at: row.updated_at,
170
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
171
+ }));
172
+ }
173
+ catch {
174
+ // Fallback to LIKE if FTS fails
175
+ console.warn('[WebViewer] FTS search failed, falling back to LIKE');
176
+ let query = 'SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at FROM memory_entries';
177
+ const conditions = [];
178
+ const params = [];
179
+ if (namespace) {
180
+ conditions.push('namespace = ?');
181
+ params.push(namespace);
182
+ }
117
183
  conditions.push('(content LIKE ? OR key LIKE ? OR tags LIKE ?)');
118
184
  const searchPattern = `%${search}%`;
119
185
  params.push(searchPattern, searchPattern, searchPattern);
120
- }
121
- if (conditions.length > 0) {
122
186
  query += ' WHERE ' + conditions.join(' AND ');
123
- }
124
- query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
125
- params.push(limit, offset);
126
- const stmt = db.prepare(query);
127
- stmt.bind(params);
128
- const entries = [];
129
- while (stmt.step()) {
130
- const row = stmt.getAsObject();
131
- entries.push({
187
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
188
+ params.push(limit, offset);
189
+ const rows = db.prepare(query).all(...params);
190
+ return rows.map((row) => ({
132
191
  id: row.id,
133
192
  key: row.key,
134
193
  content: row.content,
@@ -137,9 +196,46 @@ function getEntries(db, namespace, limit = 50, offset = 0, search) {
137
196
  tags: JSON.parse(row.tags || '[]'),
138
197
  created_at: row.created_at,
139
198
  updated_at: row.updated_at,
140
- });
199
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
200
+ }));
201
+ }
202
+ }
203
+ /**
204
+ * Search entries using HybridSearchEngine
205
+ * Supports hybrid (text + vector), text-only, or vector-only search
206
+ */
207
+ async function searchEntries(searchEngine, query, options = {}) {
208
+ const { type = 'hybrid', namespace, limit = 20 } = options;
209
+ // Use searchCompact for efficient search with scores
210
+ const results = await searchEngine.searchCompact(query, {
211
+ limit,
212
+ namespace,
213
+ includeKeyword: type === 'hybrid' || type === 'text',
214
+ includeSemantic: type === 'hybrid' || type === 'vector',
215
+ });
216
+ // Fetch full entries for the results
217
+ const db = getDatabase();
218
+ const entries = [];
219
+ for (const result of results) {
220
+ const row = db.prepare(`
221
+ SELECT id, key, content, type, namespace, tags, embedding, created_at, updated_at
222
+ FROM memory_entries WHERE id = ?
223
+ `).get(result.id);
224
+ if (row) {
225
+ entries.push({
226
+ id: row.id,
227
+ key: row.key,
228
+ content: row.content,
229
+ type: row.type,
230
+ namespace: row.namespace,
231
+ tags: JSON.parse(row.tags || '[]'),
232
+ created_at: row.created_at,
233
+ updated_at: row.updated_at,
234
+ score: result.score,
235
+ hasEmbedding: !!(row.embedding && row.embedding.length > 0),
236
+ });
237
+ }
141
238
  }
142
- stmt.free();
143
239
  return entries;
144
240
  }
145
241
  function getHTML() {
@@ -149,6 +245,7 @@ function getHTML() {
149
245
  <meta charset="UTF-8">
150
246
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
151
247
  <title>AgentKits Memory Viewer</title>
248
+ <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">
152
249
  <style>
153
250
  :root {
154
251
  --bg-primary: #0F172A;
@@ -259,6 +356,202 @@ function getHTML() {
259
356
  fill: var(--text-muted);
260
357
  }
261
358
 
359
+ .search-type-select {
360
+ padding: 12px 16px;
361
+ background: var(--bg-secondary);
362
+ border: 1px solid var(--border);
363
+ border-radius: 8px;
364
+ color: var(--text-primary);
365
+ font-size: 14px;
366
+ cursor: pointer;
367
+ min-width: 180px;
368
+ transition: border-color 0.2s;
369
+ }
370
+
371
+ .search-type-select:focus {
372
+ outline: none;
373
+ border-color: var(--accent);
374
+ }
375
+
376
+ .search-type-select:hover { border-color: var(--accent); }
377
+
378
+ .score-badge {
379
+ font-size: 11px;
380
+ padding: 3px 8px;
381
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
382
+ color: var(--accent);
383
+ border-radius: 4px;
384
+ font-weight: 600;
385
+ white-space: nowrap;
386
+ }
387
+
388
+ .vector-badge {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ font-size: 10px;
392
+ font-weight: 600;
393
+ padding: 2px 6px;
394
+ border-radius: 4px;
395
+ text-transform: uppercase;
396
+ letter-spacing: 0.5px;
397
+ }
398
+
399
+ .vector-badge.has-vector {
400
+ background: rgba(34, 197, 94, 0.15);
401
+ color: #22C55E;
402
+ }
403
+
404
+ .vector-badge.no-vector {
405
+ background: rgba(100, 116, 139, 0.1);
406
+ color: var(--text-muted);
407
+ }
408
+
409
+ .embedding-stats {
410
+ display: flex;
411
+ gap: 16px;
412
+ margin-bottom: 20px;
413
+ padding: 16px;
414
+ background: var(--bg-card);
415
+ border-radius: 8px;
416
+ }
417
+
418
+ .embedding-stat {
419
+ text-align: center;
420
+ flex: 1;
421
+ }
422
+
423
+ .embedding-stat-value {
424
+ font-size: 24px;
425
+ font-weight: 700;
426
+ color: var(--text-primary);
427
+ }
428
+
429
+ .embedding-stat-label {
430
+ font-size: 12px;
431
+ color: var(--text-muted);
432
+ text-transform: uppercase;
433
+ }
434
+
435
+ .embedding-stat-value.success { color: var(--success); }
436
+ .embedding-stat-value.warning { color: var(--warning); }
437
+
438
+ .progress-bar {
439
+ height: 8px;
440
+ background: var(--bg-card);
441
+ border-radius: 4px;
442
+ overflow: hidden;
443
+ margin: 16px 0;
444
+ }
445
+
446
+ .progress-bar-fill {
447
+ height: 100%;
448
+ background: linear-gradient(90deg, var(--accent), #8B5CF6);
449
+ border-radius: 4px;
450
+ transition: width 0.3s ease;
451
+ }
452
+
453
+ .progress-text {
454
+ text-align: center;
455
+ font-size: 14px;
456
+ color: var(--text-secondary);
457
+ margin-top: 8px;
458
+ }
459
+
460
+ .btn-icon {
461
+ padding: 10px;
462
+ min-width: auto;
463
+ }
464
+
465
+ .embedding-section {
466
+ margin-top: 20px;
467
+ padding: 16px;
468
+ background: var(--bg-card);
469
+ border-radius: 8px;
470
+ border: 1px solid var(--border);
471
+ }
472
+
473
+ .embedding-header {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 12px;
477
+ margin-bottom: 12px;
478
+ }
479
+
480
+ .embedding-status {
481
+ display: inline-flex;
482
+ align-items: center;
483
+ gap: 6px;
484
+ font-size: 13px;
485
+ padding: 4px 10px;
486
+ border-radius: 6px;
487
+ }
488
+
489
+ .embedding-status.has-embedding {
490
+ background: rgba(34, 197, 94, 0.15);
491
+ color: #22C55E;
492
+ }
493
+
494
+ .embedding-status.no-embedding {
495
+ background: rgba(239, 68, 68, 0.15);
496
+ color: #EF4444;
497
+ }
498
+
499
+ .embedding-status svg {
500
+ width: 14px;
501
+ height: 14px;
502
+ fill: currentColor;
503
+ }
504
+
505
+ .embedding-dims {
506
+ font-size: 12px;
507
+ color: var(--text-muted);
508
+ }
509
+
510
+ .embedding-viz {
511
+ display: flex;
512
+ align-items: flex-end;
513
+ gap: 2px;
514
+ height: 40px;
515
+ margin-top: 12px;
516
+ padding: 8px;
517
+ background: var(--bg-primary);
518
+ border-radius: 6px;
519
+ overflow: hidden;
520
+ }
521
+
522
+ .embedding-bar {
523
+ flex: 1;
524
+ min-width: 4px;
525
+ border-radius: 2px 2px 0 0;
526
+ transition: height 0.3s ease;
527
+ }
528
+
529
+ .embedding-bar.positive { background: linear-gradient(to top, #3B82F6, #60A5FA); }
530
+ .embedding-bar.negative { background: linear-gradient(to top, #8B5CF6, #A78BFA); }
531
+
532
+ .embedding-legend {
533
+ display: flex;
534
+ justify-content: space-between;
535
+ margin-top: 8px;
536
+ font-size: 11px;
537
+ color: var(--text-muted);
538
+ }
539
+
540
+ .embedding-legend-item {
541
+ display: flex;
542
+ align-items: center;
543
+ gap: 4px;
544
+ }
545
+
546
+ .legend-dot {
547
+ width: 8px;
548
+ height: 8px;
549
+ border-radius: 50%;
550
+ }
551
+
552
+ .legend-dot.positive { background: #3B82F6; }
553
+ .legend-dot.negative { background: #8B5CF6; }
554
+
262
555
  .entries-list { display: flex; flex-direction: column; gap: 12px; }
263
556
 
264
557
  .entry-card {
@@ -284,7 +577,14 @@ function getHTML() {
284
577
  gap: 12px;
285
578
  }
286
579
 
287
- .entry-key { font-weight: 600; font-size: 15px; color: var(--text-primary); word-break: break-word; }
580
+ .entry-key { font-weight: 600; font-size: 15px; color: var(--text-primary); word-break: break-word; flex: 1; }
581
+
582
+ .entry-badges {
583
+ display: flex;
584
+ align-items: center;
585
+ gap: 8px;
586
+ flex-shrink: 0;
587
+ }
288
588
 
289
589
  .entry-namespace {
290
590
  font-size: 12px;
@@ -567,6 +867,10 @@ function getHTML() {
567
867
  <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
568
868
  Add Memory
569
869
  </button>
870
+ <button class="btn" onclick="openEmbeddingModal()">
871
+ <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>
872
+ Embeddings
873
+ </button>
570
874
  <button class="btn" onclick="loadData()">
571
875
  <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>
572
876
  Refresh
@@ -582,6 +886,11 @@ function getHTML() {
582
886
  <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>
583
887
  <input type="text" id="search-input" placeholder="Search memories..." oninput="debounceSearch()">
584
888
  </div>
889
+ <select id="search-type" class="search-type-select" onchange="debounceSearch()">
890
+ <option value="hybrid">Hybrid (Text + Semantic)</option>
891
+ <option value="text">Text Only (FTS5)</option>
892
+ <option value="vector">Semantic Only (Vector)</option>
893
+ </select>
585
894
  </div>
586
895
 
587
896
  <div id="entries-container" class="entries-list">
@@ -676,6 +985,57 @@ function getHTML() {
676
985
  </div>
677
986
  </div>
678
987
 
988
+ <!-- Embedding Management Modal -->
989
+ <div id="embedding-modal" class="modal-overlay" onclick="closeEmbeddingModal(event)">
990
+ <div class="modal" style="max-width: 500px;" onclick="event.stopPropagation()">
991
+ <div class="modal-header">
992
+ <span class="modal-title">Manage Embeddings</span>
993
+ <button class="modal-close" onclick="closeEmbeddingModal()">
994
+ <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>
995
+ </button>
996
+ </div>
997
+ <div class="modal-body" id="embedding-modal-body">
998
+ <div class="embedding-stats" id="embedding-stats">
999
+ <div class="embedding-stat">
1000
+ <div class="embedding-stat-value" id="stat-total">-</div>
1001
+ <div class="embedding-stat-label">Total</div>
1002
+ </div>
1003
+ <div class="embedding-stat">
1004
+ <div class="embedding-stat-value success" id="stat-with">-</div>
1005
+ <div class="embedding-stat-label">With Embedding</div>
1006
+ </div>
1007
+ <div class="embedding-stat">
1008
+ <div class="embedding-stat-value warning" id="stat-without">-</div>
1009
+ <div class="embedding-stat-label">Without</div>
1010
+ </div>
1011
+ </div>
1012
+ <p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 12px;">
1013
+ Vector embeddings enable semantic search - finding memories by meaning, not just keywords.
1014
+ </p>
1015
+ <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 20px; padding: 10px; background: var(--bg-card); border-radius: 6px;">
1016
+ <strong>Model:</strong> multilingual-e5-small (100+ languages, optimized for retrieval)
1017
+ </p>
1018
+ <div id="embedding-progress" style="display: none;">
1019
+ <div class="progress-bar">
1020
+ <div class="progress-bar-fill" id="progress-fill" style="width: 0%"></div>
1021
+ </div>
1022
+ <div class="progress-text" id="progress-text">Processing...</div>
1023
+ </div>
1024
+ </div>
1025
+ <div class="modal-footer" id="embedding-modal-footer">
1026
+ <button class="btn" onclick="closeEmbeddingModal()">Close</button>
1027
+ <button class="btn btn-primary" id="btn-generate-missing" onclick="generateEmbeddings('missing')">
1028
+ <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
1029
+ Generate Missing
1030
+ </button>
1031
+ <button class="btn btn-success" id="btn-regenerate-all" onclick="generateEmbeddings('all')">
1032
+ <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>
1033
+ Re-generate All
1034
+ </button>
1035
+ </div>
1036
+ </div>
1037
+ </div>
1038
+
679
1039
  <script>
680
1040
  let currentNamespace = '';
681
1041
  let currentSearch = '';
@@ -708,10 +1068,6 @@ function getHTML() {
708
1068
  <div class="stat-label">Namespaces</div>
709
1069
  <div class="stat-value">\${Object.keys(stats.byNamespace || {}).length}</div>
710
1070
  </div>
711
- <div class="stat-card">
712
- <div class="stat-label">Types</div>
713
- <div class="stat-value">\${Object.keys(stats.byType || {}).length}</div>
714
- </div>
715
1071
  \`;
716
1072
  }
717
1073
 
@@ -730,13 +1086,32 @@ function getHTML() {
730
1086
  const container = document.getElementById('entries-container');
731
1087
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
732
1088
 
733
- const params = new URLSearchParams({ limit: pageSize, offset: currentPage * pageSize });
734
- if (currentNamespace) params.set('namespace', currentNamespace);
735
- if (currentSearch) params.set('search', currentSearch);
736
-
737
1089
  try {
738
- const res = await fetch('/api/entries?' + params);
739
- const entries = await res.json();
1090
+ let entries;
1091
+
1092
+ if (currentSearch && currentSearch.trim()) {
1093
+ // Use hybrid search endpoint when searching
1094
+ const searchType = document.getElementById('search-type').value;
1095
+ const params = new URLSearchParams({
1096
+ q: currentSearch,
1097
+ type: searchType,
1098
+ limit: String(pageSize)
1099
+ });
1100
+ if (currentNamespace) params.set('namespace', currentNamespace);
1101
+
1102
+ const res = await fetch('/api/search?' + params);
1103
+ entries = await res.json();
1104
+ } else {
1105
+ // Use standard entries endpoint for listing
1106
+ const params = new URLSearchParams({
1107
+ limit: String(pageSize),
1108
+ offset: String(currentPage * pageSize)
1109
+ });
1110
+ if (currentNamespace) params.set('namespace', currentNamespace);
1111
+
1112
+ const res = await fetch('/api/entries?' + params);
1113
+ entries = await res.json();
1114
+ }
740
1115
 
741
1116
  if (!Array.isArray(entries) || entries.length === 0) {
742
1117
  container.innerHTML = \`
@@ -751,7 +1126,14 @@ function getHTML() {
751
1126
  <div class="entry-card" onclick="showDetail('\${entry.id}')">
752
1127
  <div class="entry-header">
753
1128
  <span class="entry-key">\${escapeHtml(entry.key)}</span>
754
- <span class="entry-namespace">\${entry.namespace}</span>
1129
+ <div class="entry-badges">
1130
+ \${entry.score !== undefined ? \`<span class="score-badge">\${(entry.score * 100).toFixed(1)}%</span>\` : ''}
1131
+ \${entry.hasEmbedding ?
1132
+ \`<span class="vector-badge has-vector" title="Vector embedding enabled">Vec</span>\` :
1133
+ \`<span class="vector-badge no-vector" title="No vector embedding">--</span>\`
1134
+ }
1135
+ <span class="entry-namespace">\${entry.namespace}</span>
1136
+ </div>
755
1137
  </div>
756
1138
  <div class="entry-content truncated">\${escapeHtml(entry.content)}</div>
757
1139
  <div class="entry-footer">
@@ -800,6 +1182,59 @@ function getHTML() {
800
1182
  function prevPage() { if (currentPage > 0) { currentPage--; loadEntries(); } }
801
1183
  function nextPage() { currentPage++; loadEntries(); }
802
1184
 
1185
+ function renderEmbeddingViz(embedding) {
1186
+ if (!embedding || !embedding.hasEmbedding) {
1187
+ return \`
1188
+ <div class="embedding-section">
1189
+ <div class="embedding-header">
1190
+ <span class="embedding-status no-embedding">
1191
+ <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>
1192
+ No Embedding
1193
+ </span>
1194
+ </div>
1195
+ <p style="font-size: 12px; color: var(--text-muted); margin: 0;">
1196
+ This entry doesn't have a vector embedding. Edit and save to generate one.
1197
+ </p>
1198
+ </div>
1199
+ \`;
1200
+ }
1201
+
1202
+ const preview = embedding.preview || [];
1203
+ const maxVal = Math.max(...preview.map(Math.abs), 0.001);
1204
+
1205
+ const bars = preview.map(val => {
1206
+ const height = Math.abs(val) / maxVal * 100;
1207
+ const isPositive = val >= 0;
1208
+ return \`<div class="embedding-bar \${isPositive ? 'positive' : 'negative'}" style="height: \${height}%" title="\${val.toFixed(4)}"></div>\`;
1209
+ }).join('');
1210
+
1211
+ return \`
1212
+ <div class="embedding-section">
1213
+ <div class="embedding-header">
1214
+ <span class="embedding-status has-embedding">
1215
+ <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>
1216
+ Vector Enabled
1217
+ </span>
1218
+ <span class="embedding-dims">\${embedding.dimensions}D</span>
1219
+ </div>
1220
+ <div class="embedding-viz">
1221
+ \${bars}
1222
+ </div>
1223
+ <div class="embedding-legend">
1224
+ <div class="embedding-legend-item">
1225
+ <span class="legend-dot positive"></span>
1226
+ Positive values
1227
+ </div>
1228
+ <div class="embedding-legend-item">
1229
+ <span class="legend-dot negative"></span>
1230
+ Negative values
1231
+ </div>
1232
+ <span>First 20 dimensions</span>
1233
+ </div>
1234
+ </div>
1235
+ \`;
1236
+ }
1237
+
803
1238
  async function showDetail(id) {
804
1239
  try {
805
1240
  const res = await fetch('/api/entry/' + id);
@@ -830,6 +1265,7 @@ function getHTML() {
830
1265
  <div class="detail-label">Created</div>
831
1266
  <div class="detail-value">\${new Date(entry.created_at).toLocaleString()}</div>
832
1267
  </div>
1268
+ \${renderEmbeddingViz(entry.embedding)}
833
1269
  <div class="detail-actions">
834
1270
  <button class="btn btn-primary" onclick="openEditModal('\${entry.id}')">
835
1271
  <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>
@@ -951,6 +1387,85 @@ function getHTML() {
951
1387
  }
952
1388
  }
953
1389
 
1390
+ async function openEmbeddingModal() {
1391
+ document.getElementById('embedding-modal').classList.add('active');
1392
+ document.getElementById('embedding-progress').style.display = 'none';
1393
+ document.getElementById('embedding-modal-footer').style.display = 'flex';
1394
+
1395
+ // Load embedding stats
1396
+ try {
1397
+ const res = await fetch('/api/embeddings/stats');
1398
+ const embeddingStats = await res.json();
1399
+ document.getElementById('stat-total').textContent = embeddingStats.total;
1400
+ document.getElementById('stat-with').textContent = embeddingStats.withEmbedding;
1401
+ document.getElementById('stat-without').textContent = embeddingStats.withoutEmbedding;
1402
+
1403
+ // Disable buttons if nothing to do
1404
+ document.getElementById('btn-generate-missing').disabled = embeddingStats.withoutEmbedding === 0;
1405
+ document.getElementById('btn-regenerate-all').disabled = embeddingStats.total === 0;
1406
+ } catch (error) {
1407
+ console.error('Failed to load embedding stats:', error);
1408
+ }
1409
+ }
1410
+
1411
+ function closeEmbeddingModal(event) {
1412
+ if (!event || event.target.id === 'embedding-modal') {
1413
+ document.getElementById('embedding-modal').classList.remove('active');
1414
+ }
1415
+ }
1416
+
1417
+ async function generateEmbeddings(mode) {
1418
+ const progressEl = document.getElementById('embedding-progress');
1419
+ const progressFill = document.getElementById('progress-fill');
1420
+ const progressText = document.getElementById('progress-text');
1421
+ const footerEl = document.getElementById('embedding-modal-footer');
1422
+
1423
+ // Show progress, hide buttons
1424
+ progressEl.style.display = 'block';
1425
+ footerEl.style.display = 'none';
1426
+ progressFill.style.width = '0%';
1427
+ progressText.textContent = mode === 'missing' ? 'Generating missing embeddings...' : 'Re-generating all embeddings...';
1428
+
1429
+ // Animate progress bar while waiting
1430
+ let progress = 0;
1431
+ const interval = setInterval(() => {
1432
+ progress += Math.random() * 10;
1433
+ if (progress > 90) progress = 90;
1434
+ progressFill.style.width = progress + '%';
1435
+ }, 200);
1436
+
1437
+ try {
1438
+ const res = await fetch('/api/embeddings/generate', {
1439
+ method: 'POST',
1440
+ headers: { 'Content-Type': 'application/json' },
1441
+ body: JSON.stringify({ mode }),
1442
+ });
1443
+
1444
+ clearInterval(interval);
1445
+ progressFill.style.width = '100%';
1446
+
1447
+ const result = await res.json();
1448
+ progressText.textContent = result.message || 'Done!';
1449
+
1450
+ setTimeout(() => {
1451
+ showToast(result.message || 'Embeddings generated', 'success');
1452
+ closeEmbeddingModal();
1453
+ loadData();
1454
+ }, 1000);
1455
+ } catch (error) {
1456
+ clearInterval(interval);
1457
+ progressFill.style.width = '0%';
1458
+ progressText.textContent = 'Failed to generate embeddings';
1459
+ showToast('Failed to generate embeddings', 'error');
1460
+
1461
+ // Re-show buttons after error
1462
+ setTimeout(() => {
1463
+ progressEl.style.display = 'none';
1464
+ footerEl.style.display = 'flex';
1465
+ }, 2000);
1466
+ }
1467
+ }
1468
+
954
1469
  function showToast(message, type = 'success') {
955
1470
  const toast = document.createElement('div');
956
1471
  toast.className = 'toast ' + type;
@@ -985,6 +1500,7 @@ function getHTML() {
985
1500
  closeDetailModal();
986
1501
  closeFormModal();
987
1502
  closeDeleteModal();
1503
+ closeEmbeddingModal();
988
1504
  }
989
1505
  });
990
1506
 
@@ -1001,18 +1517,17 @@ async function readBody(req) {
1001
1517
  req.on('error', reject);
1002
1518
  });
1003
1519
  }
1004
- async function handleRequest(req, res) {
1520
+ function handleRequest(req, res) {
1005
1521
  const url = new URL(req.url || '/', `http://localhost:${PORT}`);
1006
1522
  const method = req.method || 'GET';
1007
1523
  res.setHeader('Content-Type', 'application/json');
1008
1524
  try {
1009
- const db = await loadOrCreateDatabase();
1525
+ const db = getDatabase();
1010
1526
  // Serve HTML
1011
1527
  if (url.pathname === '/' && method === 'GET') {
1012
1528
  res.setHeader('Content-Type', 'text/html');
1013
1529
  res.writeHead(200);
1014
1530
  res.end(getHTML());
1015
- db.close();
1016
1531
  return;
1017
1532
  }
1018
1533
  // GET stats
@@ -1020,10 +1535,9 @@ async function handleRequest(req, res) {
1020
1535
  const stats = getStats(db);
1021
1536
  res.writeHead(200);
1022
1537
  res.end(JSON.stringify(stats));
1023
- db.close();
1024
1538
  return;
1025
1539
  }
1026
- // GET entries
1540
+ // GET entries (standard listing with optional FTS search)
1027
1541
  if (url.pathname === '/api/entries' && method === 'GET') {
1028
1542
  const namespace = url.searchParams.get('namespace') || undefined;
1029
1543
  const limit = parseInt(url.searchParams.get('limit') || '50', 10);
@@ -1032,30 +1546,74 @@ async function handleRequest(req, res) {
1032
1546
  const entries = getEntries(db, namespace, limit, offset, search);
1033
1547
  res.writeHead(200);
1034
1548
  res.end(JSON.stringify(entries));
1035
- db.close();
1036
1549
  return;
1037
1550
  }
1038
- // POST create entry
1551
+ // GET hybrid search (new endpoint with vector support)
1552
+ if (url.pathname === '/api/search' && method === 'GET') {
1553
+ const query = url.searchParams.get('q') || '';
1554
+ const searchType = (url.searchParams.get('type') || 'hybrid');
1555
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
1556
+ const namespace = url.searchParams.get('namespace') || undefined;
1557
+ getSearchEngine()
1558
+ .then((searchEngine) => searchEntries(searchEngine, query, { type: searchType, namespace, limit }))
1559
+ .then((results) => {
1560
+ res.writeHead(200);
1561
+ res.end(JSON.stringify(results));
1562
+ })
1563
+ .catch((error) => {
1564
+ res.writeHead(500);
1565
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Search failed' }));
1566
+ });
1567
+ return;
1568
+ }
1569
+ // POST create entry (direct DB for compatibility with existing schema)
1039
1570
  if (url.pathname === '/api/entries' && method === 'POST') {
1040
- const body = await readBody(req);
1041
- const data = JSON.parse(body);
1042
- const now = Date.now();
1043
- const id = generateId();
1044
- db.run(`INSERT INTO memory_entries (id, key, content, type, namespace, tags, created_at, updated_at)
1045
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, data.key, data.content, data.type || 'semantic', data.namespace || 'general', JSON.stringify(data.tags || []), now, now]);
1046
- saveDatabase(db);
1047
- res.writeHead(201);
1048
- res.end(JSON.stringify({ id, success: true }));
1049
- db.close();
1571
+ readBody(req)
1572
+ .then(async (body) => {
1573
+ const data = JSON.parse(body);
1574
+ const now = Date.now();
1575
+ const id = `mem_${now}_${Math.random().toString(36).slice(2, 10)}`;
1576
+ const tags = JSON.stringify(data.tags || []);
1577
+ // Generate embedding for the content
1578
+ let embeddingBuffer = null;
1579
+ try {
1580
+ const embeddingsService = await getEmbeddingsService();
1581
+ const result = await embeddingsService.embed(data.content);
1582
+ embeddingBuffer = Buffer.from(result.embedding.buffer);
1583
+ }
1584
+ catch (e) {
1585
+ console.warn('[WebViewer] Failed to generate embedding:', e);
1586
+ }
1587
+ db.prepare(`INSERT INTO memory_entries (id, key, content, type, namespace, tags, embedding, created_at, updated_at)
1588
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, data.key, data.content, data.type || 'semantic', data.namespace || 'general', tags, embeddingBuffer, now, now);
1589
+ res.writeHead(201);
1590
+ res.end(JSON.stringify({ id, success: true }));
1591
+ })
1592
+ .catch((error) => {
1593
+ res.writeHead(500);
1594
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1595
+ });
1050
1596
  return;
1051
1597
  }
1052
1598
  // GET single entry
1053
1599
  if (url.pathname.startsWith('/api/entry/') && method === 'GET') {
1054
1600
  const id = url.pathname.split('/').pop();
1055
- const stmt = db.prepare('SELECT * FROM memory_entries WHERE id = ?');
1056
- stmt.bind([id]);
1057
- if (stmt.step()) {
1058
- const row = stmt.getAsObject();
1601
+ const row = db.prepare('SELECT * FROM memory_entries WHERE id = ?').get(id);
1602
+ if (row) {
1603
+ // Extract embedding info for visualization
1604
+ let embeddingInfo = {
1605
+ hasEmbedding: false,
1606
+ };
1607
+ if (row.embedding && row.embedding.length > 0) {
1608
+ const embedding = new Float32Array(row.embedding.buffer.slice(row.embedding.byteOffset, row.embedding.byteOffset + row.embedding.byteLength));
1609
+ // Get first 20 values for preview visualization
1610
+ const preview = Array.from(embedding.slice(0, 20));
1611
+ embeddingInfo = {
1612
+ hasEmbedding: true,
1613
+ dimensions: embedding.length,
1614
+ preview,
1615
+ };
1616
+ }
1059
1617
  res.writeHead(200);
1060
1618
  res.end(JSON.stringify({
1061
1619
  id: row.id,
@@ -1066,43 +1624,135 @@ async function handleRequest(req, res) {
1066
1624
  tags: JSON.parse(row.tags || '[]'),
1067
1625
  created_at: row.created_at,
1068
1626
  updated_at: row.updated_at,
1627
+ embedding: embeddingInfo,
1069
1628
  }));
1070
1629
  }
1071
1630
  else {
1072
1631
  res.writeHead(404);
1073
1632
  res.end(JSON.stringify({ error: 'Entry not found' }));
1074
1633
  }
1075
- stmt.free();
1076
- db.close();
1077
1634
  return;
1078
1635
  }
1079
- // PUT update entry
1636
+ // PUT update entry (direct DB for full field updates)
1080
1637
  if (url.pathname.startsWith('/api/entry/') && method === 'PUT') {
1081
1638
  const id = url.pathname.split('/').pop();
1082
- const body = await readBody(req);
1083
- const data = JSON.parse(body);
1084
- const now = Date.now();
1085
- db.run(`UPDATE memory_entries SET key = ?, content = ?, type = ?, namespace = ?, tags = ?, updated_at = ?
1086
- WHERE id = ?`, [data.key, data.content, data.type, data.namespace, JSON.stringify(data.tags || []), now, id]);
1087
- saveDatabase(db);
1088
- res.writeHead(200);
1089
- res.end(JSON.stringify({ success: true }));
1090
- db.close();
1639
+ if (!id) {
1640
+ res.writeHead(400);
1641
+ res.end(JSON.stringify({ error: 'Missing entry ID' }));
1642
+ return;
1643
+ }
1644
+ readBody(req)
1645
+ .then(async (body) => {
1646
+ const data = JSON.parse(body);
1647
+ const now = Date.now();
1648
+ const tags = JSON.stringify(data.tags || []);
1649
+ // Generate embedding for the updated content
1650
+ let embeddingBuffer = null;
1651
+ try {
1652
+ const embeddingsService = await getEmbeddingsService();
1653
+ const result = await embeddingsService.embed(data.content);
1654
+ embeddingBuffer = Buffer.from(result.embedding.buffer);
1655
+ }
1656
+ catch (e) {
1657
+ console.warn('[WebViewer] Failed to generate embedding:', e);
1658
+ }
1659
+ // Update with embedding
1660
+ const result = db.prepare(`UPDATE memory_entries SET key = ?, content = ?, type = ?, namespace = ?, tags = ?, embedding = ?, updated_at = ?
1661
+ WHERE id = ?`).run(data.key, data.content, data.type, data.namespace, tags, embeddingBuffer, now, id);
1662
+ if (result.changes > 0) {
1663
+ res.writeHead(200);
1664
+ res.end(JSON.stringify({ success: true }));
1665
+ }
1666
+ else {
1667
+ res.writeHead(404);
1668
+ res.end(JSON.stringify({ error: 'Entry not found' }));
1669
+ }
1670
+ })
1671
+ .catch((error) => {
1672
+ res.writeHead(500);
1673
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1674
+ });
1091
1675
  return;
1092
1676
  }
1093
- // DELETE entry
1677
+ // DELETE entry (direct DB for compatibility)
1094
1678
  if (url.pathname.startsWith('/api/entry/') && method === 'DELETE') {
1095
1679
  const id = url.pathname.split('/').pop();
1096
- db.run('DELETE FROM memory_entries WHERE id = ?', [id]);
1097
- saveDatabase(db);
1680
+ if (!id) {
1681
+ res.writeHead(400);
1682
+ res.end(JSON.stringify({ error: 'Missing entry ID' }));
1683
+ return;
1684
+ }
1685
+ const result = db.prepare('DELETE FROM memory_entries WHERE id = ?').run(id);
1686
+ if (result.changes > 0) {
1687
+ res.writeHead(200);
1688
+ res.end(JSON.stringify({ success: true }));
1689
+ }
1690
+ else {
1691
+ res.writeHead(404);
1692
+ res.end(JSON.stringify({ error: 'Entry not found' }));
1693
+ }
1694
+ return;
1695
+ }
1696
+ // GET embedding stats
1697
+ if (url.pathname === '/api/embeddings/stats' && method === 'GET') {
1698
+ const totalRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries').get();
1699
+ const withEmbeddingRow = db.prepare('SELECT COUNT(*) as count FROM memory_entries WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0').get();
1098
1700
  res.writeHead(200);
1099
- res.end(JSON.stringify({ success: true }));
1100
- db.close();
1701
+ res.end(JSON.stringify({
1702
+ total: totalRow?.count || 0,
1703
+ withEmbedding: withEmbeddingRow?.count || 0,
1704
+ withoutEmbedding: (totalRow?.count || 0) - (withEmbeddingRow?.count || 0),
1705
+ }));
1706
+ return;
1707
+ }
1708
+ // POST batch generate embeddings
1709
+ if (url.pathname === '/api/embeddings/generate' && method === 'POST') {
1710
+ readBody(req)
1711
+ .then(async (body) => {
1712
+ const options = JSON.parse(body || '{}');
1713
+ const mode = options.mode || 'missing';
1714
+ // Get entries to process
1715
+ const query = mode === 'missing'
1716
+ ? 'SELECT id, content FROM memory_entries WHERE embedding IS NULL OR LENGTH(embedding) = 0'
1717
+ : 'SELECT id, content FROM memory_entries';
1718
+ const entries = db.prepare(query).all();
1719
+ if (entries.length === 0) {
1720
+ res.writeHead(200);
1721
+ res.end(JSON.stringify({ processed: 0, success: 0, failed: 0, message: 'No entries to process' }));
1722
+ return;
1723
+ }
1724
+ const embeddingsService = await getEmbeddingsService();
1725
+ let success = 0;
1726
+ let failed = 0;
1727
+ const updateStmt = db.prepare('UPDATE memory_entries SET embedding = ?, updated_at = ? WHERE id = ?');
1728
+ for (const entry of entries) {
1729
+ try {
1730
+ const result = await embeddingsService.embed(entry.content);
1731
+ const embeddingBuffer = Buffer.from(result.embedding.buffer);
1732
+ updateStmt.run(embeddingBuffer, Date.now(), entry.id);
1733
+ success++;
1734
+ }
1735
+ catch (e) {
1736
+ console.warn(`[WebViewer] Failed to generate embedding for ${entry.id}:`, e);
1737
+ failed++;
1738
+ }
1739
+ }
1740
+ res.writeHead(200);
1741
+ res.end(JSON.stringify({
1742
+ processed: entries.length,
1743
+ success,
1744
+ failed,
1745
+ message: `Generated embeddings for ${success} entries${failed > 0 ? `, ${failed} failed` : ''}`,
1746
+ }));
1747
+ })
1748
+ .catch((error) => {
1749
+ res.writeHead(500);
1750
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal error' }));
1751
+ });
1101
1752
  return;
1102
1753
  }
1103
1754
  res.writeHead(404);
1104
1755
  res.end(JSON.stringify({ error: 'Not found' }));
1105
- db.close();
1106
1756
  }
1107
1757
  catch (error) {
1108
1758
  res.writeHead(500);