context-vault 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/serve.js ADDED
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * serve.js — Lightweight HTTP server for context-mcp UI
4
+ *
5
+ * Structure-agnostic: discovers tables, columns, and directories at runtime.
6
+ * Works with any SQLite database and any vault directory layout.
7
+ *
8
+ * Usage: node serve.js [--port 3141] [--db-path /path/to/vault.db] [--vault-dir /path/to/vault]
9
+ */
10
+
11
+ import { createServer } from "node:http";
12
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from "node:fs";
13
+ import { resolve, dirname, join, basename } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { resolveConfig } from "../src/config.js";
16
+ import { initDatabase } from "../src/db.js";
17
+ import { embed } from "../src/embed.js";
18
+ import { hybridSearch } from "../src/search.js";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ // ─── Config & DB (mutable — can be reconnected) ────────────────────────────
23
+
24
+ let config = resolveConfig();
25
+ let db = initDatabase(config.dbPath);
26
+
27
+ function reconnectDb(dbPath) {
28
+ try { db.close(); } catch {}
29
+ db = initDatabase(dbPath);
30
+ }
31
+
32
+ // ─── Load UI HTML once ──────────────────────────────────────────────────────
33
+
34
+ const htmlContent = readFileSync(resolve(__dirname, "index.html"), "utf-8");
35
+
36
+ // ─── Helpers ────────────────────────────────────────────────────────────────
37
+
38
+ function jsonResponse(res, data, status = 200) {
39
+ const body = JSON.stringify(data);
40
+ res.writeHead(status, {
41
+ "Content-Type": "application/json",
42
+ "Access-Control-Allow-Origin": "*",
43
+ "Access-Control-Allow-Methods": "GET, PUT, OPTIONS",
44
+ "Access-Control-Allow-Headers": "Content-Type",
45
+ });
46
+ res.end(body);
47
+ }
48
+
49
+ function htmlResponse(res) {
50
+ res.writeHead(200, {
51
+ "Content-Type": "text/html; charset=utf-8",
52
+ "Access-Control-Allow-Origin": "*",
53
+ });
54
+ res.end(htmlContent);
55
+ }
56
+
57
+ function errorResponse(res, message, status = 500) {
58
+ jsonResponse(res, { error: message }, status);
59
+ }
60
+
61
+ function safeTableName(name) {
62
+ // Only allow alphanumeric, underscore, hyphen
63
+ return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name) ? name : null;
64
+ }
65
+
66
+ // ─── Discovery ──────────────────────────────────────────────────────────────
67
+ // Returns everything the UI needs to build its sidebar and choose views.
68
+
69
+ function handleDiscover(res) {
70
+ // 1. Discover all tables with schemas and row counts
71
+ const rawTables = db.prepare(
72
+ "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name"
73
+ ).all();
74
+
75
+ const tables = rawTables.map((t) => {
76
+ try {
77
+ const count = db.prepare(`SELECT COUNT(*) as count FROM "${t.name}"`).get().count;
78
+ const cols = db.prepare(`PRAGMA table_info("${t.name}")`).all();
79
+ return {
80
+ name: t.name,
81
+ type: t.type,
82
+ count,
83
+ columns: cols.map((c) => ({ name: c.name, type: c.type, pk: !!c.pk })),
84
+ };
85
+ } catch {
86
+ return { name: t.name, type: t.type, count: 0, columns: [] };
87
+ }
88
+ });
89
+
90
+ // 2. For tables with a "kind" or "type" or "category" column, get distinct values
91
+ const tableGroups = {};
92
+ for (const t of tables) {
93
+ const groupCol = t.columns.find((c) =>
94
+ ["kind", "type", "category", "status", "group"].includes(c.name.toLowerCase())
95
+ );
96
+ if (groupCol && t.count > 0) {
97
+ try {
98
+ const groups = db.prepare(
99
+ `SELECT "${groupCol.name}" as grp, COUNT(*) as count FROM "${t.name}" WHERE "${groupCol.name}" IS NOT NULL GROUP BY "${groupCol.name}" ORDER BY count DESC`
100
+ ).all();
101
+ tableGroups[t.name] = { column: groupCol.name, groups: groups.map((g) => ({ value: g.grp, count: g.count })) };
102
+ } catch {}
103
+ }
104
+ }
105
+
106
+ // 3. Knowledge directory structure (top-level only)
107
+ let directories = [];
108
+ let vaultDirName = basename(config.vaultDir);
109
+ if (existsSync(config.vaultDir)) {
110
+ try {
111
+ const entries = readdirSync(config.vaultDir, { withFileTypes: true });
112
+ directories = entries
113
+ .filter((e) => !e.name.startsWith("."))
114
+ .map((e) => {
115
+ const fullPath = join(config.vaultDir, e.name);
116
+ const item = { name: e.name, type: e.isDirectory() ? "directory" : "file" };
117
+ if (e.isDirectory()) {
118
+ try {
119
+ // Count files recursively
120
+ const count = countFiles(fullPath);
121
+ item.fileCount = count;
122
+ } catch { item.fileCount = 0; }
123
+ }
124
+ if (e.isFile()) {
125
+ try { item.size = statSync(fullPath).size; } catch {}
126
+ }
127
+ return item;
128
+ })
129
+ .sort((a, b) => {
130
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
131
+ return a.name.localeCompare(b.name);
132
+ });
133
+ } catch {}
134
+ }
135
+
136
+ // 4. DB size
137
+ let dbSize = "unknown";
138
+ try {
139
+ const bytes = statSync(config.dbPath).size;
140
+ dbSize = bytes < 1024 * 1024
141
+ ? `${(bytes / 1024).toFixed(1)}KB`
142
+ : `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
143
+ } catch {}
144
+
145
+ jsonResponse(res, {
146
+ tables,
147
+ tableGroups,
148
+ directories,
149
+ vaultDirName,
150
+ dbSize,
151
+ connection: {
152
+ dbPath: config.dbPath,
153
+ vaultDir: config.vaultDir,
154
+ dataDir: config.dataDir,
155
+ devDir: config.devDir,
156
+ vaultDirExists: existsSync(config.vaultDir),
157
+ dbExists: existsSync(config.dbPath),
158
+ },
159
+ });
160
+ }
161
+
162
+ function countFiles(dir) {
163
+ let count = 0;
164
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
165
+ if (entry.name.startsWith(".")) continue;
166
+ if (entry.isFile()) count++;
167
+ else if (entry.isDirectory()) count += countFiles(join(dir, entry.name));
168
+ }
169
+ return count;
170
+ }
171
+
172
+ // ─── Generic Table Data ─────────────────────────────────────────────────────
173
+ // Query any table with pagination, search, and optional group filtering.
174
+
175
+ function handleTableData(res, url) {
176
+ const params = url.searchParams;
177
+ const table = params.get("table");
178
+ if (!table || !safeTableName(table)) return errorResponse(res, "Invalid table name", 400);
179
+
180
+ // Verify table exists
181
+ const exists = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name = ?").get(table);
182
+ if (!exists) return errorResponse(res, "Table not found", 404);
183
+
184
+ const limit = Math.min(parseInt(params.get("limit") || "50", 10), 200);
185
+ const offset = parseInt(params.get("offset") || "0", 10);
186
+ const q = params.get("q") || "";
187
+ const groupCol = params.get("groupCol") || "";
188
+ const groupVal = params.get("groupVal") || "";
189
+ const sortCol = params.get("sort") || "";
190
+ const sortDir = params.get("dir") === "asc" ? "ASC" : "DESC";
191
+
192
+ // Get column info for this table
193
+ const colInfo = db.prepare(`PRAGMA table_info("${table}")`).all();
194
+ const colNames = colInfo.map((c) => c.name);
195
+
196
+ // Build WHERE clause
197
+ const conditions = [];
198
+ const binds = [];
199
+
200
+ // Group filter
201
+ if (groupCol && groupVal && colNames.includes(groupCol)) {
202
+ conditions.push(`"${groupCol}" = ?`);
203
+ binds.push(groupVal);
204
+ }
205
+
206
+ // Search: LIKE across all text-looking columns
207
+ if (q) {
208
+ const textCols = colInfo.filter((c) =>
209
+ !c.type || c.type.toUpperCase().includes("TEXT") || c.type.toUpperCase().includes("VARCHAR") || c.type === ""
210
+ );
211
+ if (textCols.length > 0) {
212
+ const likeClauses = textCols.map((c) => `"${c.name}" LIKE ?`);
213
+ conditions.push(`(${likeClauses.join(" OR ")})`);
214
+ for (const _ of textCols) binds.push(`%${q}%`);
215
+ }
216
+ }
217
+
218
+ const where = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : "";
219
+
220
+ // Sort
221
+ let orderBy = "";
222
+ if (sortCol && colNames.includes(sortCol)) {
223
+ orderBy = ` ORDER BY "${sortCol}" ${sortDir}`;
224
+ } else {
225
+ // Auto-detect: prefer created_at, date, updated_at, id, or rowid
226
+ const dateCols = ["created_at", "updated_at", "date", "timestamp", "created", "modified"];
227
+ const autoSort = dateCols.find((c) => colNames.includes(c));
228
+ if (autoSort) {
229
+ orderBy = ` ORDER BY "${autoSort}" DESC`;
230
+ }
231
+ }
232
+
233
+ try {
234
+ const rows = db.prepare(`SELECT * FROM "${table}"${where}${orderBy} LIMIT ? OFFSET ?`).all(...binds, limit, offset);
235
+ const total = db.prepare(`SELECT COUNT(*) as count FROM "${table}"${where}`).get(...binds).count;
236
+
237
+ jsonResponse(res, {
238
+ table,
239
+ columns: colInfo.map((c) => ({ name: c.name, type: c.type, pk: !!c.pk })),
240
+ rows,
241
+ total,
242
+ });
243
+ } catch (e) {
244
+ errorResponse(res, "Query failed: " + e.message);
245
+ }
246
+ }
247
+
248
+ // ─── Knowledge Directory Browser ────────────────────────────────────────────
249
+
250
+ function handleBrowse(res, url) {
251
+ const subpath = url.searchParams.get("path") || "";
252
+ const baseDir = config.vaultDir;
253
+
254
+ if (!existsSync(baseDir)) {
255
+ return jsonResponse(res, { exists: false, path: baseDir, baseName: basename(baseDir), items: [] });
256
+ }
257
+
258
+ const targetDir = subpath ? resolve(baseDir, subpath) : baseDir;
259
+
260
+ // Safety: prevent traversal outside knowledge dir
261
+ if (!targetDir.startsWith(baseDir)) {
262
+ return errorResponse(res, "Invalid path", 400);
263
+ }
264
+
265
+ if (!existsSync(targetDir)) {
266
+ return jsonResponse(res, { exists: false, path: targetDir, baseName: basename(baseDir), items: [] });
267
+ }
268
+
269
+ try {
270
+ const entries = readdirSync(targetDir, { withFileTypes: true });
271
+ const items = entries
272
+ .filter((e) => !e.name.startsWith("."))
273
+ .map((e) => {
274
+ const fullPath = join(targetDir, e.name);
275
+ const item = {
276
+ name: e.name,
277
+ type: e.isDirectory() ? "directory" : "file",
278
+ path: subpath ? join(subpath, e.name) : e.name,
279
+ };
280
+ if (e.isFile()) {
281
+ try {
282
+ const st = statSync(fullPath);
283
+ item.size = st.size;
284
+ item.modified = st.mtime.toISOString();
285
+ } catch {}
286
+ }
287
+ if (e.isDirectory()) {
288
+ try {
289
+ const children = readdirSync(fullPath, { withFileTypes: true });
290
+ item.childCount = children.filter((c) => !c.name.startsWith(".")).length;
291
+ } catch { item.childCount = 0; }
292
+ }
293
+ return item;
294
+ })
295
+ .sort((a, b) => {
296
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
297
+ return a.name.localeCompare(b.name);
298
+ });
299
+
300
+ jsonResponse(res, { exists: true, path: targetDir, baseName: basename(baseDir), relativePath: subpath, items });
301
+ } catch (e) {
302
+ errorResponse(res, "Failed to browse: " + e.message);
303
+ }
304
+ }
305
+
306
+ function handleFileContent(res, url) {
307
+ const filePath = url.searchParams.get("path") || "";
308
+ const baseDir = config.vaultDir;
309
+
310
+ if (!filePath || !existsSync(baseDir)) {
311
+ return errorResponse(res, "Invalid path", 400);
312
+ }
313
+
314
+ const fullPath = resolve(baseDir, filePath);
315
+
316
+ // Safety: prevent traversal
317
+ if (!fullPath.startsWith(baseDir)) {
318
+ return errorResponse(res, "Invalid path", 400);
319
+ }
320
+
321
+ if (!existsSync(fullPath)) {
322
+ return errorResponse(res, "File not found", 404);
323
+ }
324
+
325
+ try {
326
+ const content = readFileSync(fullPath, "utf-8");
327
+ const stats = statSync(fullPath);
328
+ jsonResponse(res, {
329
+ path: filePath,
330
+ fullPath,
331
+ baseName: basename(baseDir),
332
+ name: basename(fullPath),
333
+ content,
334
+ size: stats.size,
335
+ modified: stats.mtime.toISOString(),
336
+ });
337
+ } catch (e) {
338
+ errorResponse(res, "Failed to read file: " + e.message);
339
+ }
340
+ }
341
+
342
+ // ─── Semantic Search ─────────────────────────────────────────────────────────
343
+
344
+ async function handleSearch(res, url) {
345
+ const query = url.searchParams.get("q") || "";
346
+ const kindParam = url.searchParams.get("kind") || url.searchParams.get("type") || "";
347
+ const kindFilter = kindParam ? kindParam.replace(/s$/, "") : null;
348
+
349
+ if (!query) return jsonResponse(res, { results: [] });
350
+
351
+ const sorted = await hybridSearch(db, embed, query, kindFilter);
352
+ jsonResponse(res, { query, results: sorted });
353
+ }
354
+
355
+ // ─── Config Handlers ─────────────────────────────────────────────────────────
356
+
357
+ const configFilePath = resolve(__dirname, "..", "config.json");
358
+
359
+ function handleGetConfig(res) {
360
+ try {
361
+ const raw = existsSync(configFilePath) ? readFileSync(configFilePath, "utf-8") : "{}";
362
+ const cfg = JSON.parse(raw);
363
+ jsonResponse(res, {
364
+ config: cfg,
365
+ path: configFilePath,
366
+ active: {
367
+ dbPath: config.dbPath,
368
+ vaultDir: config.vaultDir,
369
+ dataDir: config.dataDir,
370
+ devDir: config.devDir,
371
+ },
372
+ });
373
+ } catch (e) {
374
+ errorResponse(res, "Failed to read config: " + e.message);
375
+ }
376
+ }
377
+
378
+ const MAX_BODY = 1024 * 1024; // 1MB
379
+
380
+ function handlePutConfig(req, res) {
381
+ let body = "";
382
+ let size = 0;
383
+ req.on("data", (chunk) => {
384
+ size += chunk.length;
385
+ if (size > MAX_BODY) {
386
+ req.destroy();
387
+ return errorResponse(res, "Request body too large", 413);
388
+ }
389
+ body += chunk;
390
+ });
391
+ req.on("end", () => {
392
+ try {
393
+ const newConfig = JSON.parse(body);
394
+ const allowed = ["vaultDir", "dataDir", "dbPath", "devDir"];
395
+ const sanitized = {};
396
+ for (const key of allowed) {
397
+ if (newConfig[key] !== undefined) sanitized[key] = newConfig[key];
398
+ }
399
+ writeFileSync(configFilePath, JSON.stringify(sanitized, null, 2) + "\n");
400
+
401
+ // Reconnect DB if dbPath changed
402
+ const newDbPath = resolve(sanitized.dbPath || config.dbPath);
403
+ if (newDbPath !== config.dbPath && existsSync(newDbPath)) {
404
+ reconnectDb(newDbPath);
405
+ config.dbPath = newDbPath;
406
+ }
407
+
408
+ if (sanitized.vaultDir) config.vaultDir = resolve(sanitized.vaultDir);
409
+ if (sanitized.dataDir) config.dataDir = resolve(sanitized.dataDir);
410
+ if (sanitized.devDir) config.devDir = resolve(sanitized.devDir);
411
+ config.vaultDirExists = existsSync(config.vaultDir);
412
+
413
+ jsonResponse(res, { ok: true, config: sanitized });
414
+ } catch (e) {
415
+ errorResponse(res, "Invalid JSON: " + e.message, 400);
416
+ }
417
+ });
418
+ }
419
+
420
+ // ─── Server ─────────────────────────────────────────────────────────────────
421
+
422
+ const port = (() => {
423
+ const portArg = process.argv.indexOf("--port");
424
+ if (portArg !== -1 && process.argv[portArg + 1]) return parseInt(process.argv[portArg + 1], 10);
425
+ if (process.env.PORT) return parseInt(process.env.PORT, 10);
426
+ return 3141;
427
+ })();
428
+
429
+ const server = createServer((req, res) => {
430
+ if (req.method === "OPTIONS") {
431
+ res.writeHead(204, {
432
+ "Access-Control-Allow-Origin": "*",
433
+ "Access-Control-Allow-Methods": "GET, PUT, OPTIONS",
434
+ "Access-Control-Allow-Headers": "Content-Type",
435
+ });
436
+ return res.end();
437
+ }
438
+
439
+ const url = new URL(req.url, `http://localhost:${port}`);
440
+ const path = url.pathname;
441
+
442
+ try {
443
+ if (path === "/") return htmlResponse(res);
444
+ if (path === "/api/discover") return handleDiscover(res);
445
+ if (path === "/api/table-data") return handleTableData(res, url);
446
+ if (path === "/api/search") return handleSearch(res, url);
447
+ if (path === "/api/browse") return handleBrowse(res, url);
448
+ if (path === "/api/file") return handleFileContent(res, url);
449
+ if (path === "/api/config" && req.method === "GET") return handleGetConfig(res);
450
+ if (path === "/api/config" && req.method === "PUT") return handlePutConfig(req, res);
451
+ errorResponse(res, "Not found", 404);
452
+ } catch (e) {
453
+ console.error(e);
454
+ errorResponse(res, e.message);
455
+ }
456
+ });
457
+
458
+ server.listen(port, () => {
459
+ console.log(`Vault UI → http://localhost:${port}`);
460
+ console.log(`DB: ${config.dbPath}`);
461
+ console.log(`Vault: ${config.vaultDir}`);
462
+ });
463
+
464
+ // ─── Graceful Shutdown ───────────────────────────────────────────────────────
465
+
466
+ function shutdown() {
467
+ server.close(() => {
468
+ try { db.close(); } catch {}
469
+ process.exit(0);
470
+ });
471
+ }
472
+ process.on("SIGINT", shutdown);
473
+ process.on("SIGTERM", shutdown);