pi-vault-mind 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +21 -21
  2. package/dist/src/agent-queue.d.ts +108 -0
  3. package/dist/src/agent-queue.js +225 -0
  4. package/dist/src/auth.d.ts +30 -0
  5. package/dist/src/auth.js +93 -0
  6. package/dist/src/auto-index.d.ts +47 -0
  7. package/dist/src/auto-index.js +122 -0
  8. package/dist/src/commands.js +265 -4
  9. package/dist/src/discover-schema.d.ts +27 -0
  10. package/dist/src/discover-schema.js +255 -0
  11. package/dist/src/index.js +2 -0
  12. package/dist/src/intake.d.ts +4 -0
  13. package/dist/src/intake.js +248 -0
  14. package/dist/src/server.d.ts +31 -0
  15. package/dist/src/server.js +561 -4
  16. package/dist/src/settings-ui.js +11 -11
  17. package/dist/src/tombstone.d.ts +13 -0
  18. package/dist/src/tombstone.js +103 -0
  19. package/dist/src/tools.d.ts +8 -0
  20. package/dist/src/tools.js +58 -4
  21. package/dist/src/types.d.ts +47 -0
  22. package/dist/src/types.js +1 -0
  23. package/dist/src/utils.d.ts +4 -2
  24. package/dist/src/utils.js +9 -4
  25. package/dist/src/watcher.d.ts +7 -1
  26. package/dist/src/watcher.js +170 -11
  27. package/dist/test/agent-queue.test.js +204 -0
  28. package/dist/test/auth.test.js +136 -0
  29. package/dist/test/auto-indexer.test.js +2 -0
  30. package/dist/test/dedup.test.js +62 -0
  31. package/dist/test/discover-schema.test.js +201 -0
  32. package/dist/test/modal-config.test.js +17 -2
  33. package/dist/test/queue-events.test.js +146 -0
  34. package/dist/test/rest-queue.test.js +234 -0
  35. package/dist/test/rest-setup.test.js +213 -0
  36. package/dist/test/rest-wiki.test.js +161 -0
  37. package/dist/test/server-ws.test.js +219 -0
  38. package/dist/test/tombstone.test.js +233 -0
  39. package/package.json +6 -2
  40. package/scripts/e2e-ws.mjs +364 -0
  41. package/scripts/fetch-modal-token.sh +66 -0
  42. package/scripts/modal-e2e-smoke.mjs +171 -0
  43. package/scripts/setup-test-config.sh +66 -0
  44. package/dist/test/index.test.d.ts +0 -1
@@ -5,18 +5,35 @@
5
5
  * GET /vault-mind/status → { running, dispatches, vaults, uptime }
6
6
  * POST /vault-mind/scan → scan a file for @agent markers { file }
7
7
  * POST /vault-mind/dispatch → fire a manual dispatch
8
+ * POST /vault-mind/context → push active editor context from Obsidian { filePath, selection, cursor }
9
+ * GET /vault-mind/context → latest editor context (file, cursor, selection) for pi agents
8
10
  *
9
11
  * Binds 127.0.0.1 only — no network exposure. Port defaults to 11435,
10
12
  * configurable via wiki.httpPort in pi-vault-mind.config.json.
11
13
  *
12
14
  * Used by the obsidian-shellcommands plugin to notify pi of file saves,
13
15
  * replacing fs.watch reliance with explicit push notifications.
16
+ *
17
+ * New Stage-1 routes (auth-gated by `PVM_API_TOKEN`):
18
+ * GET /wiki/status, POST /wiki/init, POST /wiki/setup, POST /server/start,
19
+ * GET /vault-mind/config, GET /agent/queue, GET /agent/jobs/:id,
20
+ * POST /agent/jobs/:id/retry, POST /agent/jobs/:id/cancel,
21
+ * POST /wiki/search, POST /wiki/append, WS /agent/stream.
14
22
  */
15
23
  import * as fs from "node:fs";
16
24
  import * as http from "node:http";
17
- import { processQueue, scanFile } from "./watcher.js";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+ import { WebSocketServer } from "ws";
28
+ import { cancelJobHelper, getJob, listJobs, retryJob, subscribeQueue, } from "./agent-queue.js";
29
+ import { authoriseRequest, resolveAuthToken } from "./auth.js";
30
+ import { GITIGNORE_ENTRIES } from "./commands.js";
31
+ import { searchHybrid, upsertEntry } from "./lance.js";
32
+ import { DEFAULT_CONFIG } from "./types.js";
33
+ import { EXT_ROOT, ensureDir, expandHome, findConfig, loadConfig, shrinkHome } from "./utils.js";
34
+ import { processQueue, scanFile, startWatcher, stopWatcher } from "./watcher.js";
18
35
  export function createServerState(port = 11435) {
19
- return { server: null, port, startTime: 0 };
36
+ return { server: null, wss: null, port, startTime: 0 };
20
37
  }
21
38
  export function startServer(pi, serverState, watcherState) {
22
39
  if (serverState.server) {
@@ -24,6 +41,29 @@ export function startServer(pi, serverState, watcherState) {
24
41
  return;
25
42
  }
26
43
  serverState.startTime = Date.now();
44
+ serverState.pi = pi;
45
+ serverState.watcherState = watcherState;
46
+ // Resolve the vault path from the global config's default vault, falling back to cwd.
47
+ const home = os.homedir();
48
+ const globalCfgPath = home ? path.join(home, ".pi", "agent", "vault-mind.config.json") : null;
49
+ let vaultPath = process.cwd();
50
+ if (globalCfgPath && fs.existsSync(globalCfgPath)) {
51
+ try {
52
+ const globalCfg = JSON.parse(fs.readFileSync(globalCfgPath, "utf-8"));
53
+ const defaultVault = globalCfg?.wiki?.vaults?.default?.path;
54
+ if (defaultVault) {
55
+ const resolved = defaultVault.startsWith("~")
56
+ ? path.join(home, defaultVault.slice(1))
57
+ : defaultVault;
58
+ if (fs.existsSync(resolved))
59
+ vaultPath = resolved;
60
+ }
61
+ }
62
+ catch {
63
+ // config parse error — keep cwd
64
+ }
65
+ }
66
+ serverState.vaultPath = vaultPath;
27
67
  serverState.server = http.createServer((req, res) => {
28
68
  // CORS for localhost-only access
29
69
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:11435");
@@ -57,9 +97,87 @@ export function startServer(pi, serverState, watcherState) {
57
97
  }
58
98
  handleDispatch(req, res, pi, watcherState);
59
99
  break;
100
+ case "/vault-mind/context":
101
+ if (req.method === "POST") {
102
+ withAuth(req, res, () => handleVaultMindContextUpdate(req, res, serverState));
103
+ }
104
+ else if (req.method === "GET") {
105
+ withAuth(req, res, () => handleVaultMindContextGet(res, serverState));
106
+ }
107
+ else {
108
+ res.writeHead(405);
109
+ res.end(JSON.stringify({ error: "Method not allowed" }));
110
+ }
111
+ break;
112
+ // ── Stage 1 auth-gated routes ──────────────────────────
113
+ case "/wiki/status":
114
+ withAuth(req, res, () => handleWikiStatus(res, serverState));
115
+ break;
116
+ case "/wiki/watcher/toggle":
117
+ if (req.method !== "POST") {
118
+ res.writeHead(405);
119
+ res.end(JSON.stringify({ error: "Method not allowed" }));
120
+ return;
121
+ }
122
+ withAuth(req, res, () => handleWatcherToggle(res, serverState));
123
+ break;
124
+ case "/wiki/init":
125
+ if (req.method !== "POST") {
126
+ res.writeHead(405);
127
+ res.end(JSON.stringify({ error: "Method not allowed" }));
128
+ return;
129
+ }
130
+ withAuth(req, res, () => handleWikiInit(req, res));
131
+ break;
132
+ case "/wiki/setup":
133
+ if (req.method !== "POST") {
134
+ res.writeHead(405);
135
+ res.end(JSON.stringify({ error: "Method not allowed" }));
136
+ return;
137
+ }
138
+ withAuth(req, res, () => handleWikiSetup(req, res));
139
+ break;
140
+ case "/server/start":
141
+ if (req.method !== "POST") {
142
+ res.writeHead(405);
143
+ res.end(JSON.stringify({ error: "Method not allowed" }));
144
+ return;
145
+ }
146
+ withAuth(req, res, () => handleServerStart(res, serverState));
147
+ break;
148
+ case "/vault-mind/config":
149
+ withAuth(req, res, () => handleVaultMindConfig(res));
150
+ break;
151
+ // ── Agent queue routes ────────────────────────────────
152
+ case "/agent/queue":
153
+ withAuth(req, res, () => handleAgentQueue(req, res));
154
+ break;
155
+ // ── Wiki tool routes ───────────────────────────────────
156
+ case "/wiki/search":
157
+ if (req.method !== "POST") {
158
+ res.writeHead(405);
159
+ res.end(JSON.stringify({ error: "Method not allowed" }));
160
+ return;
161
+ }
162
+ withAuth(req, res, () => handleWikiSearch(req, res, serverState));
163
+ break;
164
+ case "/wiki/append":
165
+ if (req.method !== "POST") {
166
+ res.writeHead(405);
167
+ res.end(JSON.stringify({ error: "Method not allowed" }));
168
+ return;
169
+ }
170
+ withAuth(req, res, () => handleWikiAppend(req, res, serverState));
171
+ break;
60
172
  default:
61
- res.writeHead(404);
62
- res.end(JSON.stringify({ error: "Not found" }));
173
+ if (url.pathname.startsWith("/agent/jobs/")) {
174
+ withAuth(req, res, () => handleAgentJob(req, res, url.pathname));
175
+ }
176
+ else {
177
+ res.writeHead(404);
178
+ res.end(JSON.stringify({ error: "Not found" }));
179
+ }
180
+ break;
63
181
  }
64
182
  }
65
183
  catch (err) {
@@ -76,11 +194,52 @@ export function startServer(pi, serverState, watcherState) {
76
194
  console.error("[pi-vault-mind] HTTP server error:", err);
77
195
  }
78
196
  });
197
+ // ── WebSocket mount for live queue streaming ────────────────────
198
+ const wss = new WebSocketServer({ noServer: true });
199
+ serverState.wss = wss;
200
+ wss.on("connection", (ws, req) => {
201
+ const auth = authoriseRequest(req);
202
+ if (!auth.ok) {
203
+ ws.close(4401, JSON.stringify({ error: "unauthorized", reason: auth.reason }));
204
+ return;
205
+ }
206
+ const vaultPath = serverState.vaultPath;
207
+ if (vaultPath) {
208
+ const snapshot = listJobs(vaultPath);
209
+ ws.send(JSON.stringify({ type: "queue/snapshot", jobs: snapshot }));
210
+ }
211
+ const off = subscribeQueue((e) => {
212
+ if (ws.readyState === ws.OPEN) {
213
+ ws.send(JSON.stringify(e));
214
+ }
215
+ });
216
+ ws.on("close", off);
217
+ ws.on("error", () => {
218
+ // socket error — unsubscribe is handled by close
219
+ });
220
+ });
221
+ serverState.server.on("upgrade", (req, sock, head) => {
222
+ if (req.url?.startsWith("/agent/stream")) {
223
+ wss.handleUpgrade(req, sock, head, (ws) => {
224
+ wss.emit("connection", ws, req);
225
+ });
226
+ }
227
+ else {
228
+ sock.destroy();
229
+ }
230
+ });
79
231
  serverState.server.listen(serverState.port, "127.0.0.1", () => {
232
+ const addr = serverState.server?.address();
233
+ if (addr && typeof addr === "object")
234
+ serverState.port = addr.port;
80
235
  console.log(`[pi-vault-mind] HTTP server listening on http://127.0.0.1:${serverState.port}`);
81
236
  });
82
237
  }
83
238
  export function stopServer(serverState) {
239
+ if (serverState.wss) {
240
+ serverState.wss.close();
241
+ serverState.wss = null;
242
+ }
84
243
  if (serverState.server) {
85
244
  serverState.server.close();
86
245
  serverState.server = null;
@@ -178,3 +337,401 @@ function handleDispatch(req, res, _pi, _watcherState) {
178
337
  message: "Manual dispatch not yet implemented. Use /wiki watcher status for state.",
179
338
  }));
180
339
  }
340
+ async function handleVaultMindContextUpdate(req, res, serverState) {
341
+ const body = (await readJsonBody(req));
342
+ serverState.latestEditorContext = {
343
+ filePath: typeof body.filePath === "string" ? body.filePath : null,
344
+ selection: typeof body.selection === "string" ? body.selection : null,
345
+ cursor: typeof body.cursor === "number" ? body.cursor : null,
346
+ };
347
+ res.writeHead(200);
348
+ res.end(JSON.stringify({ ok: true, context: serverState.latestEditorContext }));
349
+ }
350
+ function handleVaultMindContextGet(res, serverState) {
351
+ res.writeHead(200);
352
+ res.end(JSON.stringify(serverState.latestEditorContext ?? {
353
+ filePath: null,
354
+ selection: null,
355
+ cursor: null,
356
+ }));
357
+ }
358
+ // ── Stage 1 setup routes (auth-gated) ───────────────────────────────────────
359
+ function handleWikiStatus(res, serverState) {
360
+ const ws = serverState.watcherState;
361
+ const cfg = loadConfig(process.cwd());
362
+ const configured = !!findConfig(process.cwd()).project;
363
+ const provider = cfg.wiki.embedding.provider;
364
+ const model = provider === "ollama"
365
+ ? cfg.wiki.embedding.ollamaModel || "embeddinggemma"
366
+ : provider === "modal"
367
+ ? cfg.wiki.embedding.modal?.model || "embeddinggemma"
368
+ : "all-MiniLM-L6-v2";
369
+ const dim = provider === "modal"
370
+ ? (cfg.wiki.embedding.modal?.dim ?? null)
371
+ : provider === "ollama"
372
+ ? 768
373
+ : 384;
374
+ res.writeHead(200);
375
+ res.end(JSON.stringify({
376
+ ok: true,
377
+ configured,
378
+ watcher: ws?.running ?? false,
379
+ provider,
380
+ model,
381
+ dim,
382
+ queue: {
383
+ pending: ws?.pendingQueue?.length ?? 0,
384
+ running: ws?.activeDispatches ?? 0,
385
+ },
386
+ server: {
387
+ running: !!serverState.server,
388
+ port: serverState.port,
389
+ uptimeMs: Date.now() - serverState.startTime,
390
+ },
391
+ version: "0.8.0",
392
+ }));
393
+ }
394
+ function handleWatcherToggle(res, serverState) {
395
+ const ws = serverState.watcherState;
396
+ if (!ws) {
397
+ res.writeHead(500);
398
+ res.end(JSON.stringify({ error: "Watcher state not available." }));
399
+ return;
400
+ }
401
+ if (ws.running) {
402
+ stopWatcher(ws);
403
+ res.writeHead(200);
404
+ res.end(JSON.stringify({ ok: true, watcher: false }));
405
+ return;
406
+ }
407
+ const pi = serverState.pi;
408
+ if (!pi) {
409
+ res.writeHead(500);
410
+ res.end(JSON.stringify({ error: "Extension API not available." }));
411
+ return;
412
+ }
413
+ const cfg = loadConfig(serverState.vaultPath || process.cwd());
414
+ const vaults = cfg.wiki.vaults;
415
+ if (!vaults || Object.keys(vaults).length === 0) {
416
+ res.writeHead(400);
417
+ res.end(JSON.stringify({ error: "No vaults configured. Add vaults to wiki.vaults first." }));
418
+ return;
419
+ }
420
+ startWatcher(pi, vaults, ws);
421
+ res.writeHead(200);
422
+ res.end(JSON.stringify({ ok: true, watcher: true }));
423
+ }
424
+ function handleWikiInit(req, res) {
425
+ readBody(req)
426
+ .then((raw) => {
427
+ let parsed;
428
+ try {
429
+ parsed = JSON.parse(raw || "{}");
430
+ }
431
+ catch {
432
+ res.writeHead(400);
433
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
434
+ return;
435
+ }
436
+ const vaultPath = parsed.vaultPath || process.cwd();
437
+ if (!fs.existsSync(vaultPath)) {
438
+ res.writeHead(400);
439
+ res.end(JSON.stringify({ error: `Vault path does not exist: ${vaultPath}` }));
440
+ return;
441
+ }
442
+ // Scaffold project config
443
+ const cfgPath = path.join(vaultPath, "pi-vault-mind.config.json");
444
+ if (!fs.existsSync(cfgPath)) {
445
+ const tmpl = path.join(EXT_ROOT, "templates", "pi-vault-mind.config.json");
446
+ if (fs.existsSync(tmpl)) {
447
+ fs.copyFileSync(tmpl, cfgPath);
448
+ }
449
+ else {
450
+ fs.writeFileSync(cfgPath, `${JSON.stringify({ version: 2, collections: DEFAULT_CONFIG.collections, injectors: DEFAULT_CONFIG.injectors, wiki: DEFAULT_CONFIG.wiki }, null, 2)}\n`, "utf-8");
451
+ }
452
+ }
453
+ // Scaffold collections dir
454
+ const collDir = path.join(vaultPath, "collections");
455
+ if (!fs.existsSync(collDir))
456
+ fs.mkdirSync(collDir, { recursive: true });
457
+ for (const [name, def] of Object.entries(DEFAULT_CONFIG.collections)) {
458
+ const p = path.join(vaultPath, def.path);
459
+ if (!fs.existsSync(p)) {
460
+ fs.mkdirSync(path.dirname(p), { recursive: true });
461
+ fs.writeFileSync(p, "", "utf-8");
462
+ }
463
+ }
464
+ // Scaffold .gitignore
465
+ const giPath = path.join(vaultPath, ".gitignore");
466
+ const existing = fs.existsSync(giPath) ? fs.readFileSync(giPath, "utf-8").split(/\r?\n/) : [];
467
+ let changed = false;
468
+ for (const entry of GITIGNORE_ENTRIES) {
469
+ if (!existing.some((l) => l.trim() === entry)) {
470
+ existing.push(entry);
471
+ changed = true;
472
+ }
473
+ }
474
+ if (changed)
475
+ fs.writeFileSync(giPath, `${existing.join("\n")}\n`, "utf-8");
476
+ res.writeHead(200);
477
+ res.end(JSON.stringify({ ok: true, path: vaultPath }));
478
+ })
479
+ .catch((err) => {
480
+ if (!res.headersSent) {
481
+ res.writeHead(500);
482
+ res.end(JSON.stringify({ error: String(err) }));
483
+ }
484
+ });
485
+ }
486
+ function handleWikiSetup(req, res) {
487
+ readBody(req)
488
+ .then((raw) => {
489
+ let parsed;
490
+ try {
491
+ parsed = JSON.parse(raw || "{}");
492
+ }
493
+ catch {
494
+ res.writeHead(400);
495
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
496
+ return;
497
+ }
498
+ const globalPath = path.join(os.homedir(), ".pi", "agent", "vault-mind.config.json");
499
+ const existingGlobal = fs.existsSync(globalPath);
500
+ const config = existingGlobal
501
+ ? JSON.parse(fs.readFileSync(globalPath, "utf-8"))
502
+ : { wiki: { embedding: {}, vaults: {} } };
503
+ config.wiki = config.wiki || {};
504
+ config.wiki.embedding = config.wiki.embedding || {};
505
+ config.wiki.vaults = config.wiki.vaults || {};
506
+ if (parsed.vault) {
507
+ config.wiki.vaults.default = { path: shrinkHome(parsed.vault), autoSync: true };
508
+ }
509
+ if (parsed.provider && ["ollama", "transformers", "modal"].includes(parsed.provider)) {
510
+ config.wiki.embedding.provider = parsed.provider;
511
+ }
512
+ if (parsed.model) {
513
+ config.wiki.embedding.ollamaModel = parsed.model;
514
+ }
515
+ if (parsed.workspace) {
516
+ config.wiki.embedding.modal = config.wiki.embedding.modal || {};
517
+ config.wiki.embedding.modal.workspace = parsed.workspace;
518
+ }
519
+ const dir = path.dirname(globalPath);
520
+ if (!fs.existsSync(dir))
521
+ fs.mkdirSync(dir, { recursive: true });
522
+ fs.writeFileSync(globalPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
523
+ res.writeHead(200);
524
+ res.end(JSON.stringify({ ok: true }));
525
+ })
526
+ .catch((err) => {
527
+ if (!res.headersSent) {
528
+ res.writeHead(500);
529
+ res.end(JSON.stringify({ error: String(err) }));
530
+ }
531
+ });
532
+ }
533
+ function handleServerStart(res, serverState) {
534
+ if (serverState.server) {
535
+ res.writeHead(200);
536
+ res.end(JSON.stringify({ ok: true, port: serverState.port, alreadyRunning: true }));
537
+ return;
538
+ }
539
+ const pi = serverState.pi;
540
+ const ws = serverState.watcherState;
541
+ if (!pi || !ws) {
542
+ res.writeHead(500);
543
+ res.end(JSON.stringify({
544
+ error: "Server cannot start: pi or watcherState not available. The extension must be loaded first.",
545
+ }));
546
+ return;
547
+ }
548
+ startServer(pi, serverState, ws);
549
+ res.writeHead(200);
550
+ res.end(JSON.stringify({ ok: true, port: serverState.port }));
551
+ }
552
+ function handleVaultMindConfig(res) {
553
+ const globalPath2 = path.join(os.homedir(), ".pi", "agent", "vault-mind.config.json");
554
+ const globalCfg = fs.existsSync(globalPath2)
555
+ ? JSON.parse(fs.readFileSync(globalPath2, "utf-8"))
556
+ : null;
557
+ const projectCfg = findConfig(process.cwd()).project;
558
+ const project = projectCfg ? JSON.parse(fs.readFileSync(projectCfg, "utf-8")) : null;
559
+ const hasToken = !!resolveAuthToken();
560
+ const modal = globalCfg?.wiki?.embedding?.modal ?? project?.wiki?.embedding?.modal ?? null;
561
+ res.writeHead(200);
562
+ res.end(JSON.stringify({
563
+ global: globalCfg,
564
+ project,
565
+ hasToken,
566
+ modal: modal
567
+ ? {
568
+ workspace: modal.workspace ?? null,
569
+ baseUrl: modal.baseUrl ?? null,
570
+ model: modal.model ?? "embeddinggemma",
571
+ dim: modal.dim ?? null,
572
+ fallback: modal.fallback ?? null,
573
+ sync: modal.sync ?? null,
574
+ }
575
+ : null,
576
+ version: "0.8.0",
577
+ }));
578
+ }
579
+ /**
580
+ * Wrap a request handler with bearer-token auth. Use this for every
581
+ * new Stage-1 route so auth is consistent and testable in one place.
582
+ * The handler is only invoked if `authoriseRequest` passes.
583
+ */
584
+ export const withAuth = (req, res, handler) => {
585
+ const result = authoriseRequest(req);
586
+ if (!result.ok) {
587
+ res.writeHead(401, { "WWW-Authenticate": "Bearer" });
588
+ res.end(JSON.stringify({
589
+ error: "unauthorized",
590
+ reason: result.reason,
591
+ hint: resolveAuthToken()
592
+ ? "Send Authorization: Bearer <PVM_API_TOKEN>"
593
+ : "Set PVM_API_TOKEN in env or ~/.pi/agent/vault-mind.env, or in pi-vault-mind.config.json (wiki.embedding.modal.apiToken), then restart pi.",
594
+ }));
595
+ return;
596
+ }
597
+ handler();
598
+ };
599
+ /** Read the full request body as UTF-8. Already used by handleScan; exposed for new routes. */
600
+ export const readJsonBody = (req) => readBody(req).then((text) => (text ? JSON.parse(text) : {}));
601
+ // ── Agent queue handlers ────────────────────────────────────────────────────
602
+ const defaultVaultPath = () => {
603
+ const cfg = loadConfig(process.cwd());
604
+ const p = cfg.wiki.vaults?.default?.path;
605
+ return p ? expandHome(p) : undefined;
606
+ };
607
+ function handleAgentQueue(req, res) {
608
+ const url = new URL(req.url || "/", "http://127.0.0.1");
609
+ const statusParam = url.searchParams.get("status");
610
+ const validStatuses = ["pending", "running", "done", "failed", "cancelled"];
611
+ const status = statusParam && validStatuses.includes(statusParam)
612
+ ? statusParam
613
+ : undefined;
614
+ const vaultPath = defaultVaultPath();
615
+ if (!vaultPath) {
616
+ res.writeHead(500);
617
+ res.end(JSON.stringify({ error: "No default vault configured." }));
618
+ return;
619
+ }
620
+ const jobs = listJobs(vaultPath, status);
621
+ res.writeHead(200);
622
+ res.end(JSON.stringify(jobs));
623
+ }
624
+ function handleAgentJob(req, res, pathname) {
625
+ // pathname is /agent/jobs/<id> or /agent/jobs/<id>/retry or /agent/jobs/<id>/cancel
626
+ const parts = pathname.split("/").filter(Boolean);
627
+ // parts = ["agent", "jobs", "<id>"] or ["agent", "jobs", "<id>", "retry"]
628
+ const id = parts[2];
629
+ const action = parts[3];
630
+ if (!id) {
631
+ res.writeHead(400);
632
+ res.end(JSON.stringify({ error: "Missing job id in path." }));
633
+ return;
634
+ }
635
+ const vaultPath = defaultVaultPath();
636
+ if (!vaultPath) {
637
+ res.writeHead(500);
638
+ res.end(JSON.stringify({ error: "No default vault configured." }));
639
+ return;
640
+ }
641
+ if (!action) {
642
+ // GET /agent/jobs/:id
643
+ const job = getJob(vaultPath, id);
644
+ if (!job) {
645
+ res.writeHead(404);
646
+ res.end(JSON.stringify({ error: `Job ${id} not found.` }));
647
+ return;
648
+ }
649
+ res.writeHead(200);
650
+ res.end(JSON.stringify(job));
651
+ return;
652
+ }
653
+ if (req.method !== "POST") {
654
+ res.writeHead(405);
655
+ res.end(JSON.stringify({ error: "Method not allowed" }));
656
+ return;
657
+ }
658
+ if (action === "retry") {
659
+ const result = retryJob(vaultPath, id);
660
+ if ("error" in result) {
661
+ res.writeHead(409);
662
+ res.end(JSON.stringify({ error: result.error }));
663
+ return;
664
+ }
665
+ res.writeHead(200);
666
+ res.end(JSON.stringify(result.job));
667
+ return;
668
+ }
669
+ if (action === "cancel") {
670
+ const result = cancelJobHelper(vaultPath, id);
671
+ if ("error" in result) {
672
+ res.writeHead(409);
673
+ res.end(JSON.stringify({ error: result.error }));
674
+ return;
675
+ }
676
+ res.writeHead(200);
677
+ res.end(JSON.stringify(result.job));
678
+ return;
679
+ }
680
+ res.writeHead(404);
681
+ res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
682
+ }
683
+ // ── Wiki tool routes ─────────────────────────────────────────────────────────
684
+ async function handleWikiSearch(req, res, serverState) {
685
+ const body = (await readJsonBody(req));
686
+ if (!body.query) {
687
+ res.writeHead(400);
688
+ res.end(JSON.stringify({ error: "Missing query." }));
689
+ return;
690
+ }
691
+ const vaultPath = serverState.vaultPath || process.cwd();
692
+ const cfg = loadConfig(vaultPath);
693
+ const collection = body.collection || "main";
694
+ const limit = body.limit ?? 5;
695
+ try {
696
+ const results = await searchHybrid(cfg.wiki.dataDir, collection, body.query, limit, cfg.wiki);
697
+ res.writeHead(200);
698
+ res.end(JSON.stringify({ hits: results, collection, query: body.query }));
699
+ }
700
+ catch (err) {
701
+ const message = err instanceof Error ? err.message : String(err);
702
+ res.writeHead(500);
703
+ res.end(JSON.stringify({ error: message }));
704
+ }
705
+ }
706
+ async function handleWikiAppend(req, res, serverState) {
707
+ const body = (await readJsonBody(req));
708
+ if (!body.collection || !body.entry) {
709
+ res.writeHead(400);
710
+ res.end(JSON.stringify({ error: "Missing collection or entry." }));
711
+ return;
712
+ }
713
+ const vaultPath = serverState.vaultPath || process.cwd();
714
+ const cfg = loadConfig(vaultPath);
715
+ const def = cfg.collections[body.collection];
716
+ if (!def) {
717
+ res.writeHead(400);
718
+ res.end(JSON.stringify({
719
+ error: `Unknown collection "${body.collection}".`,
720
+ available: Object.keys(cfg.collections),
721
+ }));
722
+ return;
723
+ }
724
+ const line = `${JSON.stringify(body.entry)}\n`;
725
+ ensureDir(def.path);
726
+ fs.appendFileSync(def.path, line, "utf-8");
727
+ try {
728
+ await upsertEntry(cfg.wiki.dataDir, body.collection, body.entry, cfg.wiki);
729
+ res.writeHead(200);
730
+ res.end(JSON.stringify({ ok: true, collection: body.collection }));
731
+ }
732
+ catch (err) {
733
+ const message = err instanceof Error ? err.message : String(err);
734
+ res.writeHead(500);
735
+ res.end(JSON.stringify({ error: message }));
736
+ }
737
+ }
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { testOllamaConnection } from "./lance.js";
4
4
  import { MODAL_TOKEN_ENV, createModalClient, modalUrl, resolveModalToken } from "./modal-config.js";
5
- import { GLOBAL_CONFIG_PATH, collectionNames, findConfig, loadConfig, shrinkHome, } from "./utils.js";
5
+ import { collectionNames, findConfig, getGlobalConfigPath, loadConfig, shrinkHome, } from "./utils.js";
6
6
  export const createCollectionWizard = async (ctx) => {
7
7
  const { project: cfgPath } = findConfig(ctx.cwd);
8
8
  if (!cfgPath) {
@@ -84,11 +84,11 @@ export const createInjectorWizard = async (ctx) => {
84
84
  ctx.ui.notify(`✅ Injector "${name}" created and configured!`, "info");
85
85
  };
86
86
  export const setupWizard = async (ctx, cliArgs) => {
87
- const existingGlobal = fs.existsSync(GLOBAL_CONFIG_PATH);
87
+ const existingGlobal = fs.existsSync(getGlobalConfigPath());
88
88
  // ── Non-interactive / CLI mode ──────────────────────────────────────────
89
89
  if (cliArgs && (cliArgs.vault || cliArgs.provider || cliArgs.workspace)) {
90
90
  const config = existingGlobal
91
- ? JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, "utf-8"))
91
+ ? JSON.parse(fs.readFileSync(getGlobalConfigPath(), "utf-8"))
92
92
  : { wiki: { embedding: {}, vaults: {} } };
93
93
  config.wiki = config.wiki || {};
94
94
  config.wiki.embedding = config.wiki.embedding || {};
@@ -106,11 +106,11 @@ export const setupWizard = async (ctx, cliArgs) => {
106
106
  config.wiki.embedding.modal = config.wiki.embedding.modal || {};
107
107
  config.wiki.embedding.modal.workspace = cliArgs.workspace;
108
108
  }
109
- const dir = path.dirname(GLOBAL_CONFIG_PATH);
109
+ const dir = path.dirname(getGlobalConfigPath());
110
110
  if (!fs.existsSync(dir))
111
111
  fs.mkdirSync(dir, { recursive: true });
112
- fs.writeFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
113
- const lines = ["✅ Global config written:", ` ${GLOBAL_CONFIG_PATH}`, ""];
112
+ fs.writeFileSync(getGlobalConfigPath(), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
113
+ const lines = ["✅ Global config written:", ` ${getGlobalConfigPath()}`, ""];
114
114
  if (cliArgs.vault)
115
115
  lines.push(` Vault: ${cliArgs.vault}`);
116
116
  if (cliArgs.provider)
@@ -137,7 +137,7 @@ export const setupWizard = async (ctx, cliArgs) => {
137
137
  if (!choice || choice === "Exit")
138
138
  return;
139
139
  if (choice === "View current config") {
140
- const cfg = JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
140
+ const cfg = JSON.parse(fs.readFileSync(getGlobalConfigPath(), "utf-8"));
141
141
  ctx.ui.notify(JSON.stringify(cfg, null, 2), "info");
142
142
  return;
143
143
  }
@@ -189,7 +189,7 @@ export const setupWizard = async (ctx, cliArgs) => {
189
189
  }
190
190
  // ── Write config ────────────────────────────────────────────────────────
191
191
  const config = existingGlobal
192
- ? JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, "utf-8"))
192
+ ? JSON.parse(fs.readFileSync(getGlobalConfigPath(), "utf-8"))
193
193
  : { wiki: { graph: { enabled: true, canvasSync: true }, ftsEnabled: true } };
194
194
  config.wiki = config.wiki || {};
195
195
  config.wiki.embedding = config.wiki.embedding || {};
@@ -202,13 +202,13 @@ export const setupWizard = async (ctx, cliArgs) => {
202
202
  config.wiki.vaults.default = { path: shrinkHome(vaultPath), autoSync: true };
203
203
  config.wiki.graph = config.wiki.graph || { enabled: true, canvasSync: true };
204
204
  config.wiki.ftsEnabled = config.wiki.ftsEnabled !== false;
205
- const dir = path.dirname(GLOBAL_CONFIG_PATH);
205
+ const dir = path.dirname(getGlobalConfigPath());
206
206
  if (!fs.existsSync(dir))
207
207
  fs.mkdirSync(dir, { recursive: true });
208
- fs.writeFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
208
+ fs.writeFileSync(getGlobalConfigPath(), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
209
209
  ctx.ui.notify([
210
210
  "✅ Global config written!",
211
- ` ${GLOBAL_CONFIG_PATH}`,
211
+ ` ${getGlobalConfigPath()}`,
212
212
  "",
213
213
  ` Vault: ${vaultPath}`,
214
214
  ` Embedding: ${config.wiki.embedding.provider}`,