@thingd/cli 0.77.0 → 0.77.3

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 (68) hide show
  1. package/dist/commands/cloud.d.ts +3 -0
  2. package/dist/commands/cloud.d.ts.map +1 -0
  3. package/dist/commands/cloud.js +669 -0
  4. package/dist/commands/mcp-connect.d.ts +3 -0
  5. package/dist/commands/mcp-connect.d.ts.map +1 -0
  6. package/dist/commands/mcp-connect.js +154 -0
  7. package/dist/commands/sync.d.ts +3 -0
  8. package/dist/commands/sync.d.ts.map +1 -0
  9. package/dist/commands/sync.js +197 -0
  10. package/dist/dashboard/public/assets/favicon-CgGFvG_0.svg +4 -0
  11. package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
  12. package/dist/dashboard/public/assets/index-DrpfyClj.js +4 -0
  13. package/dist/dashboard/public/index.html +19 -0
  14. package/dist/dashboard/server.d.ts +6 -0
  15. package/dist/dashboard/server.d.ts.map +1 -0
  16. package/dist/dashboard/server.js +684 -0
  17. package/dist/data-movement.d.ts +6 -0
  18. package/dist/data-movement.d.ts.map +1 -0
  19. package/dist/data-movement.js +475 -0
  20. package/dist/doctor.d.ts +3 -0
  21. package/dist/doctor.d.ts.map +1 -0
  22. package/dist/doctor.js +108 -0
  23. package/dist/index.d.ts +48 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +1446 -0
  26. package/dist/install.d.ts +3 -0
  27. package/dist/install.d.ts.map +1 -0
  28. package/dist/install.js +229 -0
  29. package/dist/interactive.d.ts +2 -0
  30. package/dist/interactive.d.ts.map +1 -0
  31. package/dist/interactive.js +3039 -0
  32. package/dist/lib/cloud-api.d.ts +143 -0
  33. package/dist/lib/cloud-api.d.ts.map +1 -0
  34. package/dist/lib/cloud-api.js +207 -0
  35. package/dist/lib/cloud-config.d.ts +33 -0
  36. package/dist/lib/cloud-config.d.ts.map +1 -0
  37. package/dist/lib/cloud-config.js +42 -0
  38. package/dist/lib/mcp-config-writer.d.ts +26 -0
  39. package/dist/lib/mcp-config-writer.d.ts.map +1 -0
  40. package/dist/lib/mcp-config-writer.js +86 -0
  41. package/dist/lib/sync-config.d.ts +22 -0
  42. package/dist/lib/sync-config.d.ts.map +1 -0
  43. package/dist/lib/sync-config.js +25 -0
  44. package/dist/logo.d.ts +3 -0
  45. package/dist/logo.d.ts.map +1 -0
  46. package/dist/logo.js +8 -0
  47. package/dist/mcp/cluster.d.ts +69 -0
  48. package/dist/mcp/cluster.d.ts.map +1 -0
  49. package/dist/mcp/cluster.js +304 -0
  50. package/dist/mcp/config.d.ts +14 -0
  51. package/dist/mcp/config.d.ts.map +1 -0
  52. package/dist/mcp/config.js +67 -0
  53. package/dist/mcp/http.d.ts +35 -0
  54. package/dist/mcp/http.d.ts.map +1 -0
  55. package/dist/mcp/http.js +957 -0
  56. package/dist/mcp/index.d.ts +5 -0
  57. package/dist/mcp/index.d.ts.map +1 -0
  58. package/dist/mcp/index.js +3 -0
  59. package/dist/mcp-http.d.ts +3 -0
  60. package/dist/mcp-http.d.ts.map +1 -0
  61. package/dist/mcp-http.js +42 -0
  62. package/dist/mcp.d.ts +3 -0
  63. package/dist/mcp.d.ts.map +1 -0
  64. package/dist/mcp.js +28 -0
  65. package/dist/paths.d.ts +4 -0
  66. package/dist/paths.d.ts.map +1 -0
  67. package/dist/paths.js +14 -0
  68. package/package.json +2 -2
@@ -0,0 +1,684 @@
1
+ import { existsSync, promises as fs, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, extname, isAbsolute, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { handleRestRequest, ThingD } from "@thingd/sdk";
7
+ import { readCloudConfig } from "../lib/cloud-config.js";
8
+ import { readSyncConfig } from "../lib/sync-config.js";
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const _require = createRequire(__filename);
12
+ let pkgVersion = "0.0.0";
13
+ try {
14
+ const pkg = _require("../../package.json");
15
+ pkgVersion = pkg.version || "0.0.0";
16
+ }
17
+ catch {
18
+ // fallback — try relative to dist
19
+ try {
20
+ const pkg = _require("../package.json");
21
+ pkgVersion = pkg.version || "0.0.0";
22
+ }
23
+ catch {
24
+ // leave default
25
+ }
26
+ }
27
+ // Candidate public folders to support both tsx dev and compiled dist packaging
28
+ const publicDirCandidates = [
29
+ join(__dirname, "public"),
30
+ join(__dirname, "../public"),
31
+ join(__dirname, "../../../src/dashboard/public"),
32
+ join(__dirname, "../../src/dashboard/public"),
33
+ ];
34
+ let publicDir = "";
35
+ for (const cand of publicDirCandidates) {
36
+ if (existsSync(cand)) {
37
+ publicDir = cand;
38
+ break;
39
+ }
40
+ }
41
+ const MIME_TYPES = {
42
+ ".html": "text/html",
43
+ ".css": "text/css",
44
+ ".js": "application/javascript",
45
+ ".json": "application/json",
46
+ ".png": "image/png",
47
+ ".ico": "image/x-icon",
48
+ ".svg": "image/svg+xml",
49
+ ".webmanifest": "application/manifest+json",
50
+ };
51
+ async function readBody(req) {
52
+ return new Promise((resolvePromise, rejectPromise) => {
53
+ let body = "";
54
+ req.on("data", (chunk) => {
55
+ body += chunk;
56
+ });
57
+ req.on("end", () => {
58
+ resolvePromise(body);
59
+ });
60
+ req.on("error", (err) => {
61
+ rejectPromise(err);
62
+ });
63
+ });
64
+ }
65
+ function sendError(res, status, message) {
66
+ res.writeHead(status, { "Content-Type": "application/json" });
67
+ res.end(JSON.stringify({ error: message }));
68
+ }
69
+ function isCloudPath(path) {
70
+ return path.startsWith("http://") || path.startsWith("https://") || path.startsWith("thingd://");
71
+ }
72
+ export async function startDashboardServer(connectionOptions, port) {
73
+ // 1. Maintain dynamic active database options
74
+ let activeOptions = { ...connectionOptions };
75
+ let db = await ThingD.open({
76
+ path: activeOptions.path,
77
+ url: activeOptions.cloud ? activeOptions.path : undefined,
78
+ driver: activeOptions.driver,
79
+ authToken: activeOptions.authToken,
80
+ instanceSlug: activeOptions.instanceSlug,
81
+ });
82
+ // 2. Create HTTP Server
83
+ const server = createServer(async (req, res) => {
84
+ try {
85
+ const url = new URL(req.url || "", `http://${req.headers.host || "localhost"}`);
86
+ const pathname = url.pathname;
87
+ // Handle CORS for ease of developer integrations
88
+ const allowedOrigins = [
89
+ "http://localhost:8757",
90
+ "http://localhost:8758",
91
+ "http://127.0.0.1:8757",
92
+ "http://127.0.0.1:8758",
93
+ ];
94
+ const origin = req.headers.origin;
95
+ if (origin && allowedOrigins.includes(origin)) {
96
+ res.setHeader("Access-Control-Allow-Origin", origin);
97
+ }
98
+ else {
99
+ res.setHeader("Access-Control-Allow-Origin", "*");
100
+ }
101
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
102
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
103
+ if (req.method === "OPTIONS") {
104
+ res.writeHead(204);
105
+ res.end();
106
+ return;
107
+ }
108
+ // CSRF protection: state-changing requests must come from a known origin
109
+ if (req.method !== "GET" && req.method !== "OPTIONS" && req.method !== "HEAD") {
110
+ const requestOrigin = req.headers.origin;
111
+ if (requestOrigin && !allowedOrigins.includes(requestOrigin)) {
112
+ sendError(res, 403, "Cross-origin state-changing requests are not allowed");
113
+ return;
114
+ }
115
+ }
116
+ // Security Gate middleware for API endpoints
117
+ const isApiRoute = pathname.startsWith("/api/");
118
+ const isConnectRoute = pathname === "/api/connect";
119
+ const isRestRoute = pathname.startsWith("/v1/");
120
+ if ((isApiRoute || isRestRoute) && !isConnectRoute && activeOptions.authToken) {
121
+ const authHeader = req.headers.authorization;
122
+ const expectedHeader = `Bearer ${activeOptions.authToken}`;
123
+ if (!authHeader || authHeader !== expectedHeader) {
124
+ res.writeHead(401, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify({ error: "Unauthorized. Valid auth token is required." }));
126
+ return;
127
+ }
128
+ }
129
+ // REST API Routes
130
+ if (pathname.startsWith("/api/")) {
131
+ // POST /api/connect (Dynamic connection swapping)
132
+ if (pathname === "/api/connect" && req.method === "POST") {
133
+ const bodyStr = await readBody(req);
134
+ const { path, driver, authToken, instanceSlug } = JSON.parse(bodyStr);
135
+ if (!path || !driver) {
136
+ sendError(res, 400, "Fields 'path' and 'driver' are required.");
137
+ return;
138
+ }
139
+ const cloudMode = isCloudPath(path);
140
+ const resolvedToken = authToken || (cloudMode ? readCloudConfig()?.token : undefined);
141
+ const resolvedInstanceSlug = instanceSlug || activeOptions.instanceSlug;
142
+ // Safely shut down the old db instance
143
+ await db.close();
144
+ // Spawn new db connection dynamically
145
+ db = await ThingD.open({
146
+ path,
147
+ url: cloudMode ? path : undefined,
148
+ driver,
149
+ authToken: resolvedToken,
150
+ instanceSlug: resolvedInstanceSlug,
151
+ });
152
+ activeOptions = {
153
+ path,
154
+ driver,
155
+ authToken: resolvedToken,
156
+ cloud: cloudMode,
157
+ instanceSlug: resolvedInstanceSlug,
158
+ };
159
+ res.writeHead(200, { "Content-Type": "application/json" });
160
+ res.end(JSON.stringify({ success: true, path, driver }));
161
+ return;
162
+ }
163
+ // GET /api/status
164
+ if (pathname === "/api/status" && req.method === "GET") {
165
+ const [objects, events, activeJobs, deadJobs] = await Promise.all([
166
+ db.countObjects(),
167
+ db.countEvents(),
168
+ db.countActiveJobs(),
169
+ db.countDeadJobs(),
170
+ ]);
171
+ let dbSize = "N/A (in-memory)";
172
+ if (activeOptions.driver === "native" && existsSync(activeOptions.path)) {
173
+ try {
174
+ const stats = statSync(activeOptions.path);
175
+ dbSize = `${(stats.size / 1024).toFixed(1)} KB`;
176
+ }
177
+ catch {
178
+ dbSize = "N/A (error)";
179
+ }
180
+ }
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({
183
+ version: pkgVersion,
184
+ mode: activeOptions.cloud ? "cloud" : "local",
185
+ driver: activeOptions.driver || "memory",
186
+ path: activeOptions.path,
187
+ metrics: { objects, events, activeJobs, deadJobs, dbSize },
188
+ authRequired: !!activeOptions.authToken,
189
+ }));
190
+ return;
191
+ }
192
+ if (pathname === "/api/replication/status" && req.method === "GET") {
193
+ const config = readSyncConfig();
194
+ const replicationEvents = await db.events.list("__thingd:system:replication", {
195
+ limit: 1_000_000,
196
+ });
197
+ const last = replicationEvents.at(-1);
198
+ res.writeHead(200, { "Content-Type": "application/json" });
199
+ res.end(JSON.stringify({
200
+ configured: Boolean(config),
201
+ config,
202
+ sourceId: config?.sourceId,
203
+ latestCursor: last?.sequence ?? 0,
204
+ protectedCloudTarget: config?.provider === "thingd.cloud" && !config.allowCloudTarget,
205
+ }));
206
+ return;
207
+ }
208
+ // GET /api/collections
209
+ if (pathname === "/api/collections" && req.method === "GET") {
210
+ const collections = await db.listCollections();
211
+ res.writeHead(200, { "Content-Type": "application/json" });
212
+ res.end(JSON.stringify(collections));
213
+ return;
214
+ }
215
+ // GET/POST/DELETE /api/objects
216
+ if (pathname === "/api/objects") {
217
+ if (req.method === "GET") {
218
+ const collection = url.searchParams.get("collection");
219
+ if (!collection) {
220
+ sendError(res, 400, "Query parameter 'collection' is required.");
221
+ return;
222
+ }
223
+ const objects = await db.listObjects(collection);
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify(objects));
226
+ return;
227
+ }
228
+ if (req.method === "POST") {
229
+ const bodyStr = await readBody(req);
230
+ const { collection, id, text, data } = JSON.parse(bodyStr);
231
+ if (!collection || !id) {
232
+ sendError(res, 400, "Fields 'collection' and 'id' are required.");
233
+ return;
234
+ }
235
+ const result = await db.put(collection, { id, text, ...data });
236
+ res.writeHead(200, { "Content-Type": "application/json" });
237
+ res.end(JSON.stringify(result));
238
+ return;
239
+ }
240
+ if (req.method === "DELETE") {
241
+ const collection = url.searchParams.get("collection");
242
+ const id = url.searchParams.get("id");
243
+ if (!collection || !id) {
244
+ sendError(res, 400, "Query parameters 'collection' and 'id' are required.");
245
+ return;
246
+ }
247
+ const result = await db.delete(collection, id);
248
+ res.writeHead(200, { "Content-Type": "application/json" });
249
+ res.end(JSON.stringify(result));
250
+ return;
251
+ }
252
+ }
253
+ // GET/POST /api/events
254
+ if (pathname === "/api/events") {
255
+ if (req.method === "GET") {
256
+ const stream = url.searchParams.get("stream") || undefined;
257
+ const limitVal = url.searchParams.get("limit");
258
+ const limit = limitVal ? Number.parseInt(limitVal, 10) : undefined;
259
+ const events = await db.events.list(stream);
260
+ const sliced = limit ? events.slice(0, limit) : events;
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ res.end(JSON.stringify(sliced));
263
+ return;
264
+ }
265
+ if (req.method === "POST") {
266
+ const bodyStr = await readBody(req);
267
+ const { stream, type, text, data } = JSON.parse(bodyStr);
268
+ if (!stream || !type) {
269
+ sendError(res, 400, "Fields 'stream' and 'type' are required.");
270
+ return;
271
+ }
272
+ const result = await db.events.append(stream, { type, text, ...data });
273
+ res.writeHead(200, { "Content-Type": "application/json" });
274
+ res.end(JSON.stringify(result));
275
+ return;
276
+ }
277
+ }
278
+ // GET /api/events/streams
279
+ if (pathname === "/api/events/streams" && req.method === "GET") {
280
+ const streams = await db.listStreams();
281
+ res.writeHead(200, { "Content-Type": "application/json" });
282
+ res.end(JSON.stringify(streams));
283
+ return;
284
+ }
285
+ // GET /api/queues
286
+ if (pathname === "/api/queues" && req.method === "GET") {
287
+ const queues = await db.listQueues();
288
+ res.writeHead(200, { "Content-Type": "application/json" });
289
+ res.end(JSON.stringify(queues));
290
+ return;
291
+ }
292
+ // GET /api/queues/jobs
293
+ if (pathname === "/api/queues/jobs" && req.method === "GET") {
294
+ const queue = url.searchParams.get("queue");
295
+ const status = url.searchParams.get("status");
296
+ if (!queue) {
297
+ sendError(res, 400, "Query parameter 'queue' is required.");
298
+ return;
299
+ }
300
+ const q = db.queue(queue);
301
+ const jobs = status === "dead" ? await q.dead() : await q.list();
302
+ res.writeHead(200, { "Content-Type": "application/json" });
303
+ res.end(JSON.stringify(jobs));
304
+ return;
305
+ }
306
+ // GET /api/queues/stats
307
+ if (pathname === "/api/queues/stats" && req.method === "GET") {
308
+ const queue = url.searchParams.get("queue");
309
+ if (!queue) {
310
+ sendError(res, 400, "Query parameter 'queue' is required.");
311
+ return;
312
+ }
313
+ const q = db.queue(queue);
314
+ const [activeJobs, deadJobs] = await Promise.all([q.list(), q.dead()]);
315
+ const leased = activeJobs.filter((j) => j.status === "leased").length;
316
+ const ready = activeJobs.filter((j) => j.status === "ready").length;
317
+ res.writeHead(200, { "Content-Type": "application/json" });
318
+ res.end(JSON.stringify({
319
+ queue,
320
+ totalActive: activeJobs.length,
321
+ ready,
322
+ leased,
323
+ dead: deadJobs.length,
324
+ }));
325
+ return;
326
+ }
327
+ // POST /api/queues/push
328
+ if (pathname === "/api/queues/push" && req.method === "POST") {
329
+ const bodyStr = await readBody(req);
330
+ const { queue, payload, delayMs, maxAttempts, idempotencyKey } = JSON.parse(bodyStr);
331
+ if (!queue || !payload) {
332
+ sendError(res, 400, "Fields 'queue' and 'payload' are required.");
333
+ return;
334
+ }
335
+ const q = db.queue(queue);
336
+ const result = await q.push(payload, { delayMs, maxAttempts, idempotencyKey });
337
+ res.writeHead(200, { "Content-Type": "application/json" });
338
+ res.end(JSON.stringify(result));
339
+ return;
340
+ }
341
+ // POST /api/queues/claim
342
+ if (pathname === "/api/queues/claim" && req.method === "POST") {
343
+ const bodyStr = await readBody(req);
344
+ const { queue, leaseMs } = JSON.parse(bodyStr);
345
+ if (!queue) {
346
+ sendError(res, 400, "Field 'queue' is required.");
347
+ return;
348
+ }
349
+ const q = db.queue(queue);
350
+ const job = await q.claim({ leaseMs });
351
+ res.writeHead(200, { "Content-Type": "application/json" });
352
+ res.end(JSON.stringify(job || null));
353
+ return;
354
+ }
355
+ // POST /api/queues/ack
356
+ if (pathname === "/api/queues/ack" && req.method === "POST") {
357
+ const bodyStr = await readBody(req);
358
+ const { queue, jobId } = JSON.parse(bodyStr);
359
+ if (!queue || !jobId) {
360
+ sendError(res, 400, "Fields 'queue' and 'jobId' are required.");
361
+ return;
362
+ }
363
+ const q = db.queue(queue);
364
+ const result = await q.ack(jobId);
365
+ res.writeHead(200, { "Content-Type": "application/json" });
366
+ res.end(JSON.stringify(result));
367
+ return;
368
+ }
369
+ // POST /api/queues/nack
370
+ if (pathname === "/api/queues/nack" && req.method === "POST") {
371
+ const bodyStr = await readBody(req);
372
+ const { queue, jobId, error, delayMs } = JSON.parse(bodyStr);
373
+ if (!queue || !jobId) {
374
+ sendError(res, 400, "Fields 'queue' and 'jobId' are required.");
375
+ return;
376
+ }
377
+ const q = db.queue(queue);
378
+ const result = await q.nack(jobId, { error, delayMs });
379
+ res.writeHead(200, { "Content-Type": "application/json" });
380
+ res.end(JSON.stringify(result));
381
+ return;
382
+ }
383
+ // GET /api/search
384
+ if (pathname === "/api/search" && req.method === "GET") {
385
+ const query = url.searchParams.get("query");
386
+ const limitVal = url.searchParams.get("limit");
387
+ const collectionsStr = url.searchParams.get("collections");
388
+ const filterStr = url.searchParams.get("filter");
389
+ if (!query) {
390
+ sendError(res, 400, "Query parameter 'query' is required.");
391
+ return;
392
+ }
393
+ const limit = limitVal ? Number.parseInt(limitVal, 10) : undefined;
394
+ const collections = collectionsStr ? collectionsStr.split(",") : undefined;
395
+ const filter = filterStr ? JSON.parse(filterStr) : undefined;
396
+ const results = await db.search(query, { limit, collections, filter });
397
+ res.writeHead(200, { "Content-Type": "application/json" });
398
+ res.end(JSON.stringify(results));
399
+ return;
400
+ }
401
+ // GET /api/schema
402
+ if (pathname === "/api/schema" && req.method === "GET") {
403
+ const collection = url.searchParams.get("collection") || undefined;
404
+ const schemas = await db.schema(collection);
405
+ res.writeHead(200, { "Content-Type": "application/json" });
406
+ res.end(JSON.stringify(schemas));
407
+ return;
408
+ }
409
+ // POST /api/nlq
410
+ if (pathname === "/api/nlq" && req.method === "POST") {
411
+ const bodyStr = await readBody(req);
412
+ const { question, collection, model, endpoint, apiKey } = JSON.parse(bodyStr);
413
+ if (!question) {
414
+ sendError(res, 400, "Field 'question' is required.");
415
+ return;
416
+ }
417
+ const llmModel = model || "llama3";
418
+ const llmEndpoint = (endpoint || "http://localhost:11434/v1").replace(/\/+$/, "");
419
+ const llmApiKey = apiKey || "";
420
+ // Step 1: Reflect schema
421
+ const schemas = await db.schema(collection || undefined);
422
+ if (!schemas || schemas.length === 0) {
423
+ sendError(res, 400, "No collections found. Add objects first or specify a valid collection.");
424
+ return;
425
+ }
426
+ // Step 2: Build prompt and call LLM
427
+ const systemPrompt = `You are a data analysis assistant. The user has a thingd database with these collections and inferred schemas:
428
+
429
+ ${JSON.stringify(schemas, null, 2)}
430
+
431
+ You can perform these operations on the data:
432
+ - "aggregate": count/sum/avg/min/max with optional groupBy
433
+ - "timeseries": time-bucketed aggregation by hour/day/week/month
434
+ - "search": full-text search across objects
435
+
436
+ Respond with ONLY a JSON object (no markdown, no explanation) matching this type:
437
+ {
438
+ "action": "aggregate" | "timeseries" | "search",
439
+ "collection": "string (collection name)",
440
+ "function": "count" | "sum" | "avg" | "min" | "max" (omit for search)",
441
+ "field": "string (field name for sum/avg/min/max, omit for count)",
442
+ "groupBy": "string (field name to group by, optional)",
443
+ "bucket": "hour" | "day" | "week" | "month" (only for timeseries)",
444
+ "query": "string (search query, only for search action)",
445
+ "limit": number (optional, max 100)
446
+ }}
447
+
448
+ Example: { "action": "aggregate", "collection": "orders", "function": "sum", "field": "revenue", "groupBy": "region" }`;
449
+ const llmResponse = await fetch(`${llmEndpoint}/chat/completions`, {
450
+ method: "POST",
451
+ headers: {
452
+ "Content-Type": "application/json",
453
+ ...(llmApiKey ? { Authorization: `Bearer ${llmApiKey}` } : {}),
454
+ },
455
+ body: JSON.stringify({
456
+ model: llmModel,
457
+ messages: [
458
+ { role: "system", content: systemPrompt },
459
+ { role: "user", content: question },
460
+ ],
461
+ max_tokens: 1024,
462
+ temperature: 0.1,
463
+ }),
464
+ });
465
+ if (!llmResponse.ok) {
466
+ const errText = await llmResponse.text();
467
+ console.error("LLM request failed:", llmResponse.status, errText);
468
+ sendError(res, 502, "LLM request failed");
469
+ return;
470
+ }
471
+ const llmData = await llmResponse.json();
472
+ const llmText = llmData.choices?.[0]?.message?.content;
473
+ if (!llmText) {
474
+ sendError(res, 502, "LLM returned no choices");
475
+ return;
476
+ }
477
+ // Step 3: Parse intent
478
+ const cleaned = llmText
479
+ .trim()
480
+ .replace(/^```(?:json)?\s*/, "")
481
+ .replace(/\s*```$/, "")
482
+ .trim();
483
+ let intent;
484
+ try {
485
+ intent = JSON.parse(cleaned);
486
+ }
487
+ catch {
488
+ sendError(res, 502, `Failed to parse LLM response as JSON: ${cleaned}`);
489
+ return;
490
+ }
491
+ // Step 4: Execute
492
+ let data;
493
+ switch (intent.action) {
494
+ case "aggregate": {
495
+ const fn = intent.function || "count";
496
+ const col = intent.collection;
497
+ if (fn === "count") {
498
+ data = await db.aggregate.count(col, {
499
+ groupBy: intent.groupBy,
500
+ });
501
+ }
502
+ else if (fn === "sum") {
503
+ data = await db.aggregate.sum(col, intent.field, {
504
+ groupBy: intent.groupBy,
505
+ });
506
+ }
507
+ else if (fn === "avg") {
508
+ data = await db.aggregate.avg(col, intent.field, {
509
+ groupBy: intent.groupBy,
510
+ });
511
+ }
512
+ else if (fn === "min") {
513
+ data = await db.aggregate.min(col, intent.field, {
514
+ groupBy: intent.groupBy,
515
+ });
516
+ }
517
+ else {
518
+ data = await db.aggregate.max(col, intent.field, {
519
+ groupBy: intent.groupBy,
520
+ });
521
+ }
522
+ break;
523
+ }
524
+ case "timeseries": {
525
+ const bucket = (intent.bucket || "day");
526
+ data = await db.timeseries(intent.collection, {
527
+ function: (intent.function || "count"),
528
+ bucket,
529
+ field: intent.field,
530
+ });
531
+ break;
532
+ }
533
+ case "search": {
534
+ data = await db.search(intent.query || question, {
535
+ collections: [intent.collection],
536
+ limit: intent.limit || 10,
537
+ });
538
+ break;
539
+ }
540
+ default:
541
+ sendError(res, 400, `Unknown action: ${intent.action}`);
542
+ return;
543
+ }
544
+ const result = {
545
+ answer: formatAnswer(intent, data),
546
+ data,
547
+ intent,
548
+ };
549
+ res.writeHead(200, { "Content-Type": "application/json" });
550
+ res.end(JSON.stringify(result));
551
+ return;
552
+ }
553
+ // GET /api/db/checkpoint
554
+ if (pathname === "/api/db/checkpoint" && req.method === "GET") {
555
+ try {
556
+ const result = db.walCheckpoint();
557
+ res.writeHead(200, { "Content-Type": "application/json" });
558
+ res.end(JSON.stringify(result));
559
+ }
560
+ catch (e) {
561
+ console.error("Checkpoint failed:", e);
562
+ sendError(res, 400, "Checkpoint failed");
563
+ }
564
+ return;
565
+ }
566
+ // GET /api/db/integrity
567
+ if (pathname === "/api/db/integrity" && req.method === "GET") {
568
+ try {
569
+ await db.countObjects();
570
+ res.writeHead(200, { "Content-Type": "application/json" });
571
+ res.end(JSON.stringify({ ok: true, message: "Database is accessible" }));
572
+ }
573
+ catch (e) {
574
+ console.error("Integrity check failed:", e);
575
+ res.writeHead(200, { "Content-Type": "application/json" });
576
+ res.end(JSON.stringify({ ok: false, message: "Database integrity check failed" }));
577
+ }
578
+ return;
579
+ }
580
+ // POST /api/backup
581
+ if (pathname === "/api/backup" && req.method === "POST") {
582
+ try {
583
+ let body = "";
584
+ for await (const chunk of req) {
585
+ body += chunk;
586
+ }
587
+ const { path: backupPath } = JSON.parse(body);
588
+ if (!backupPath) {
589
+ sendError(res, 400, "Missing 'path' in request body");
590
+ return;
591
+ }
592
+ db.backupTo(backupPath);
593
+ const { statSync } = await import("node:fs");
594
+ const stats = statSync(backupPath);
595
+ res.writeHead(200, { "Content-Type": "application/json" });
596
+ res.end(JSON.stringify({ path: backupPath, sizeBytes: stats.size }));
597
+ }
598
+ catch (e) {
599
+ console.error("Backup failed:", e);
600
+ sendError(res, 500, "Backup failed");
601
+ }
602
+ return;
603
+ }
604
+ // GET /api/config/error-mode
605
+ if (pathname === "/api/config/error-mode" && req.method === "GET") {
606
+ res.writeHead(200, { "Content-Type": "application/json" });
607
+ res.end(JSON.stringify({ productionMode: false }));
608
+ return;
609
+ }
610
+ sendError(res, 404, `Endpoint ${req.method} ${pathname} not found.`);
611
+ return;
612
+ }
613
+ // REST API Routes (/v1/*)
614
+ if (pathname.startsWith("/v1/")) {
615
+ await handleRestRequest(db, req, res, pathname);
616
+ return;
617
+ }
618
+ // Static File Server
619
+ const targetFilePath = pathname === "/" ? "index.html" : pathname.replace(/^\//, "");
620
+ const fullFilePath = join(publicDir, targetFilePath);
621
+ // Security: ensure the resolved path is inside the public folder
622
+ const relativePath = relative(publicDir, fullFilePath);
623
+ if (isAbsolute(relativePath) || relativePath.startsWith("..")) {
624
+ res.writeHead(403);
625
+ res.end("Forbidden");
626
+ return;
627
+ }
628
+ if (existsSync(fullFilePath)) {
629
+ const fileContent = await fs.readFile(fullFilePath);
630
+ const ext = extname(fullFilePath).toLowerCase();
631
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
632
+ res.writeHead(200, { "Content-Type": contentType });
633
+ res.end(fileContent);
634
+ }
635
+ else {
636
+ res.writeHead(404);
637
+ res.end("Not Found");
638
+ }
639
+ }
640
+ catch (err) {
641
+ console.error("Dashboard server exception:", err);
642
+ sendError(res, 500, "Internal server error");
643
+ }
644
+ });
645
+ return new Promise((resolvePromise, rejectPromise) => {
646
+ server.listen(port, () => {
647
+ resolvePromise({
648
+ server,
649
+ close: async () => {
650
+ await new Promise((closeRes) => server.close(() => closeRes()));
651
+ await db.close();
652
+ },
653
+ });
654
+ });
655
+ server.on("error", (err) => {
656
+ rejectPromise(err);
657
+ });
658
+ });
659
+ }
660
+ function formatAnswer(intent, data) {
661
+ const fnName = intent.function || "count";
662
+ const field = intent.field || "objects";
663
+ switch (intent.action) {
664
+ case "aggregate": {
665
+ const d = data;
666
+ const total = d?.total ?? 0;
667
+ const groups = d?.groups?.length ?? 0;
668
+ if (groups > 0) {
669
+ return `${fnName} of ${field} = ${total}, grouped by ${intent.groupBy} into ${groups} groups`;
670
+ }
671
+ return `${fnName} of ${field} = ${total}`;
672
+ }
673
+ case "timeseries": {
674
+ const d = data;
675
+ return `Time series with ${d?.buckets?.length ?? 0} buckets`;
676
+ }
677
+ case "search": {
678
+ const hits = data?.length ?? 0;
679
+ return `Found ${hits} results`;
680
+ }
681
+ default:
682
+ return "Query executed.";
683
+ }
684
+ }