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
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Background auto-indexing for collection directories.
3
+ *
4
+ * Watches the directories that contain configured collection JSONL files.
5
+ * When a collection file is created, modified, or deleted, a debounced
6
+ * reindex is triggered for that collection.
7
+ *
8
+ * Controlled by `wiki.autoIndex` (default false). Started alongside the HTTP
9
+ * server and stopped on session shutdown.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { upsertEntry } from "./lance.js";
14
+ export const DEFAULT_DEBOUNCE_MS = 2000;
15
+ export function createAutoIndexState() {
16
+ return { running: false, watchers: new Map(), debounceTimers: new Map() };
17
+ }
18
+ /**
19
+ * Start watching configured collection directories.
20
+ * Returns a stop function that closes watchers and clears timers.
21
+ */
22
+ export function startAutoIndex(cfg, options = {}) {
23
+ const state = options.state ?? createAutoIndexState();
24
+ if (state.running) {
25
+ console.warn("[pi-vault-mind] Auto-index already running.");
26
+ return { stop: () => stopAutoIndex(state), state };
27
+ }
28
+ state.running = true;
29
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
30
+ const reindexFn = options.reindex ?? reindexCollection;
31
+ // Group collections by parent directory so a single watcher covers multiple
32
+ // collection files in the same directory.
33
+ const dirMap = new Map();
34
+ for (const [collection, def] of Object.entries(cfg.collections)) {
35
+ const dir = path.dirname(def.path);
36
+ const fileName = path.basename(def.path);
37
+ const entries = dirMap.get(dir) ?? [];
38
+ entries.push({ collection, fileName, def });
39
+ dirMap.set(dir, entries);
40
+ }
41
+ for (const [dir, entries] of dirMap) {
42
+ if (!fs.existsSync(dir)) {
43
+ console.warn(`[pi-vault-mind] Auto-index skipping missing directory: ${dir}`);
44
+ continue;
45
+ }
46
+ const watcher = fs.watch(dir, { recursive: false }, (_eventType, filename) => {
47
+ if (!filename || !state.running)
48
+ return;
49
+ const matched = entries.filter((e) => e.fileName === filename);
50
+ if (matched.length === 0)
51
+ return;
52
+ for (const m of matched) {
53
+ scheduleReindex(state, m.collection, m.def, cfg.wiki, reindexFn, debounceMs);
54
+ }
55
+ });
56
+ watcher.on("error", (err) => {
57
+ console.error(`[pi-vault-mind] Auto-index watcher error for ${dir}:`, err);
58
+ });
59
+ state.watchers.set(dir, watcher);
60
+ }
61
+ if (state.watchers.size > 0) {
62
+ console.log(`[pi-vault-mind] Auto-index started: watching ${state.watchers.size} collection dir(s).`);
63
+ }
64
+ return { stop: () => stopAutoIndex(state), state };
65
+ }
66
+ /**
67
+ * Stop all watchers and clear pending debounces.
68
+ */
69
+ export function stopAutoIndex(state) {
70
+ state.running = false;
71
+ for (const [, timer] of state.debounceTimers) {
72
+ clearTimeout(timer);
73
+ }
74
+ state.debounceTimers.clear();
75
+ for (const [dir, watcher] of state.watchers) {
76
+ watcher.close();
77
+ console.log(`[pi-vault-mind] Auto-index stopped watching ${dir}.`);
78
+ }
79
+ state.watchers.clear();
80
+ }
81
+ function scheduleReindex(state, collection, def, wikiCfg, reindexFn, debounceMs) {
82
+ const existing = state.debounceTimers.get(collection);
83
+ if (existing)
84
+ clearTimeout(existing);
85
+ const timer = setTimeout(() => {
86
+ state.debounceTimers.delete(collection);
87
+ reindexFn(wikiCfg.dataDir, collection, def, wikiCfg).catch((err) => {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ console.warn(`[pi-vault-mind] Auto-index reindex failed for "${collection}": ${message}`);
90
+ });
91
+ }, debounceMs);
92
+ state.debounceTimers.set(collection, timer);
93
+ }
94
+ /**
95
+ * Re-index a single collection by reading its JSONL source file and upserting
96
+ * each valid entry into LanceDB. Malformed lines are skipped.
97
+ */
98
+ export const reindexCollection = async (dataDir, collection, def, cfg) => {
99
+ if (!fs.existsSync(def.path)) {
100
+ console.log(`[pi-vault-mind] Auto-index skipped "${collection}" (file missing): ${def.path}`);
101
+ return;
102
+ }
103
+ const content = fs.readFileSync(def.path, "utf-8");
104
+ const lines = content.split("\n").filter(Boolean);
105
+ let indexed = 0;
106
+ let skipped = 0;
107
+ for (const line of lines) {
108
+ try {
109
+ const entry = JSON.parse(line);
110
+ if (typeof entry !== "object" || entry === null) {
111
+ skipped++;
112
+ continue;
113
+ }
114
+ await upsertEntry(dataDir, collection, entry, cfg);
115
+ indexed++;
116
+ }
117
+ catch {
118
+ skipped++;
119
+ }
120
+ }
121
+ console.log(`[pi-vault-mind] Auto-index reindexed "${collection}": ${indexed}/${lines.length} entries${skipped > 0 ? `, ${skipped} skipped` : ""}`);
122
+ };
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { Container, SelectList, Text } from "@earendil-works/pi-tui";
@@ -6,11 +7,16 @@ import { getActiveCollection, setActiveCollection } from "./state.js";
6
7
  import { DEFAULT_CONFIG } from "./types.js";
7
8
  import { CONFIG_FILES, EXT_ROOT, collectionNames, ensureDir, findConfig, getPiContextConfig, hasPiContextTools, loadConfig, } from "./utils.js";
8
9
  import { updateActiveCollectionWidget } from "./widget.js";
10
+ import { cancelJobHelper, listJobs, retryJob, } from "./agent-queue.js";
11
+ import { handleDiscoverSchema } from "./discover-schema.js";
12
+ import { runAskIntake } from "./intake.js";
9
13
  import { connect, pullOllamaModel, testOllamaConnection } from "./lance.js";
10
14
  import { MODAL_TOKEN_ENV, createModalClient, isModalConfigured, resolveBaseUrl, resolveModalToken, resolveWorkspace, } from "./modal-config.js";
11
15
  import { createServerState } from "./server.js";
12
16
  import { createCollectionWizard, createInjectorWizard, openSettingsDashboard, setupWizard, } from "./settings-ui.js";
13
17
  import { reindexRemote, syncAll, syncCollection } from "./sync.js";
18
+ import { tombstoneEntryById } from "./tombstone.js";
19
+ import { expandHome } from "./utils.js";
14
20
  import { createWatcherState, getWatcherStatus, startWatcher, stopWatcher } from "./watcher.js";
15
21
  // ── Shared helpers ───────────────────────────────────────────────────────────
16
22
  const WIKI_USAGE = [
@@ -19,11 +25,13 @@ const WIKI_USAGE = [
19
25
  " /wiki init Scaffold config + collections",
20
26
  " /wiki validate Check LanceDB, config, and collection health",
21
27
  " /wiki approve [collection] Batch-review pending entries",
28
+ " /wiki tombstone <collection> <entry-id> Redact an entry in place",
22
29
  " /wiki settings Open interactive settings dashboard",
23
30
  " /wiki audit Audit config for missing defaults",
24
31
  " /wiki reindex Rebuild FTS + vector indexes (replaces old /qmd-index)",
25
32
  " /wiki collection select Select active collection",
26
33
  " /wiki collection create Create a new collection (wizard)",
34
+ " /wiki discover-schema Infer schema from a JSONL file",
27
35
  " /wiki injector create Create a new injector (wizard)",
28
36
  " /wiki context enable|disable Enable/disable pi-context integration",
29
37
  " /wiki context status Show pi-context integration status",
@@ -40,6 +48,9 @@ const WIKI_USAGE = [
40
48
  " /wiki watcher start Start passive file watcher",
41
49
  " /wiki watcher stop Stop passive file watcher",
42
50
  " /wiki watcher status Show watcher status",
51
+ " /wiki watcher queue [status] Show agent dispatch queue (pending|running|done|failed|cancelled|all)",
52
+ " /wiki watcher retry <id> Retry a failed or cancelled agent job",
53
+ " /wiki watcher cancel <id> Cancel a pending agent job",
43
54
  " /wiki server status Show HTTP server status + port",
44
55
  " /wiki setup Interactive global config wizard",
45
56
  " /wiki setup --vault <path> CLI: set vault path",
@@ -383,6 +394,24 @@ const handleApprove = async (args, ctx) => {
383
394
  fs.writeFileSync(pending.path, rejected.join("\n") + (rejected.length > 0 ? "\n" : ""));
384
395
  ctx.ui.notify(`Approved ${approved} → "${targetName}". Rejected ${rejected.length}.`, "info");
385
396
  };
397
+ // ── /wiki tombstone ─────────────────────────────────────────────────────────
398
+ const handleTombstone = async (args, ctx) => {
399
+ const parts = args.trim().split(/\s+/).filter(Boolean);
400
+ if (parts.length < 2) {
401
+ ctx.ui.notify("Usage: /wiki tombstone <collection> <entry-id>", "error");
402
+ return;
403
+ }
404
+ const collection = parts[0];
405
+ const entryId = parts.slice(1).join(" ");
406
+ const cfg = loadConfig(ctx.cwd);
407
+ const def = cfg.collections[collection];
408
+ if (!def) {
409
+ ctx.ui.notify(`Unknown collection "${collection}".`, "error");
410
+ return;
411
+ }
412
+ const result = tombstoneEntryById(def.path, entryId);
413
+ ctx.ui.notify(result.message, result.success ? "info" : "error");
414
+ };
386
415
  // ── /wiki reindex ────────────────────────────────────────────────────────────
387
416
  const handleReindex = async (args, ctx, pi) => {
388
417
  const cfg = loadConfig(ctx.cwd);
@@ -825,7 +854,7 @@ const handleModalConfig = async (args, ctx) => {
825
854
  return;
826
855
  }
827
856
  case "token": {
828
- ctx.ui.notify(`Token resolution: env ${MODAL_TOKEN_ENV} is preferred (never committed).\nSet it in your shell: export ${MODAL_TOKEN_ENV}=...\nConfig wiki.embedding.modal.apiToken is a fallback only.`, "info");
857
+ await handleModalToken(ctx, parts[1]);
829
858
  return;
830
859
  }
831
860
  default:
@@ -1178,6 +1207,11 @@ const handleServer = (ctx) => {
1178
1207
  const handleWatcher = async (args, ctx, pi) => {
1179
1208
  const parts = args.trim().split(/\s+/g);
1180
1209
  const subcommand = parts[0]?.toLowerCase() || "status";
1210
+ const defaultVaultPath = () => {
1211
+ const cfg = loadConfig(ctx.cwd);
1212
+ const p = cfg.wiki.vaults?.default?.path;
1213
+ return p ? expandHome(p) : undefined;
1214
+ };
1181
1215
  switch (subcommand) {
1182
1216
  case "start": {
1183
1217
  const cfg = loadConfig(ctx.cwd);
@@ -1202,6 +1236,92 @@ const handleWatcher = async (args, ctx, pi) => {
1202
1236
  ctx.ui.notify("Watcher stopped.", "info");
1203
1237
  return;
1204
1238
  }
1239
+ case "queue": {
1240
+ const vaultPath = defaultVaultPath();
1241
+ if (!vaultPath) {
1242
+ ctx.ui.notify("No default vault configured. Set one with /wiki setup --vault <path>.", "error");
1243
+ return;
1244
+ }
1245
+ const statusArg = parts[1]?.toLowerCase();
1246
+ const statuses = ["pending", "running", "done", "failed", "cancelled"];
1247
+ const status = statusArg === "all"
1248
+ ? undefined
1249
+ : statuses.includes(statusArg || "")
1250
+ ? statusArg
1251
+ : undefined;
1252
+ const jobs = listJobs(vaultPath, status);
1253
+ if (jobs.length === 0) {
1254
+ ctx.ui.notify(`No ${status || "agent"} jobs in queue for this vault.`, "info");
1255
+ return;
1256
+ }
1257
+ const lines = [
1258
+ `**Agent Queue (${status || "all"}) — ${jobs.length} job(s):**`,
1259
+ "",
1260
+ "| id | role | status | attempts | file |",
1261
+ "|---|---|---|---|---|",
1262
+ ...jobs.map((j) => {
1263
+ const rel = j.filePath.split("/").slice(-3).join("/");
1264
+ return `| ${j.id} | ${j.role} | ${j.status} | ${j.attempts} | ${rel} |`;
1265
+ }),
1266
+ ];
1267
+ ctx.ui.notify(lines.join("\n"), "info");
1268
+ return;
1269
+ }
1270
+ case "retry": {
1271
+ const id = parts[1];
1272
+ if (!id) {
1273
+ ctx.ui.notify("Usage: /wiki watcher retry <job-id>", "error");
1274
+ return;
1275
+ }
1276
+ const vaultPath = defaultVaultPath();
1277
+ if (!vaultPath) {
1278
+ ctx.ui.notify("No default vault configured.", "error");
1279
+ return;
1280
+ }
1281
+ const result = retryJob(vaultPath, id);
1282
+ if ("error" in result) {
1283
+ ctx.ui.notify(result.error, "warning");
1284
+ return;
1285
+ }
1286
+ const job = result.job;
1287
+ // Re-create the dispatch by re-sending the task as a user message
1288
+ const taskDescription = [
1289
+ `Retry of dispatch ${id} for @agent-${job.role} in ${job.filePath}.`,
1290
+ "",
1291
+ "Please dispatch a subagent to handle it:",
1292
+ "",
1293
+ "```",
1294
+ "subagent({",
1295
+ ` agent: "${job.agentName}",`,
1296
+ ` task: ${JSON.stringify(job.instruction)},`,
1297
+ ` context: "fork",`,
1298
+ " async: true,",
1299
+ "})",
1300
+ "```",
1301
+ ].join("\n");
1302
+ pi.sendUserMessage(taskDescription, { deliverAs: "followUp" });
1303
+ ctx.ui.notify(`✅ Retried job ${id} → ${job.agentName}`, "info");
1304
+ return;
1305
+ }
1306
+ case "cancel": {
1307
+ const id = parts[1];
1308
+ if (!id) {
1309
+ ctx.ui.notify("Usage: /wiki watcher cancel <job-id>", "error");
1310
+ return;
1311
+ }
1312
+ const vaultPath = defaultVaultPath();
1313
+ if (!vaultPath) {
1314
+ ctx.ui.notify("No default vault configured.", "error");
1315
+ return;
1316
+ }
1317
+ const result = cancelJobHelper(vaultPath, id);
1318
+ if ("error" in result) {
1319
+ ctx.ui.notify(result.error, "warning");
1320
+ return;
1321
+ }
1322
+ ctx.ui.notify(`✅ Cancelled job ${id}`, "info");
1323
+ return;
1324
+ }
1205
1325
  default: {
1206
1326
  ctx.ui.notify(getWatcherStatus(watcherState), "info");
1207
1327
  return;
@@ -1230,6 +1350,88 @@ const handleSetup = async (args, ctx) => {
1230
1350
  const hasCliArgs = cliArgs.vault || cliArgs.provider || cliArgs.model || cliArgs.workspace;
1231
1351
  await setupWizard(ctx, hasCliArgs ? cliArgs : undefined);
1232
1352
  };
1353
+ // ── /wiki intake ─────────────────────────────────────────────────────────────
1354
+ const handleIntake = async (args, ctx) => {
1355
+ const filePath = args.trim();
1356
+ if (!filePath) {
1357
+ ctx.ui.notify("Usage: /wiki intake <path-to-json-spec>", "error");
1358
+ return;
1359
+ }
1360
+ if (!fs.existsSync(filePath)) {
1361
+ ctx.ui.notify(`Intake spec not found: ${filePath}`, "error");
1362
+ return;
1363
+ }
1364
+ let parsed;
1365
+ try {
1366
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1367
+ }
1368
+ catch (err) {
1369
+ ctx.ui.notify(`Failed to read intake spec: ${err.message}`, "error");
1370
+ return;
1371
+ }
1372
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.questions)) {
1373
+ ctx.ui.notify("Invalid intake spec: expected { questions: [...], persist?, collection? }", "error");
1374
+ return;
1375
+ }
1376
+ const result = await runAskIntake(ctx, parsed);
1377
+ if (result.cancelled) {
1378
+ ctx.ui.notify("Intake cancelled.", "warning");
1379
+ }
1380
+ else {
1381
+ ctx.ui.notify("Intake complete. Answers stored.", "info");
1382
+ }
1383
+ };
1384
+ // ── /wiki modal token ────────────────────────────────────────────────────────
1385
+ /** Dotenv path where the Modal token is persisted. */
1386
+ const MODAL_TOKEN_ENV_PATH = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent", "vault-mind.env");
1387
+ const writeTokenEnv = (token) => {
1388
+ const dir = path.dirname(MODAL_TOKEN_ENV_PATH);
1389
+ if (!fs.existsSync(dir))
1390
+ fs.mkdirSync(dir, { recursive: true });
1391
+ // fs mode bits are unsupported on Windows; skip them to avoid EINVAL.
1392
+ const options = process.platform === "win32" ? {} : { mode: 0o600 };
1393
+ fs.writeFileSync(MODAL_TOKEN_ENV_PATH, `PVM_API_TOKEN="${token}"\n`, options);
1394
+ };
1395
+ const fetchTokenFrom1Password = () => {
1396
+ try {
1397
+ return execFileSync("op", ["item", "get", "pi-vault-mind-auth", "--reveal", "--field", "password"], {
1398
+ encoding: "utf-8",
1399
+ stdio: ["ignore", "pipe", "pipe"],
1400
+ }).trim();
1401
+ }
1402
+ catch {
1403
+ return null;
1404
+ }
1405
+ };
1406
+ const handleModalToken = async (ctx, cliToken) => {
1407
+ if (cliToken) {
1408
+ writeTokenEnv(cliToken);
1409
+ ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1410
+ return;
1411
+ }
1412
+ const envToken = process.env[MODAL_TOKEN_ENV];
1413
+ if (envToken) {
1414
+ ctx.ui.notify("PVM_API_TOKEN is already exported in env. Skipping write so we don't clobber it.", "warning");
1415
+ return;
1416
+ }
1417
+ const tokenFrom1Pass = fetchTokenFrom1Password();
1418
+ if (tokenFrom1Pass) {
1419
+ writeTokenEnv(tokenFrom1Pass);
1420
+ ctx.ui.notify(`✅ Fetched token from 1Password item "pi-vault-mind-auth" and wrote it to ${MODAL_TOKEN_ENV_PATH}`, "info");
1421
+ return;
1422
+ }
1423
+ if (!ctx.hasUI) {
1424
+ ctx.ui.notify("No 1Password token found and no TUI available. Use:\n /wiki modal token <token>", "error");
1425
+ return;
1426
+ }
1427
+ const token = await ctx.ui.input("Modal API token:", "paste bearer token here (will be written to ~/.pi/agent/vault-mind.env)");
1428
+ if (!token) {
1429
+ ctx.ui.notify("Token setup cancelled.", "warning");
1430
+ return;
1431
+ }
1432
+ writeTokenEnv(token);
1433
+ ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1434
+ };
1233
1435
  // ── Main /wiki command ───────────────────────────────────────────────────────
1234
1436
  export const registerCommands = (pi) => {
1235
1437
  pi.registerCommand("wiki", {
@@ -1240,11 +1442,13 @@ export const registerCommands = (pi) => {
1240
1442
  "init",
1241
1443
  "validate",
1242
1444
  "approve",
1445
+ "tombstone",
1243
1446
  "settings",
1244
1447
  "audit",
1245
1448
  "help",
1246
1449
  "reindex",
1247
1450
  "collection",
1451
+ "discover-schema",
1248
1452
  "injector",
1249
1453
  "context",
1250
1454
  "embedding",
@@ -1271,6 +1475,11 @@ export const registerCommands = (pi) => {
1271
1475
  .filter((c) => c.startsWith(prefix))
1272
1476
  .map((c) => ({ label: c, value: c, description: `context ${c}` }));
1273
1477
  }
1478
+ if (subcommand === "discover-schema") {
1479
+ return ["--name", "--sample"]
1480
+ .filter((c) => c.startsWith(prefix))
1481
+ .map((c) => ({ label: c, value: c, description: `discover-schema ${c}` }));
1482
+ }
1274
1483
  if (subcommand === "collection") {
1275
1484
  return ["select", "create"]
1276
1485
  .filter((c) => c.startsWith(prefix))
@@ -1287,17 +1496,17 @@ export const registerCommands = (pi) => {
1287
1496
  .map((c) => ({ label: c, value: c, description: `reindex ${c}` }));
1288
1497
  }
1289
1498
  if (subcommand === "modal") {
1290
- return ["status", "config", "sync", "jobs", "migrate"]
1499
+ return ["status", "config", "auto", "token", "sync", "jobs", "migrate"]
1291
1500
  .filter((c) => c.startsWith(prefix))
1292
1501
  .map((c) => ({ label: c, value: c, description: `modal ${c}` }));
1293
1502
  }
1294
1503
  if (subcommand === "watcher") {
1295
- return ["start", "stop", "status"]
1504
+ return ["start", "stop", "status", "queue", "retry", "cancel"]
1296
1505
  .filter((c) => c.startsWith(prefix))
1297
1506
  .map((c) => ({ label: c, value: c, description: `watcher ${c}` }));
1298
1507
  }
1299
1508
  if (subcommand === "setup") {
1300
- return ["--vault", "--provider", "--model"]
1509
+ return ["--vault", "--provider", "--model", "--workspace"]
1301
1510
  .filter((c) => c.startsWith(prefix))
1302
1511
  .map((c) => ({ label: c, value: c, description: `setup ${c}` }));
1303
1512
  }
@@ -1315,12 +1524,16 @@ export const registerCommands = (pi) => {
1315
1524
  return handleValidate(rest, ctx, pi);
1316
1525
  case "approve":
1317
1526
  return handleApprove(rest, ctx);
1527
+ case "tombstone":
1528
+ return handleTombstone(rest, ctx);
1318
1529
  case "settings":
1319
1530
  return openSettingsDashboard(ctx);
1320
1531
  case "audit":
1321
1532
  return auditConfig(ctx);
1322
1533
  case "reindex":
1323
1534
  return handleReindex(rest, ctx, pi);
1535
+ case "discover-schema":
1536
+ return handleDiscoverSchema(rest, ctx);
1324
1537
  case "collection":
1325
1538
  return handleCollection(rest, ctx);
1326
1539
  case "injector":
@@ -1343,4 +1556,52 @@ export const registerCommands = (pi) => {
1343
1556
  },
1344
1557
  });
1345
1558
  // Legacy aliases — /wiki-* commands delegate to /wiki subcommand handlers
1559
+ /* ── /intake ───────────────────────────────────────────────────────────── */
1560
+ pi.registerCommand("intake", {
1561
+ description: "Run an intake questionnaire from a JSON spec file. Optionally write the result to --output.",
1562
+ handler: async (args, ctx) => {
1563
+ const parts = args.trim().split(/\s+/);
1564
+ const outputIdx = parts.indexOf("--output");
1565
+ const outputPath = outputIdx >= 0 ? parts[outputIdx + 1] : undefined;
1566
+ const specPath = outputIdx === 0 ? parts[2] : parts[0];
1567
+ if (!specPath || specPath === "--output") {
1568
+ ctx.ui.notify("Usage: /intake <path-to-json-spec> [--output <result-json-path>]", "error");
1569
+ return;
1570
+ }
1571
+ const resolved = path.isAbsolute(specPath) ? specPath : path.join(ctx.cwd, specPath);
1572
+ if (!fs.existsSync(resolved)) {
1573
+ ctx.ui.notify(`Intake spec not found: ${resolved}`, "error");
1574
+ return;
1575
+ }
1576
+ let parsed;
1577
+ try {
1578
+ parsed = JSON.parse(fs.readFileSync(resolved, "utf-8"));
1579
+ }
1580
+ catch (err) {
1581
+ ctx.ui.notify(`Failed to read intake spec: ${err.message}`, "error");
1582
+ return;
1583
+ }
1584
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.questions)) {
1585
+ ctx.ui.notify("Invalid intake spec: expected { questions: [...], persist?, collection? }", "error");
1586
+ return;
1587
+ }
1588
+ const result = await runAskIntake(ctx, parsed);
1589
+ if (outputPath) {
1590
+ try {
1591
+ const out = path.isAbsolute(outputPath) ? outputPath : path.join(ctx.cwd, outputPath);
1592
+ ensureDir(out);
1593
+ fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
1594
+ }
1595
+ catch (err) {
1596
+ ctx.ui.notify(`Failed to write intake result: ${err.message}`, "error");
1597
+ }
1598
+ }
1599
+ if (result.cancelled) {
1600
+ ctx.ui.notify("Intake cancelled.", "warning");
1601
+ }
1602
+ else {
1603
+ ctx.ui.notify(`Intake complete. ${Object.keys(result.answers).length} answer(s).${result.ledgerEntry ? ` Ledger: ${result.ledgerEntry.path}.` : ""}${result.vaultEntry ? ` Vault: ${result.vaultEntry.path}.` : ""}`, "info");
1604
+ }
1605
+ },
1606
+ });
1346
1607
  };
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { CollectionDef } from "./types.js";
3
+ /** Scalar type family that can be inferred from JSON values. */
4
+ export type InferredType = "string" | "number" | "boolean" | "array" | "object";
5
+ /** Result returned by the discover_schema tool and helper functions. */
6
+ export interface DiscoverSchemaResult {
7
+ collectionName: string;
8
+ path: string;
9
+ schema: string[];
10
+ types: Record<string, InferredType | "mixed">;
11
+ rowCount: number;
12
+ sampled: number;
13
+ }
14
+ /** Infer a single JSON value's type. */
15
+ export declare const inferType: (value: unknown) => InferredType | null;
16
+ /** Read the first `sample` JSON lines from a .jsonl file and infer schema. */
17
+ export declare const discoverSchemaFromFile: (filePath: string, sample?: number) => Omit<DiscoverSchemaResult, "collectionName" | "path"> & {
18
+ allKeys: string[];
19
+ };
20
+ /** Register (or update) a collection in the project config. */
21
+ export declare const writeCollectionToConfig: (cwd: string, name: string, def: CollectionDef) => string;
22
+ /** Run the full discover_schema flow and update config. */
23
+ export declare const runDiscoverSchema: (cwd: string, file: string, name?: string, sample?: number) => DiscoverSchemaResult;
24
+ /** Register the `discover_schema` Pi tool. */
25
+ export declare const registerDiscoverSchemaTool: (pi: ExtensionAPI) => void;
26
+ /** Handler for the /wiki discover-schema slash command. */
27
+ export declare const handleDiscoverSchema: (args: string, ctx: ExtensionContext) => Promise<void>;