@vivantel/virage-cli 0.1.6 → 0.1.8

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.
@@ -1,22 +1,18 @@
1
- import { createServer } from "http";
2
- import { readFile, writeFile, mkdir } from "fs/promises";
1
+ import express from "express";
2
+ import { WebSocketServer } from "ws";
3
+ import { readFile, writeFile, mkdir, unlink } from "fs/promises";
3
4
  import { existsSync } from "fs";
4
- import { join, extname, basename, resolve } from "path";
5
+ import { join, resolve, basename } from "path";
5
6
  import { homedir } from "os";
6
7
  import { fileURLToPath } from "url";
7
- import { EmbeddingsDb } from "@vivantel/virage-core";
8
- const MIME = {
9
- ".html": "text/html; charset=utf-8",
10
- ".js": "application/javascript",
11
- ".css": "text/css",
12
- ".svg": "image/svg+xml",
13
- ".ico": "image/x-icon",
14
- };
8
+ import { EmbeddingsDb, loadConfig, Orchestrator, ExperimentStore, bootstrapPairedTest, generateEvalDataset, EvalRunner, loadEvalDataset, } from "@vivantel/virage-core";
15
9
  const RECENT_PROJECTS_PATH = join(homedir(), ".virage", "recent-projects.json");
16
10
  const MAX_PROJECTS = 10;
17
11
  let projectsState = { projects: [], activeIndex: 0 };
18
- // Resolve the compiled dashboard-ui/ directory shipped alongside this package.
19
- // At runtime the compiled CLI lives at dist/cli/dashboard.js; dashboard-ui is at dist/dashboard-ui/.
12
+ // Cache the loaded config per project root to avoid reloading the embedder model on every search
13
+ let configCache = null;
14
+ // Single-operation guard for WebSocket pipeline runs
15
+ let wsOperationRunning = false;
20
16
  const THIS_DIR = fileURLToPath(new URL(".", import.meta.url));
21
17
  const UI_DIR = join(THIS_DIR, "..", "dashboard-ui");
22
18
  const HAS_UI = existsSync(join(UI_DIR, "index.html"));
@@ -37,7 +33,7 @@ async function loadRecentProjects() {
37
33
  }
38
34
  }
39
35
  catch {
40
- /* first run — file does not exist yet */
36
+ /* first run */
41
37
  }
42
38
  return { projects: [], activeIndex: 0 };
43
39
  }
@@ -51,166 +47,20 @@ async function saveRecentProjects(state) {
51
47
  await mkdir(join(homedir(), ".virage"), { recursive: true });
52
48
  await writeFile(RECENT_PROJECTS_PATH, JSON.stringify(next, null, 2), "utf-8");
53
49
  projectsState = next;
50
+ configCache = null; // invalidate on project change
54
51
  }
55
- function readBody(req) {
56
- return new Promise((resolve, reject) => {
57
- const chunks = [];
58
- req.on("data", (chunk) => chunks.push(chunk));
59
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
60
- req.on("error", reject);
61
- });
52
+ function activeProject() {
53
+ return projectsState.projects[projectsState.activeIndex];
62
54
  }
63
- async function serveStatic(urlPath, res) {
64
- if (!HAS_UI)
65
- return false;
66
- const normalized = urlPath === "/" || urlPath === "" ? "/index.html" : urlPath;
67
- // Only serve known static assets; fall back to index.html for SPA routing
68
- const filePath = join(UI_DIR, normalized);
69
- const ext = extname(filePath).toLowerCase();
70
- try {
71
- const content = await readFile(filePath);
72
- res.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
73
- res.end(content);
74
- return true;
75
- }
76
- catch {
77
- // Try index.html for SPA client-side routing (non-asset paths)
78
- if (!normalized.startsWith("/assets/") && ext === ".html") {
79
- try {
80
- const html = await readFile(join(UI_DIR, "index.html"));
81
- res.setHeader("Content-Type", "text/html; charset=utf-8");
82
- res.end(html);
83
- return true;
84
- }
85
- catch {
86
- return false;
87
- }
88
- }
89
- return false;
90
- }
91
- }
92
- export async function runDashboard(opts) {
93
- if (!HAS_UI) {
94
- console.warn("⚠️ Dashboard UI not found. Run `npm run build -w @vivantel/virage-dashboard` first.");
95
- }
96
- // Derive the startup project from CLI options and register it
97
- const startupRoot = resolve(opts.dbPath, "..", "..");
98
- const startupProject = projectFromRoot(startupRoot, {
99
- embeddingsDb: resolve(opts.dbPath),
100
- });
101
- const loaded = await loadRecentProjects();
102
- const existingIdx = loaded.projects.findIndex((p) => p.rootPath === startupProject.rootPath);
103
- if (existingIdx >= 0) {
104
- loaded.projects[existingIdx] = startupProject;
105
- loaded.activeIndex = existingIdx;
106
- }
107
- else {
108
- loaded.projects.unshift(startupProject);
109
- loaded.activeIndex = 0;
110
- }
111
- await saveRecentProjects(loaded);
112
- const server = createServer(async (req, res) => {
113
- const url = req.url ?? "/";
114
- res.setHeader("Content-Type", "application/json");
115
- try {
116
- const active = projectsState.projects[projectsState.activeIndex];
117
- if (url === "/api/status" && req.method === "GET") {
118
- const status = await getStatus(active.embeddingsDb);
119
- res.end(JSON.stringify(status));
120
- return;
121
- }
122
- if (url === "/api/chunks" && req.method === "GET") {
123
- const chunks = await getChunksHistogram(active.embeddingsDb);
124
- res.end(JSON.stringify(chunks));
125
- return;
126
- }
127
- if (url === "/api/embeddings/anomalies" && req.method === "GET") {
128
- const anomalies = await getAnomalies(active.embeddingsDb);
129
- res.end(JSON.stringify(anomalies));
130
- return;
131
- }
132
- if (url === "/api/projects" && req.method === "GET") {
133
- res.end(JSON.stringify(projectsState));
134
- return;
135
- }
136
- if (url === "/api/projects/add" && req.method === "POST") {
137
- const body = JSON.parse(await readBody(req));
138
- if (typeof body.rootPath !== "string" || !body.rootPath.trim()) {
139
- res.statusCode = 400;
140
- res.end(JSON.stringify({ error: "rootPath is required" }));
141
- return;
142
- }
143
- const entry = projectFromRoot(body.rootPath.trim());
144
- if (!existsSync(entry.embeddingsDb)) {
145
- res.statusCode = 422;
146
- res.end(JSON.stringify({
147
- error: `No .virage data found in ${entry.rootPath}`,
148
- }));
149
- return;
150
- }
151
- const idx = projectsState.projects.findIndex((p) => p.rootPath === entry.rootPath);
152
- const next = {
153
- ...projectsState,
154
- projects: [...projectsState.projects],
155
- };
156
- if (idx >= 0) {
157
- next.projects[idx] = { ...entry, lastUsed: Date.now() };
158
- next.activeIndex = idx;
159
- }
160
- else {
161
- next.projects = [entry, ...next.projects];
162
- next.activeIndex = 0;
163
- }
164
- await saveRecentProjects(next);
165
- res.end(JSON.stringify(projectsState));
166
- return;
167
- }
168
- if (url === "/api/projects/switch" && req.method === "POST") {
169
- const body = JSON.parse(await readBody(req));
170
- const index = Number(body.index);
171
- if (!Number.isInteger(index) ||
172
- index < 0 ||
173
- index >= projectsState.projects.length) {
174
- res.statusCode = 400;
175
- res.end(JSON.stringify({ error: "Invalid index" }));
176
- return;
177
- }
178
- const next = {
179
- projects: [...projectsState.projects],
180
- activeIndex: index,
181
- };
182
- next.projects[index] = {
183
- ...next.projects[index],
184
- lastUsed: Date.now(),
185
- };
186
- await saveRecentProjects(next);
187
- res.end(JSON.stringify(projectsState));
188
- return;
189
- }
190
- // Serve React app static assets
191
- const served = await serveStatic(url, res);
192
- if (!served) {
193
- res.statusCode = 404;
194
- res.end(JSON.stringify({ error: "Not found" }));
195
- }
196
- }
197
- catch (err) {
198
- res.statusCode = 500;
199
- res.end(JSON.stringify({ error: err.message }));
200
- }
201
- });
202
- server.listen(opts.port, () => {
203
- console.log(`🚀 RAG Dashboard running at http://localhost:${opts.port}`);
204
- console.log(" Press Ctrl+C to stop");
205
- });
206
- // Keep process alive
207
- await new Promise((resolve) => {
208
- process.on("SIGINT", () => {
209
- server.close();
210
- resolve();
211
- });
212
- });
55
+ async function getCachedConfig(configPath) {
56
+ if (configCache?.path === configPath)
57
+ return configCache.cfg;
58
+ const cfg = await loadConfig(configPath);
59
+ await cfg.vectorStore.initialize();
60
+ configCache = { path: configPath, cfg };
61
+ return cfg;
213
62
  }
63
+ // ─── Status helpers (preserved from original) ─────────────────────────────────
214
64
  async function getStatus(dbPath) {
215
65
  let totalChunks = 0;
216
66
  let totalEmbeddings = 0;
@@ -273,4 +123,389 @@ async function getAnomalies(dbPath) {
273
123
  return { anomalies: [] };
274
124
  }
275
125
  }
126
+ // ─── WebSocket handler ─────────────────────────────────────────────────────────
127
+ function safeSend(ws, msg) {
128
+ if (ws.readyState === ws.OPEN) {
129
+ ws.send(JSON.stringify(msg));
130
+ }
131
+ }
132
+ async function handleWsOperation(ws, op) {
133
+ const active = activeProject();
134
+ if (!active) {
135
+ safeSend(ws, { type: "error", message: "No active project" });
136
+ return;
137
+ }
138
+ const configPath = join(active.rootPath, "virage.config.json");
139
+ if (!existsSync(configPath)) {
140
+ safeSend(ws, {
141
+ type: "error",
142
+ message: `virage.config.json not found in ${active.rootPath}`,
143
+ });
144
+ return;
145
+ }
146
+ try {
147
+ const cfg = await getCachedConfig(configPath);
148
+ if (op === "update") {
149
+ const orchestrator = new Orchestrator({
150
+ ...cfg,
151
+ options: {
152
+ ...cfg.options,
153
+ onChunkProgress: (done, total) => safeSend(ws, { type: "progress", stage: "chunk", done, total }),
154
+ onEmbedProgress: (done, total) => safeSend(ws, { type: "progress", stage: "embed", done, total }),
155
+ onUploadProgress: (done, total) => safeSend(ws, { type: "progress", stage: "upload", done, total }),
156
+ },
157
+ });
158
+ safeSend(ws, { type: "progress", stage: "starting", done: 0, total: 0 });
159
+ await orchestrator.run();
160
+ safeSend(ws, { type: "done" });
161
+ }
162
+ else if (op === "eval-generate") {
163
+ const db = new EmbeddingsDb(active.embeddingsDb);
164
+ const chunks = db.getAllChunks();
165
+ db.close();
166
+ safeSend(ws, {
167
+ type: "progress",
168
+ stage: "eval-generate",
169
+ message: `Generating eval dataset from ${chunks.length} chunks...`,
170
+ });
171
+ const outputPath = join(active.rootPath, ".virage", "eval-dataset.json");
172
+ await generateEvalDataset(chunks, { includeNegatives: false, paraphraseRatio: 0 }, outputPath);
173
+ safeSend(ws, {
174
+ type: "done",
175
+ message: `Eval dataset written to ${outputPath}`,
176
+ });
177
+ }
178
+ else if (op === "evaluate") {
179
+ const datasetPath = join(active.rootPath, ".virage", "eval-dataset.json");
180
+ if (!existsSync(datasetPath)) {
181
+ safeSend(ws, {
182
+ type: "error",
183
+ message: "No eval dataset found. Run eval-generate first.",
184
+ });
185
+ return;
186
+ }
187
+ const dataset = await loadEvalDataset(datasetPath);
188
+ safeSend(ws, {
189
+ type: "progress",
190
+ stage: "evaluate",
191
+ message: `Evaluating ${dataset.queries.length} queries...`,
192
+ });
193
+ const runner = new EvalRunner(cfg.vectorStore, cfg.embedder, dataset, 10);
194
+ const result = await runner.run((completed, total) => safeSend(ws, {
195
+ type: "progress",
196
+ stage: "evaluate",
197
+ done: completed,
198
+ total,
199
+ }));
200
+ safeSend(ws, { type: "done", result });
201
+ }
202
+ else {
203
+ safeSend(ws, { type: "error", message: `Unknown operation: ${op}` });
204
+ }
205
+ }
206
+ catch (err) {
207
+ safeSend(ws, {
208
+ type: "error",
209
+ message: err instanceof Error ? err.message : String(err),
210
+ });
211
+ }
212
+ }
213
+ // ─── Main entry point ──────────────────────────────────────────────────────────
214
+ export async function runDashboard(opts) {
215
+ if (!HAS_UI) {
216
+ console.warn("⚠️ Dashboard UI not found. Run `npm run build -w @vivantel/virage-dashboard` first.");
217
+ }
218
+ const startupRoot = resolve(opts.dbPath, "..", "..");
219
+ const startupProject = projectFromRoot(startupRoot, {
220
+ embeddingsDb: resolve(opts.dbPath),
221
+ });
222
+ const loaded = await loadRecentProjects();
223
+ const existingIdx = loaded.projects.findIndex((p) => p.rootPath === startupProject.rootPath);
224
+ if (existingIdx >= 0) {
225
+ loaded.projects[existingIdx] = startupProject;
226
+ loaded.activeIndex = existingIdx;
227
+ }
228
+ else {
229
+ loaded.projects.unshift(startupProject);
230
+ loaded.activeIndex = 0;
231
+ }
232
+ await saveRecentProjects(loaded);
233
+ const app = express();
234
+ app.use(express.json());
235
+ // ─── Existing routes ────────────────────────────────────────────────────────
236
+ app.get("/api/status", async (_req, res) => {
237
+ const active = activeProject();
238
+ res.json(await getStatus(active?.embeddingsDb ?? opts.dbPath));
239
+ });
240
+ app.get("/api/chunks", async (_req, res) => {
241
+ const active = activeProject();
242
+ res.json(await getChunksHistogram(active?.embeddingsDb ?? opts.dbPath));
243
+ });
244
+ app.get("/api/embeddings/anomalies", async (_req, res) => {
245
+ const active = activeProject();
246
+ res.json(await getAnomalies(active?.embeddingsDb ?? opts.dbPath));
247
+ });
248
+ app.get("/api/projects", (_req, res) => {
249
+ res.json(projectsState);
250
+ });
251
+ app.post("/api/projects/add", async (req, res) => {
252
+ const body = req.body;
253
+ if (typeof body.rootPath !== "string" || !body.rootPath.trim()) {
254
+ res.status(400).json({ error: "rootPath is required" });
255
+ return;
256
+ }
257
+ const entry = projectFromRoot(body.rootPath.trim());
258
+ if (!existsSync(entry.embeddingsDb)) {
259
+ res.status(422).json({
260
+ error: `No .virage data found in ${entry.rootPath}`,
261
+ });
262
+ return;
263
+ }
264
+ const idx = projectsState.projects.findIndex((p) => p.rootPath === entry.rootPath);
265
+ const next = { ...projectsState, projects: [...projectsState.projects] };
266
+ if (idx >= 0) {
267
+ next.projects[idx] = { ...entry, lastUsed: Date.now() };
268
+ next.activeIndex = idx;
269
+ }
270
+ else {
271
+ next.projects = [entry, ...next.projects];
272
+ next.activeIndex = 0;
273
+ }
274
+ await saveRecentProjects(next);
275
+ res.json(projectsState);
276
+ });
277
+ app.post("/api/projects/switch", async (req, res) => {
278
+ const body = req.body;
279
+ const index = Number(body.index);
280
+ if (!Number.isInteger(index) ||
281
+ index < 0 ||
282
+ index >= projectsState.projects.length) {
283
+ res.status(400).json({ error: "Invalid index" });
284
+ return;
285
+ }
286
+ const next = {
287
+ projects: [...projectsState.projects],
288
+ activeIndex: index,
289
+ };
290
+ next.projects[index] = { ...next.projects[index], lastUsed: Date.now() };
291
+ await saveRecentProjects(next);
292
+ res.json(projectsState);
293
+ });
294
+ // ─── New chunk routes ────────────────────────────────────────────────────────
295
+ app.get("/api/chunks/all", (req, res) => {
296
+ const active = activeProject();
297
+ if (!active) {
298
+ res.status(503).json({ error: "No active project" });
299
+ return;
300
+ }
301
+ try {
302
+ const db = new EmbeddingsDb(active.embeddingsDb);
303
+ let chunks = db.getAllChunks();
304
+ db.close();
305
+ const sf = req.query["sourceFile"];
306
+ if (typeof sf === "string") {
307
+ chunks = chunks.filter((c) => c.sourceFile === sf);
308
+ }
309
+ res.json({ chunks });
310
+ }
311
+ catch (err) {
312
+ res.status(500).json({ error: err.message });
313
+ }
314
+ });
315
+ app.delete("/api/chunks/file", (req, res) => {
316
+ const body = req.body;
317
+ if (typeof body.sourceFile !== "string" || !body.sourceFile.trim()) {
318
+ res.status(400).json({ error: "sourceFile is required" });
319
+ return;
320
+ }
321
+ const active = activeProject();
322
+ if (!active) {
323
+ res.status(503).json({ error: "No active project" });
324
+ return;
325
+ }
326
+ try {
327
+ const db = new EmbeddingsDb(active.embeddingsDb);
328
+ db.deleteBySourceFile(body.sourceFile.trim());
329
+ db.close();
330
+ res.json({ ok: true });
331
+ }
332
+ catch (err) {
333
+ res.status(500).json({ error: err.message });
334
+ }
335
+ });
336
+ app.delete("/api/chunks/all", (_req, res) => {
337
+ const active = activeProject();
338
+ if (!active) {
339
+ res.status(503).json({ error: "No active project" });
340
+ return;
341
+ }
342
+ try {
343
+ const db = new EmbeddingsDb(active.embeddingsDb);
344
+ db.clearAll();
345
+ db.close();
346
+ res.json({ ok: true });
347
+ }
348
+ catch (err) {
349
+ res.status(500).json({ error: err.message });
350
+ }
351
+ });
352
+ // ─── RAG search route ────────────────────────────────────────────────────────
353
+ app.post("/api/search", async (req, res) => {
354
+ const body = req.body;
355
+ if (typeof body.query !== "string" || !body.query.trim()) {
356
+ res.status(400).json({ error: "query is required" });
357
+ return;
358
+ }
359
+ const active = activeProject();
360
+ if (!active) {
361
+ res.status(503).json({ error: "No active project" });
362
+ return;
363
+ }
364
+ const configPath = join(active.rootPath, "virage.config.json");
365
+ if (!existsSync(configPath)) {
366
+ res
367
+ .status(422)
368
+ .json({ error: `virage.config.json not found in ${active.rootPath}` });
369
+ return;
370
+ }
371
+ try {
372
+ const cfg = await getCachedConfig(configPath);
373
+ const topK = typeof body.topK === "number" ? body.topK : 5;
374
+ const embedding = await cfg.embedder.embed(body.query.trim());
375
+ const results = await cfg.vectorStore.search(embedding, topK);
376
+ res.json({ results });
377
+ }
378
+ catch (err) {
379
+ res.status(500).json({ error: err.message });
380
+ }
381
+ });
382
+ // ─── Experiment routes ───────────────────────────────────────────────────────
383
+ const ID_RE = /^[a-zA-Z0-9_-]+$/;
384
+ app.get("/api/experiments", async (_req, res) => {
385
+ const active = activeProject();
386
+ if (!active) {
387
+ res.status(503).json({ error: "No active project" });
388
+ return;
389
+ }
390
+ try {
391
+ const store = new ExperimentStore(join(active.rootPath, ".rag-experiments"));
392
+ const runs = await store.list();
393
+ res.json({ runs });
394
+ }
395
+ catch (err) {
396
+ res.status(500).json({ error: err.message });
397
+ }
398
+ });
399
+ app.get("/api/experiments/:id", async (req, res) => {
400
+ if (!ID_RE.test(req.params["id"] ?? "")) {
401
+ res.status(400).json({ error: "Invalid experiment id" });
402
+ return;
403
+ }
404
+ const active = activeProject();
405
+ if (!active) {
406
+ res.status(503).json({ error: "No active project" });
407
+ return;
408
+ }
409
+ try {
410
+ const store = new ExperimentStore(join(active.rootPath, ".rag-experiments"));
411
+ const run = await store.load(req.params["id"]);
412
+ res.json(run);
413
+ }
414
+ catch (err) {
415
+ res.status(404).json({ error: err.message });
416
+ }
417
+ });
418
+ app.delete("/api/experiments/:id", async (req, res) => {
419
+ if (!ID_RE.test(req.params["id"] ?? "")) {
420
+ res.status(400).json({ error: "Invalid experiment id" });
421
+ return;
422
+ }
423
+ const active = activeProject();
424
+ if (!active) {
425
+ res.status(503).json({ error: "No active project" });
426
+ return;
427
+ }
428
+ try {
429
+ const filePath = join(active.rootPath, ".rag-experiments", `${req.params["id"]}.json`);
430
+ await unlink(filePath);
431
+ res.json({ ok: true });
432
+ }
433
+ catch (err) {
434
+ res.status(500).json({ error: err.message });
435
+ }
436
+ });
437
+ app.post("/api/experiments/compare", async (req, res) => {
438
+ const body = req.body;
439
+ if (typeof body.baseline !== "string" ||
440
+ typeof body.candidate !== "string") {
441
+ res
442
+ .status(400)
443
+ .json({ error: "baseline and candidate ids are required" });
444
+ return;
445
+ }
446
+ const active = activeProject();
447
+ if (!active) {
448
+ res.status(503).json({ error: "No active project" });
449
+ return;
450
+ }
451
+ try {
452
+ const store = new ExperimentStore(join(active.rootPath, ".rag-experiments"));
453
+ const [bRun, cRun] = await Promise.all([
454
+ store.load(body.baseline),
455
+ store.load(body.candidate),
456
+ ]);
457
+ if (!bRun.perQueryRrScores || !cRun.perQueryRrScores) {
458
+ res
459
+ .status(422)
460
+ .json({ error: "Per-query scores unavailable for comparison" });
461
+ return;
462
+ }
463
+ const result = bootstrapPairedTest(bRun.perQueryRrScores, cRun.perQueryRrScores);
464
+ res.json(result);
465
+ }
466
+ catch (err) {
467
+ res.status(500).json({ error: err.message });
468
+ }
469
+ });
470
+ // ─── Static assets + SPA fallback ───────────────────────────────────────────
471
+ if (HAS_UI) {
472
+ app.use(express.static(UI_DIR));
473
+ app.get("*", (_req, res) => {
474
+ res.sendFile(join(UI_DIR, "index.html"));
475
+ });
476
+ }
477
+ // ─── Start server + attach WebSocket ────────────────────────────────────────
478
+ const server = app.listen(opts.port, () => {
479
+ console.log(`🚀 RAG Dashboard running at http://localhost:${opts.port}`);
480
+ console.log(" Press Ctrl+C to stop");
481
+ });
482
+ const wss = new WebSocketServer({ server, path: "/ws" });
483
+ wss.on("connection", (ws) => {
484
+ ws.on("message", (raw) => {
485
+ let msg;
486
+ try {
487
+ msg = JSON.parse(raw.toString());
488
+ }
489
+ catch {
490
+ safeSend(ws, { type: "error", message: "Invalid JSON" });
491
+ return;
492
+ }
493
+ if (wsOperationRunning) {
494
+ safeSend(ws, { type: "busy" });
495
+ return;
496
+ }
497
+ wsOperationRunning = true;
498
+ handleWsOperation(ws, msg.op ?? "").finally(() => {
499
+ wsOperationRunning = false;
500
+ });
501
+ });
502
+ });
503
+ await new Promise((resolve) => {
504
+ process.on("SIGINT", () => {
505
+ wss.close();
506
+ server.close();
507
+ resolve();
508
+ });
509
+ });
510
+ }
276
511
  //# sourceMappingURL=dashboard.js.map