pi-vault-mind 0.14.2 → 0.16.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 (164) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +5 -5
  3. package/agents/broadcaster.agent.md +2 -2
  4. package/agents/heavy-lifter.agent.md +1 -1
  5. package/agents/main.agent.md +29 -0
  6. package/agents/manager.agent.md +1 -1
  7. package/agents/miner.agent.md +1 -1
  8. package/dist/packages/obsidian/src/client.js +331 -0
  9. package/dist/packages/obsidian/src/types.js +1 -0
  10. package/dist/src/activity.d.ts +49 -0
  11. package/dist/src/activity.js +175 -0
  12. package/dist/src/agent-bus/Agent.d.ts +9 -1
  13. package/dist/src/agent-bus/Agent.js +18 -2
  14. package/dist/src/agent-bus/index.d.ts +5 -5
  15. package/dist/src/agent-bus/index.js +1 -1
  16. package/dist/src/agent-queue.js +12 -3
  17. package/dist/src/agents/BroadcasterAgent.d.ts +1 -1
  18. package/dist/src/agents/BroadcasterAgent.js +2 -2
  19. package/dist/src/agents/HeavyLifterAgent.d.ts +1 -1
  20. package/dist/src/agents/HeavyLifterAgent.js +2 -2
  21. package/dist/src/agents/ManagerAgent.js +1 -1
  22. package/dist/src/agents/MinerAgent.d.ts +1 -1
  23. package/dist/src/agents/MinerAgent.js +2 -2
  24. package/dist/src/agents/index.d.ts +2 -2
  25. package/dist/src/agents/index.js +2 -2
  26. package/dist/src/auth.d.ts +17 -15
  27. package/dist/src/auth.js +26 -37
  28. package/dist/src/autosync.js +2 -2
  29. package/dist/src/commands.d.ts +2 -19
  30. package/dist/src/commands.js +301 -231
  31. package/dist/src/config-keys.d.ts +16 -0
  32. package/dist/src/config-keys.js +45 -0
  33. package/dist/src/discover-schema.js +4 -4
  34. package/dist/src/edits.d.ts +21 -0
  35. package/dist/src/edits.js +75 -0
  36. package/dist/src/embedding-probe.d.ts +34 -0
  37. package/dist/src/embedding-probe.js +142 -0
  38. package/dist/src/embedding-providers.d.ts +133 -0
  39. package/dist/src/embedding-providers.js +173 -0
  40. package/dist/src/embedding-secrets.d.ts +38 -0
  41. package/dist/src/embedding-secrets.js +291 -0
  42. package/dist/src/engine.d.ts +6 -0
  43. package/dist/src/engine.js +78 -6
  44. package/dist/src/events.js +7 -10
  45. package/dist/src/extension-packages.d.ts +9 -0
  46. package/dist/src/extension-packages.js +14 -0
  47. package/dist/src/git.d.ts +19 -0
  48. package/dist/src/git.js +78 -0
  49. package/dist/src/graph.d.ts +2 -1
  50. package/dist/src/graph.js +47 -13
  51. package/dist/src/identities-config.d.ts +6 -0
  52. package/dist/src/identities-config.js +39 -0
  53. package/dist/src/identity-injector.js +1 -1
  54. package/dist/src/index.js +9 -26
  55. package/dist/src/lance.d.ts +9 -0
  56. package/dist/src/lance.js +143 -203
  57. package/dist/src/modal-client.d.ts +16 -0
  58. package/dist/src/modal-client.js +16 -0
  59. package/dist/src/modal-config.d.ts +37 -14
  60. package/dist/src/modal-config.js +59 -32
  61. package/dist/src/model-router.d.ts +27 -0
  62. package/dist/src/model-router.js +67 -0
  63. package/dist/src/models.d.ts +42 -0
  64. package/dist/src/models.js +78 -0
  65. package/dist/src/personalize.js +17 -3
  66. package/dist/src/scaffold.d.ts +48 -0
  67. package/dist/src/scaffold.js +246 -0
  68. package/dist/src/server.d.ts +47 -4
  69. package/dist/src/server.js +988 -221
  70. package/dist/src/session-search.d.ts +63 -0
  71. package/dist/src/session-search.js +379 -0
  72. package/dist/src/settings-ui.d.ts +2 -2
  73. package/dist/src/settings-ui.js +76 -135
  74. package/dist/src/sync.d.ts +3 -3
  75. package/dist/src/sync.js +19 -15
  76. package/dist/src/tool-catalog.d.ts +18 -0
  77. package/dist/src/tool-catalog.js +232 -0
  78. package/dist/src/tools/marksman.js +1 -1
  79. package/dist/src/tools.js +111 -65
  80. package/dist/src/types.d.ts +127 -71
  81. package/dist/src/utils.d.ts +57 -6
  82. package/dist/src/utils.js +114 -28
  83. package/dist/src/vault-tools.d.ts +34 -0
  84. package/dist/src/vault-tools.js +99 -0
  85. package/dist/src/vault-writer.js +4 -1
  86. package/dist/src/vm-handlers.js +140 -43
  87. package/dist/src/watcher.js +9 -9
  88. package/dist/test/activity-tool.test.js +54 -0
  89. package/dist/test/agent-bus/Agent.test.js +174 -0
  90. package/dist/test/agent-bus/AgentLoader.test.js +180 -0
  91. package/dist/test/agent-bus/AgentRegistry.test.js +86 -0
  92. package/dist/test/agent-bus/LLMProvider.test.js +162 -0
  93. package/dist/test/agent-bus/TaskQueue.test.js +256 -0
  94. package/dist/test/agent-dispatch-context.test.js +73 -0
  95. package/dist/test/agent-queue.test.js +1 -1
  96. package/dist/test/agents/Agents.test.js +282 -0
  97. package/dist/test/auth.test.js +57 -56
  98. package/dist/test/bridge-identity.test.js +34 -3
  99. package/dist/test/classify-search-mode.test.js +51 -0
  100. package/dist/test/commands.test.js +410 -0
  101. package/dist/test/config-merge.test.js +98 -0
  102. package/dist/test/context-capture.test.js +56 -0
  103. package/dist/test/discover-schema.test.js +8 -5
  104. package/dist/test/dispatch.test.js +45 -91
  105. package/dist/test/edits.test.js +144 -0
  106. package/dist/test/embedding-probe.test.js +424 -0
  107. package/dist/test/embedding-providers.test.js +157 -0
  108. package/dist/test/embedding-secrets.test.js +210 -0
  109. package/dist/test/engine-identity.test.js +1 -1
  110. package/dist/test/events.test.js +675 -0
  111. package/dist/test/git-bridge.test.js +160 -0
  112. package/dist/test/graph.test.js +464 -0
  113. package/dist/test/helpers/server.js +81 -0
  114. package/dist/test/identity-injector.test.js +197 -0
  115. package/dist/test/index.test.js +5 -7
  116. package/dist/test/lance-modal.test.js +67 -2
  117. package/dist/test/lance.test.js +429 -0
  118. package/dist/test/modal-client.test.js +1 -1
  119. package/dist/test/modal-config.test.js +92 -79
  120. package/dist/test/model-readiness.test.js +122 -0
  121. package/dist/test/model-router.test.js +99 -0
  122. package/dist/test/obsidian-client.test.js +42 -0
  123. package/dist/test/personalize.test.js +1 -1
  124. package/dist/test/rest-models.test.js +153 -0
  125. package/dist/test/rest-queue.test.js +135 -87
  126. package/dist/test/rest-setup.test.js +625 -115
  127. package/dist/test/rest-vm.test.js +355 -94
  128. package/dist/test/server-cors.test.js +48 -0
  129. package/dist/test/server-ws.test.js +61 -34
  130. package/dist/test/session-crud.test.js +323 -0
  131. package/dist/test/session-search.test.js +139 -0
  132. package/dist/test/settings-ui.test.js +113 -4
  133. package/dist/test/sync.test.js +12 -12
  134. package/dist/test/tombstone.test.js +8 -10
  135. package/dist/test/tool-catalog.test.js +86 -0
  136. package/dist/test/utils.test.js +588 -0
  137. package/dist/test/vault-tools.test.js +103 -12
  138. package/dist/test/vault-writer.test.js +371 -0
  139. package/dist/test/vm-handlers.test.js +620 -0
  140. package/extension-packages.json +9 -0
  141. package/package.json +107 -90
  142. package/pi-vault-mind.config.example.json +24 -2
  143. package/scripts/check-deps.sh +35 -32
  144. package/scripts/cli-e2e.mjs +215 -0
  145. package/scripts/configuration-e2e.mjs +783 -0
  146. package/scripts/e2e-commands.mjs +391 -0
  147. package/scripts/e2e-ws.mjs +3 -2
  148. package/scripts/fetch-modal-token.sh +4 -3
  149. package/scripts/generate-config-keys-json.mjs +45 -0
  150. package/scripts/generate-extension-packages-json.mjs +55 -0
  151. package/scripts/pi-session-query +0 -0
  152. package/scripts/publish-obsidian-plugin.sh +0 -0
  153. package/scripts/reset-test-vault.sh +2 -2
  154. package/scripts/setup-test-config.sh +0 -0
  155. package/scripts/setup-vault-pi.sh +48 -9
  156. package/scripts/test-slash-commands.mjs +2 -2
  157. package/scripts/vault-pi.sh +12 -3
  158. package/skills/github-release-notes-file/SKILL.md +42 -0
  159. package/skills/obsidian-guided-test-session/SKILL.md +110 -0
  160. package/skills/obsidian-interactive-test/SKILL.md +88 -0
  161. package/skills/obsidian-plugin-publish/SKILL.md +58 -0
  162. package/skills/pi-vault-mind-release/SKILL.md +141 -0
  163. package/skills/pre-test-release/SKILL.md +116 -0
  164. package/dist/test/auto-indexer.test.js +0 -2
@@ -3,31 +3,34 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { Container, SelectList, Text } from "@earendil-works/pi-tui";
5
5
  import * as lancedb from "@lancedb/lancedb";
6
- import { getActiveCollection, setActiveCollection } from "./state.js";
7
- import { DEFAULT_CONFIG } from "./types.js";
8
- import { CONFIG_FILES, EXT_ROOT, collectionNames, ensureDir, findConfig, getPiContextConfig, hasPiContextTools, loadConfig, resolveInitCollectionName, } from "./utils.js";
9
- import { updateActiveCollectionWidget } from "./widget.js";
10
- import { cancelJobHelper, listJobs, retryJob, } from "./agent-queue.js";
6
+ import { cancelJobHelper, listJobs, retryJob } from "./agent-queue.js";
11
7
  import { revokeToken, rotateToken } from "./auth.js";
8
+ import { EMBEDDING_FLAT_KEYS } from "./config-keys.js";
12
9
  import { handleDiscoverSchema } from "./discover-schema.js";
10
+ import { writeEnvEntry } from "./embedding-secrets.js";
11
+ import { queryGraph } from "./graph.js";
13
12
  import { runAskIntake } from "./intake.js";
14
- import { connect, searchHybrid } from "./lance.js";
15
- import { MODAL_TOKEN_ENV, createModalClient, isModalConfigured, resolveBaseUrl, resolveModalToken, resolveWorkspace, } from "./modal-config.js";
13
+ import { connect, searchFts, searchHybrid, searchHybridRanked } from "./lance.js";
14
+ import { createModalClient, isModalConfigured, MODAL_TOKEN_ENV, modalTokenEnvPath, resolveBaseUrl, resolveModalToken, resolveWorkspace, } from "./modal-config.js";
15
+ import { scaffoldModelRouterConfig } from "./model-router.js";
16
16
  import { runPersonalize } from "./personalize.js";
17
- import { createServerState } from "./server.js";
17
+ import { scaffoldVaultConfig } from "./scaffold.js";
18
+ import { createServerState, startServer } from "./server.js";
18
19
  import { createCollectionWizard, createInjectorWizard, openSettingsDashboard, setupWizard, } from "./settings-ui.js";
20
+ import { getActiveCollection, setActiveCollection } from "./state.js";
19
21
  import { reindexRemote, syncAll, syncCollection } from "./sync.js";
20
22
  import { tombstoneEntryById } from "./tombstone.js";
21
- import { expandHome } from "./utils.js";
23
+ import { DEFAULT_CONFIG } from "./types.js";
24
+ import { collectionNames, ensureDir, expandHome, findConfig, getConfigPath, getPiContextConfig, hasPiContextTools, isVaultMindReady, loadConfig, resolveVaultMindPaths, } from "./utils.js";
22
25
  import { createWatcherState, getWatcherStatus, startWatcher, stopWatcher } from "./watcher.js";
26
+ import { updateActiveCollectionWidget } from "./widget.js";
23
27
  // ── Shared helpers ───────────────────────────────────────────────────────────
24
28
  const VM_USAGE = [
25
29
  "**pi-vault-mind Commands**",
26
- " /vm init [--collection <name>] Scaffold config + collections (default collection path uses vault name)",
27
30
  " /vm validate Check LanceDB, config, and collection health",
28
31
  " /vm validate --audit Audit config for missing defaults",
29
32
  " /vm approve [collection] Batch-review pending entries",
30
- " /vm query <collection> <query> Hybrid semantic + FTS search",
33
+ " /vm search [collection] <query> [--mode=hybrid|semantic|fts|graph|full] Unified search",
31
34
  " /vm tombstone <collection> <entry-id> Redact an entry in place",
32
35
  " /vm settings Open interactive settings dashboard",
33
36
  " /vm collection select Select active collection",
@@ -51,22 +54,25 @@ const VM_USAGE = [
51
54
  " /vm watcher retry <id> Retry a failed or cancelled agent job",
52
55
  " /vm watcher cancel <id> Cancel a pending agent job",
53
56
  " /vm server status Show HTTP server status + port",
57
+ " /vm server start Start the in-process HTTP server",
54
58
  " /vm personalize Analyze vault and suggest personality tuning for system.md + AGENTS.md",
55
59
  " /vm setup Onboarding flow: config + personalize (auto-uses cwd when it's a vault)",
56
60
  " /vm setup --vault <path> CLI: set vault path",
57
61
  " /vm setup --remoteUrl <url> CLI: set remote embedding URL",
58
62
  " /vm setup --localUrl <url> CLI: set local embedding URL",
59
63
  " /vm setup --workspace <slug> CLI: derive Modal URL + set workspace",
64
+ " /vm setup --remoteApiKey <key> CLI: set remote API key",
65
+ " /vm setup --collection <name> CLI: override default collection name (defaults to vault name)",
60
66
  " /vm token Show API token source and masked value",
61
67
  " /vm token show Show API token source and masked value",
62
68
  " /vm token rotate Generate + persist a new API token (old token invalidated)",
63
- " /vm token revoke \u003cidentity\u003e Remove PVM_TOKEN_\u003cIDENTITY\u003e from ~/.pi/agent/vault-mind.env",
69
+ " /vm token revoke <identity> Remove PVM_TOKEN_<IDENTITY> from .vault-mind/vault-mind.env",
64
70
  " /vm help Show this help",
65
71
  ].join("\n");
66
72
  export const auditConfig = async (ctx) => {
67
73
  const { project: cfgPath } = findConfig(ctx.cwd);
68
74
  if (!cfgPath) {
69
- ctx.ui.notify("No project config file found to audit. Run /vm init first.", "info");
75
+ ctx.ui.notify("No project config file found to audit. Run /vm setup first.", "info");
70
76
  return;
71
77
  }
72
78
  const projectCfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
@@ -162,44 +168,64 @@ export const selectActiveCollection = async (ctx) => {
162
168
  ctx.ui.notify(`Active collection set to: ${result}`, "info");
163
169
  }
164
170
  };
165
- // ── /vm init ──────────────────────────────────────────────────────────────
166
- /**
167
- * Vault-root `.gitignore` entries that keep pi-vault-mind compatible with
168
- * `obsidian-git` (and, transitively, Obsidian Sync setups that also back up via
169
- * git): the LanceDB index is a large, per-device, rebuildable binary and must
170
- * never be committed; Obsidian's workspace UI state churns constantly. See
171
- * docs/OBSIDIAN_SETUP.md §6.
172
- */
173
- export const GITIGNORE_ENTRIES = [".lancedb/", ".obsidian/workspace*.json"];
174
- /**
175
- * Decide what to do with the vault `.gitignore`, given its current contents
176
- * (`null` if absent). Pure — does no I/O — so it is unit-testable. Creates the
177
- * file when missing, appends only the entries that aren't already present
178
- * (line-exact match, trimmed), and skips when everything is covered.
179
- */
180
- export const planGitignore = (existing) => {
181
- const header = "# pi-vault-mind: keep the rebuildable binary index out of git";
182
- if (existing === null) {
183
- return {
184
- action: "create",
185
- content: `${header}\n.lancedb/\n# Obsidian UI workspace state churns constantly\n.obsidian/workspace*.json\n`,
186
- };
171
+ // ── /vm doctor ──────────────────────────────────────────────────────────────
172
+ const handleDoctor = (ctx) => {
173
+ const paths = resolveVaultMindPaths(ctx.cwd);
174
+ const ready = isVaultMindReady(ctx.cwd);
175
+ const piAgentDirSet = !!process.env.PI_CODING_AGENT_DIR;
176
+ const stalePiAgent = fs.existsSync(path.join(ctx.cwd, ".pi", "agent"));
177
+ const hasGlobalPi = paths.agentDir.startsWith(path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi"));
178
+ // Count discovered providers/models from models.json
179
+ let providerCount = 0;
180
+ let modelCount = 0;
181
+ if (paths.exists.modelsJson) {
182
+ try {
183
+ const modelsJson = JSON.parse(fs.readFileSync(paths.modelsJson, "utf-8"));
184
+ const providers = modelsJson?.providers ?? {};
185
+ providerCount = Object.keys(providers).length;
186
+ for (const p of Object.values(providers)) {
187
+ modelCount += p.models?.length ?? 0;
188
+ }
189
+ }
190
+ catch {
191
+ /* ignore parse errors */
192
+ }
187
193
  }
188
- // Match leniently: `.lancedb`, `.lancedb/`, and `/.lancedb/` are the same rule,
189
- // so we don't append a duplicate when the user already ignores it differently.
190
- const normalize = (s) => s.trim().replace(/^\/+|\/+$/g, "");
191
- const present = new Set(existing.split(/\r?\n/).map(normalize));
192
- const missing = GITIGNORE_ENTRIES.filter((e) => !present.has(normalize(e)));
193
- if (missing.length === 0)
194
- return { action: "skip", content: "" };
195
- const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
196
- return { action: "append", content: `${prefix}# pi-vault-mind\n${missing.join("\n")}\n` };
194
+ const lines = [
195
+ "**Vault Mind Doctor**",
196
+ "",
197
+ `Vault: ${paths.vaultPath}`,
198
+ `Agent dir: ${paths.agentDir}`,
199
+ `Vault-mind dir: ${paths.vaultMindDir}`,
200
+ "",
201
+ "── Paths ──",
202
+ `Config: ${paths.config} ${paths.exists.config ? "✅" : "❌ missing"}`,
203
+ `Token env: ${paths.tokenEnv} ${paths.exists.tokenEnv ? "✅" : "❌ missing"}`,
204
+ `Queue: ${paths.queue}`,
205
+ `Server state: ${paths.serverState}`,
206
+ `Manual dispatch: ${paths.manualDispatch}`,
207
+ `Canvas: ${paths.canvasPath}`,
208
+ `Capture note: ${paths.captureNote ?? "(not configured)"}`,
209
+ "",
210
+ "── Framework ──",
211
+ `models.json: ${paths.modelsJson} ${paths.exists.modelsJson ? `✅ (${providerCount} provider(s), ${modelCount} model(s))` : "❌ missing"}`,
212
+ `model-router.json: ${paths.modelRouter} ${paths.exists.modelRouter ? "✅" : "❌ missing"}`,
213
+ "",
214
+ "── Status ──",
215
+ `Ready: ${ready ? "✅ yes" : "❌ no (config missing or embedding not configured)"}`,
216
+ `PI_CODING_AGENT_DIR: ${piAgentDirSet ? "✅ set" : "⚠️ not set"}`,
217
+ `Stale <vault>/.pi/agent: ${stalePiAgent ? "⚠️ exists (may be legacy)" : "✅ absent"}`,
218
+ "",
219
+ "── Auth ──",
220
+ "Provider auth is managed by pi (`pi` login). No new auth machinery here.",
221
+ "",
222
+ hasGlobalPi
223
+ ? "⚠️ WARN: agentDir resolves under ~/.pi (global pi). Vault isolation may be incomplete."
224
+ : "✅ PASS: all paths are vault-local (no ~/.pi dependency).",
225
+ ];
226
+ ctx.ui.notify(lines.join("\n"), ready ? "info" : "warning");
197
227
  };
198
228
  const handleInit = async (_args, ctx, pi) => {
199
- const cfg = loadConfig(ctx.cwd);
200
- const created = [];
201
- const updated = [];
202
- const skipped = [];
203
229
  const initTokens = _args.trim().split(/\s+/).filter(Boolean);
204
230
  let requestedCollection;
205
231
  for (let i = 0; i < initTokens.length; i++) {
@@ -208,79 +234,7 @@ const handleInit = async (_args, ctx, pi) => {
208
234
  i++;
209
235
  }
210
236
  }
211
- const initCollection = resolveInitCollectionName(ctx.cwd, requestedCollection);
212
- const ensureFile = (dest, tmpl) => {
213
- if (fs.existsSync(dest)) {
214
- skipped.push(dest);
215
- return;
216
- }
217
- ensureDir(dest);
218
- const src = path.join(EXT_ROOT, "templates", tmpl);
219
- fs.writeFileSync(dest, fs.existsSync(src) ? fs.readFileSync(src) : "", "utf-8");
220
- created.push(dest);
221
- };
222
- const cfgDest = path.join(ctx.cwd, CONFIG_FILES[0]);
223
- if (!fs.existsSync(cfgDest)) {
224
- const tmpl = {
225
- version: 2,
226
- collections: {
227
- main: {
228
- path: `collections/${initCollection}.jsonl`,
229
- schema: ["id", "domain", "source", "fact", "tag", "artifact"],
230
- dedupField: "fact",
231
- },
232
- pending: { path: "collections/pending.jsonl", schema: "main" },
233
- context_events: {
234
- path: "collections/context_events.jsonl",
235
- schema: ["id", "type", "session_entry_id", "content", "timestamp", "tags"],
236
- dedupField: "id",
237
- },
238
- },
239
- injectors: [
240
- {
241
- name: "draft-context",
242
- regex: "draft\\s+(\\S+)",
243
- collection: "main",
244
- filterField: "tag",
245
- artifactPath: "collections/artifact.md",
246
- },
247
- ],
248
- vaultMind: {
249
- dataDir: ".lancedb",
250
- ftsEnabled: true,
251
- graph: { enabled: true, canvasSync: false },
252
- },
253
- };
254
- fs.writeFileSync(cfgDest, `${JSON.stringify(tmpl, null, 2)}\n`, "utf-8");
255
- created.push(cfgDest);
256
- }
257
- else {
258
- skipped.push(cfgDest);
259
- }
260
- // Reload the config we just wrote so we scaffold the collections it defines.
261
- const effectiveCfg = fs.existsSync(cfgDest) ? loadConfig(ctx.cwd) : cfg;
262
- for (const [name, def] of Object.entries(effectiveCfg.collections)) {
263
- ensureFile(def.path, name === "pending" ? "PENDING_LEDGER.jsonl" : "UCL_LEDGER.jsonl");
264
- }
265
- for (const ij of effectiveCfg.injectors) {
266
- if (ij.artifactPath)
267
- ensureFile(ij.artifactPath, "ARTIFACT.md");
268
- }
269
- // Keep the rebuildable binary index out of git so obsidian-git (and git-backed
270
- // Obsidian Sync setups) never commit it. See docs/OBSIDIAN_SETUP.md §6.
271
- const gitignoreDest = path.join(ctx.cwd, ".gitignore");
272
- const plan = planGitignore(fs.existsSync(gitignoreDest) ? fs.readFileSync(gitignoreDest, "utf-8") : null);
273
- if (plan.action === "create") {
274
- fs.writeFileSync(gitignoreDest, plan.content, "utf-8");
275
- created.push(gitignoreDest);
276
- }
277
- else if (plan.action === "append") {
278
- fs.appendFileSync(gitignoreDest, plan.content);
279
- updated.push(gitignoreDest);
280
- }
281
- else {
282
- skipped.push(gitignoreDest);
283
- }
237
+ const { created, updated, skipped, collectionName } = scaffoldVaultConfig(ctx.cwd, requestedCollection);
284
238
  const msg = [
285
239
  "Vault-mind scaffolding complete.",
286
240
  "",
@@ -293,11 +247,12 @@ const handleInit = async (_args, ctx, pi) => {
293
247
  if (skipped.length) {
294
248
  msg.push("", "Skipped (already exist):", ...skipped.map((s) => ` • ${path.relative(ctx.cwd, s)}`));
295
249
  }
296
- msg.push("", `💡 Tip: default collection file is collections/${initCollection}.jsonl (override with /vm init --collection <name>).`);
250
+ msg.push("", `💡 Tip: default collection file is collections/${collectionName}.jsonl (override with /vm setup --collection <name>).`);
297
251
  if (ctx.hasUI) {
298
252
  ctx.ui.notify(msg.join("\n"), "info");
299
253
  }
300
254
  if (hasPiContextTools(pi)) {
255
+ const cfgDest = getConfigPath(ctx.cwd);
301
256
  const existing = JSON.parse(fs.readFileSync(cfgDest, "utf-8"));
302
257
  existing.extensionCompatibility = existing.extensionCompatibility || {};
303
258
  existing.extensionCompatibility["pi-context"] = {
@@ -341,7 +296,7 @@ const handleValidate = async (_args, ctx, pi) => {
341
296
  }
342
297
  catch { }
343
298
  const { project: cfgPath } = findConfig(ctx.cwd);
344
- lines.push(cfgPath ? `✅ Config: ${cfgPath}` : `⚠️ No config file (checked ${CONFIG_FILES.join(", ")}).`);
299
+ lines.push(cfgPath ? `✅ Config: ${cfgPath}` : `⚠️ No config file (checked ${getConfigPath(ctx.cwd)}).`);
345
300
  for (const [name, def] of Object.entries(cfg.collections)) {
346
301
  const exists = fs.existsSync(def.path);
347
302
  lines.push(`${exists ? "✅" : "⚠️"} Collection "${name}": ${def.path} ${exists ? "" : "(missing)"}`);
@@ -413,54 +368,105 @@ const handleApprove = async (args, ctx) => {
413
368
  fs.writeFileSync(pending.path, rejected.join("\n") + (rejected.length > 0 ? "\n" : ""));
414
369
  ctx.ui.notify(`Approved ${approved} → "${targetName}". Rejected ${rejected.length}.`, "info");
415
370
  };
416
- // ── /vm query ─────────────────────────────────────────────────────────────
417
- const handleQuery = async (args, ctx) => {
418
- const parts = args.trim().split(/\s+/).filter(Boolean);
371
+ // ── /vm search ─────────────────────────────────────────────────────────────
372
+ const handleSearch = async (args, ctx, defaultMode = "hybrid") => {
373
+ const modeMatch = args.match(/--mode=(\w+)/);
374
+ const mode = modeMatch ? modeMatch[1] : defaultMode;
375
+ const cleanArgs = args.replace(/--mode=\w+/, "").trim();
376
+ const parts = cleanArgs.split(/\s+/).filter(Boolean);
419
377
  if (parts.length === 0) {
420
- ctx.ui.notify("Usage: /vm query [collection] <query>", "error");
378
+ ctx.ui.notify("Usage: /vm search [collection] <query> [--mode=hybrid|semantic|fts|graph|full]", "error");
421
379
  return;
422
380
  }
423
381
  const cfg = loadConfig(ctx.cwd);
424
382
  let collection = getActiveCollection(ctx.cwd) || "main";
425
- let query = args.trim();
383
+ let query = cleanArgs;
426
384
  if (parts.length > 1 && cfg.collections[parts[0]]) {
427
385
  collection = parts[0];
428
386
  query = parts.slice(1).join(" ");
429
387
  }
430
388
  const def = cfg.collections[collection];
431
389
  if (!def) {
432
- ctx.ui.notify(`Unknown collection "${collection}". Run /vm init first.`, "error");
390
+ ctx.ui.notify(`Unknown collection "${collection}". Run /vm setup first.`, "error");
433
391
  return;
434
392
  }
393
+ const limit = 20;
394
+ const dataDir = cfg.vaultMind.dataDir;
395
+ const vmCfg = cfg.vaultMind;
435
396
  try {
436
- const results = await searchHybrid(cfg.vaultMind.dataDir, collection, query, 20, cfg.vaultMind);
397
+ let results = [];
398
+ switch (mode) {
399
+ case "hybrid":
400
+ results = await searchHybridRanked(dataDir, collection, query, limit, vmCfg);
401
+ break;
402
+ case "semantic":
403
+ results = await searchHybrid(dataDir, collection, query, limit, vmCfg);
404
+ break;
405
+ case "fts":
406
+ results = await searchFts(dataDir, collection, query, limit, vmCfg);
407
+ break;
408
+ case "graph":
409
+ results = [await queryGraph(dataDir, vmCfg, query, 1, undefined)];
410
+ break;
411
+ case "full": {
412
+ const [hybrid, semantic, fts, graph] = await Promise.all([
413
+ searchHybridRanked(dataDir, collection, query, limit, vmCfg).catch(() => []),
414
+ searchHybrid(dataDir, collection, query, limit, vmCfg).catch(() => []),
415
+ searchFts(dataDir, collection, query, limit, vmCfg).catch(() => []),
416
+ queryGraph(dataDir, vmCfg, query, 1, undefined).catch(() => []),
417
+ ]);
418
+ const graphResult = Array.isArray(graph) ? graph : graph ? [graph] : [];
419
+ results = mergeSearchResults([hybrid, semantic, fts, graphResult]);
420
+ break;
421
+ }
422
+ default:
423
+ ctx.ui.notify(`Unknown search mode "${mode}". Use hybrid, semantic, fts, graph, or full.`, "error");
424
+ return;
425
+ }
437
426
  if (results.length === 0) {
438
- ctx.ui.notify(`No results for "${query}" in "${collection}".`, "info");
427
+ ctx.ui.notify(`No results for "${query}" in "${collection}" (mode: ${mode}).`, "info");
439
428
  return;
440
429
  }
441
430
  // Rich TUI table when UI is available; plain text fallback for RPC
442
431
  if (ctx.hasUI) {
443
- await renderQueryResults(ctx, collection, query, results);
432
+ await renderSearchResults(ctx, collection, query, results);
444
433
  }
445
434
  else {
446
435
  const lines = [
447
- `**Search results (${results.length}) for "${query}" in "${collection}":**`,
436
+ `**Search results (${results.length}) for "${query}" in "${collection}" (mode: ${mode}):**`,
448
437
  "",
449
438
  ];
450
439
  for (const r of results) {
451
440
  const row = r;
452
- const fact = String(row.fact ?? row.text ?? "(no text)");
441
+ const fact = String(row.fact ??
442
+ row.text ??
443
+ (typeof row === "object" && row !== null ? JSON.stringify(row) : "(no text)"));
453
444
  lines.push(`- ${fact.slice(0, 120)}${fact.length > 120 ? "..." : ""}`);
454
445
  }
455
446
  ctx.ui.notify(lines.join("\n"), "info");
456
447
  }
457
448
  }
458
449
  catch (err) {
459
- ctx.ui.notify(`Search failed: ${err.message}`, "error");
450
+ ctx.ui.notify(`Search failed: ${err instanceof Error ? err.message : String(err)}`, "error");
460
451
  }
461
452
  };
453
+ function mergeSearchResults(sources) {
454
+ const seen = new Set();
455
+ const merged = [];
456
+ for (const source of sources) {
457
+ for (const r of source) {
458
+ const row = r;
459
+ const key = String(row.id ?? row.fact ?? JSON.stringify(row));
460
+ if (!seen.has(key)) {
461
+ seen.add(key);
462
+ merged.push(row);
463
+ }
464
+ }
465
+ }
466
+ return merged.slice(0, 20);
467
+ }
462
468
  /** Render search results as a scrollable TUI table with column headers. */
463
- async function renderQueryResults(ctx, collection, query, results) {
469
+ async function renderSearchResults(ctx, collection, query, results) {
464
470
  // Infer columns from the first result
465
471
  const first = results[0];
466
472
  const keys = first ? Object.keys(first).filter((k) => !k.startsWith("_")) : [];
@@ -487,7 +493,7 @@ async function renderQueryResults(ctx, collection, query, results) {
487
493
  divider,
488
494
  ...rows.map((r) => r.join(" ")),
489
495
  ];
490
- await ctx.ui.custom((tui, theme, _keybindings, done) => {
496
+ await ctx.ui.custom((_tui, theme, _keybindings, done) => {
491
497
  const container = new Container();
492
498
  let y = 0;
493
499
  container.addChild(new Text(theme.fg("accent", lines[0]), 1, ++y));
@@ -527,7 +533,7 @@ const handleTombstone = async (args, ctx) => {
527
533
  ctx.ui.notify(result.message, result.success ? "info" : "error");
528
534
  };
529
535
  // ── /vm reindex ────────────────────────────────────────────────────────────
530
- const handleReindex = async (args, ctx, pi) => {
536
+ const handleReindex = async (args, ctx, _pi) => {
531
537
  const cfg = loadConfig(ctx.cwd);
532
538
  const tokens = args.trim().split(/\s+/).filter(Boolean);
533
539
  const flags = new Set(tokens.map((t) => t.toLowerCase()));
@@ -537,7 +543,7 @@ const handleReindex = async (args, ctx, pi) => {
537
543
  const collectionFilter = reindexAll || rebuildEmbeddings ? null : (tokens.find((t) => !t.startsWith("--")) ?? null);
538
544
  // Remote bulk re-index: read JSONL → submit Modal bulk job → poll → sync down.
539
545
  if (remote) {
540
- if (!cfg.vaultMind.embedding.remoteUrl) {
546
+ if (!cfg.vaultMind.embedding[EMBEDDING_FLAT_KEYS[0]]) {
541
547
  ctx.ui.notify("--remote requires remoteUrl. Set it up via /vm setup.", "error");
542
548
  return;
543
549
  }
@@ -552,7 +558,7 @@ const handleReindex = async (args, ctx, pi) => {
552
558
  : Object.keys(cfg.collections);
553
559
  ctx.ui.notify(`Remote re-index: submitting Modal bulk job for ${names.join(", ")}...`, "info");
554
560
  try {
555
- const results = await reindexRemote(cfg, names, {
561
+ const results = await reindexRemote(cfg, names, ctx.cwd, {
556
562
  onStatus: (s) => ctx.ui.notify(` ${s.collection || ""}: ${s.status} ${s.processed}/${s.total}`, "info"),
557
563
  });
558
564
  const lines = ["**Remote Re-index Report:**", ""];
@@ -652,14 +658,14 @@ const handleReindex = async (args, ctx, pi) => {
652
658
  ctx.ui.notify(lines.join("\n"), "info");
653
659
  };
654
660
  // ── /vm embedding ──────────────────────────────────────────────────────────
655
- const handleEmbedding = async (args, ctx, pi) => {
661
+ const handleEmbedding = async (args, ctx, _pi) => {
656
662
  const cfg = loadConfig(ctx.cwd);
657
663
  const parts = args.trim().split(/\s+/g);
658
664
  const subcommand = parts[0]?.toLowerCase() || "status";
659
- const value = parts.slice(1).join(" ");
665
+ const _value = parts.slice(1).join(" ");
660
666
  const { project: cfgPath } = findConfig(ctx.cwd);
661
667
  if (!cfgPath) {
662
- ctx.ui.notify("No config found. Run /vm init first.", "error");
668
+ ctx.ui.notify("No config found. Run /vm setup first.", "error");
663
669
  return;
664
670
  }
665
671
  switch (subcommand) {
@@ -677,9 +683,8 @@ const handleEmbedding = async (args, ctx, pi) => {
677
683
  `Graph: ${cfg.vaultMind.graph?.enabled !== false ? "enabled" : "disabled"}`,
678
684
  `Data Dir: ${cfg.vaultMind.dataDir}`,
679
685
  ];
680
- if (emb.modal) {
681
- const modal = emb.modal;
682
- lines.push("", "**Remote Provider (Modal):**", ` Workspace: ${modal.workspace || "(default)"}`, ` Fallback: ${modal.fallback?.enabled === false ? "disabled" : modal.fallback?.provider || "(none — degrade to FTS)"}`, ` Sync: auto=${modal.sync?.autoSync ? "on" : "off"}, interval=${modal.sync?.autoSyncIntervalMs ?? 300000}ms`);
686
+ if (emb.remoteUrl) {
687
+ lines.push("", "**Remote Provider (Modal):**", ` Workspace: ${emb.workspace || "(default)"}`, ` Fallback: ${emb.fallback?.enabled === false ? "disabled" : "(on — same-space local when unreachable)"}`, ` Sync: auto=${emb.sync?.autoSync ? "on" : "off"}, interval=${emb.sync?.autoSyncIntervalMs ?? 300000}ms`);
683
688
  const co = emb.coalesce;
684
689
  if (co) {
685
690
  lines.push(` Coalesce: debounce=${co.debounceMs ?? 1000}ms, batch=${co.maxBatchSize ?? 64}, concurrency=${co.maxConcurrentFlushes ?? 2}, dedupe=${co.dedupe ?? true}, searchBypass=${co.searchBypass ?? true}`);
@@ -702,13 +707,13 @@ const MODAL_CONFIG_USAGE = [
702
707
  " /vm remote config workspace <name> Derive the Modal URL from workspace slug",
703
708
  " /vm remote config model <name> Set the canonical embedder (default embeddinggemma)",
704
709
  " /vm remote config dim <n> Set output dimension (omit for native)",
705
- " /vm remote config fallback ollama|none Set offline fallback provider",
710
+ " /vm remote config fallback <openai-compatible|ollama|none> Set offline fallback provider",
706
711
  " /vm remote config sync auto on|off Toggle auto-sync",
707
712
  " /vm remote config sync interval <ms> Auto-sync interval",
708
713
  " /vm remote config pageSize <n> Sync page size",
709
714
  " /vm remote config coalesce debounce <ms> Coalescer debounce window",
710
715
  " /vm remote config coalesce batch <n> Coalescer max batch size",
711
- " /vm remote config token Show token guidance (use PVM_API_TOKEN env)",
716
+ " /vm remote config token Show token guidance (use PVM_REMOTE_EMBEDDING_API_KEY env)",
712
717
  "",
713
718
  " (no args) Show current remote config",
714
719
  ].join("\n");
@@ -735,31 +740,31 @@ const modalSection = (obj) => {
735
740
  const handleModalConfig = async (args, ctx) => {
736
741
  const { project: cfgPath } = findConfig(ctx.cwd);
737
742
  if (!cfgPath) {
738
- ctx.ui.notify("No config found. Run /vm init first.", "error");
743
+ ctx.ui.notify("No config found. Run /vm setup first.", "error");
739
744
  return;
740
745
  }
741
746
  const cfg = loadConfig(ctx.cwd);
742
747
  const parts = args.trim().split(/\s+/).filter(Boolean);
743
748
  const key = parts[0]?.toLowerCase();
744
- const modal = cfg.vaultMind.embedding.modal ?? {};
749
+ const emb = cfg.vaultMind.embedding;
745
750
  if (!key) {
746
751
  const tokenSrc = resolveModalToken(cfg.vaultMind)
747
- ? "env or ~/.pi/agent/vault-mind.env ✅"
748
- : modal.apiToken
749
- ? "config (set PVM_API_TOKEN env/dotenv to override)"
750
- : "❌ none (set PVM_API_TOKEN env or ~/.pi/agent/vault-mind.env)";
752
+ ? "env or .vault-mind/vault-mind.env ✅"
753
+ : emb.remoteApiKey
754
+ ? "config (set PVM_REMOTE_EMBEDDING_API_KEY env/dotenv to override)"
755
+ : "❌ none (set PVM_REMOTE_EMBEDDING_API_KEY env or .vault-mind/vault-mind.env)";
751
756
  const derivedUrl = resolveBaseUrl(cfg.vaultMind);
752
757
  const lines = [
753
758
  "**Modal Config:**",
754
759
  "",
755
- ` workspace: ${resolveWorkspace(cfg.vaultMind) || "(not set)"}`,
756
- ` baseUrl: ${modal.baseUrl || (derivedUrl ? `${derivedUrl} (derived)` : "❌ not set")}`,
757
- ` model: ${modal.model || "(default embeddinggemma)"}`,
758
- ` dim: ${modal.dim ?? "(native)"}`,
760
+ ` workspace: ${emb.workspace || "(not set)"}`,
761
+ ` baseUrl: ${emb.remoteUrl || (derivedUrl ? `${derivedUrl} (derived)` : "❌ not set")}`,
762
+ ` model: ${emb.model || "(default embeddinggemma)"}`,
763
+ ` dim: ${emb.dim ?? "(native)"}`,
759
764
  ` token: ${tokenSrc}`,
760
- ` fallback: ${modal.fallback?.enabled === false ? "disabled" : modal.fallback?.provider || "(none)"}`,
761
- ` sync: ${JSON.stringify(modal.sync ?? {})}`,
762
- ` coalesce: ${JSON.stringify(cfg.vaultMind.embedding.coalesce ?? {})}`,
765
+ ` fallback: ${emb.fallback?.enabled === false ? "disabled" : "(on — same-space local when unreachable)"}`,
766
+ ` sync: ${JSON.stringify(emb.sync ?? {})}`,
767
+ ` coalesce: ${JSON.stringify(emb.coalesce ?? {})}`,
763
768
  ];
764
769
  ctx.ui.notify(lines.join("\n"), "info");
765
770
  return;
@@ -810,11 +815,13 @@ const handleModalConfig = async (args, ctx) => {
810
815
  if (v === "none") {
811
816
  m.fallback = { enabled: false };
812
817
  }
813
- else if (v === "ollama" || v === "transformers") {
814
- m.fallback = { enabled: true, provider: v };
818
+ else if (v === "openai-compatible" || v === "ollama") {
819
+ // Accept "ollama" as a friendlier alias for "openai-compatible" —
820
+ // they're functionally identical (any local /v1/embeddings endpoint).
821
+ m.fallback = { enabled: true, provider: "openai-compatible" };
815
822
  }
816
823
  else {
817
- ctx.ui.notify("/vm remote config fallback <ollama|transformers|none>", "error");
824
+ ctx.ui.notify("/vm remote config fallback <openai-compatible|ollama|none>", "error");
818
825
  return;
819
826
  }
820
827
  writeProjectConfig(cfgPath, obj);
@@ -882,17 +889,17 @@ const handleModalConfig = async (args, ctx) => {
882
889
  ctx.ui.notify(MODAL_CONFIG_USAGE, "info");
883
890
  }
884
891
  };
885
- const handleModal = async (args, ctx, pi) => {
892
+ const handleModal = async (args, ctx, _pi) => {
886
893
  const cfg = loadConfig(ctx.cwd);
887
894
  const parts = args.trim().split(/\s+/).filter(Boolean);
888
895
  const sub = parts[0]?.toLowerCase() || "status";
889
896
  const rest = parts.slice(1).join(" ");
890
897
  switch (sub) {
891
898
  case "status": {
892
- const modal = cfg.vaultMind.embedding.modal;
899
+ const emb = cfg.vaultMind.embedding;
893
900
  const tokenSrc = resolveModalToken(cfg.vaultMind)
894
- ? "env or ~/.pi/agent/vault-mind.env ✅"
895
- : modal?.apiToken
901
+ ? "env or .vault-mind/vault-mind.env ✅"
902
+ : emb.remoteApiKey
896
903
  ? "config"
897
904
  : "❌ none";
898
905
  const derivedUrl = resolveBaseUrl(cfg.vaultMind);
@@ -901,9 +908,9 @@ const handleModal = async (args, ctx, pi) => {
901
908
  "",
902
909
  `Configured: ${isModalConfigured(cfg.vaultMind) ? "✅" : "❌"}`,
903
910
  ` workspace: ${resolveWorkspace(cfg.vaultMind) || "(not set)"}`,
904
- ` baseUrl: ${modal?.baseUrl || (derivedUrl ? `${derivedUrl} (derived)` : "(not set)")}`,
905
- ` model: ${modal?.model || "(default embeddinggemma)"}`,
906
- ` dim: ${modal?.dim ?? "(native)"}`,
911
+ ` baseUrl: ${emb.remoteUrl || (derivedUrl ? `${derivedUrl} (derived)` : "(not set)")}`,
912
+ ` model: ${emb.model || "(default embeddinggemma)"}`,
913
+ ` dim: ${emb.dim ?? "(native)"}`,
907
914
  ` token: ${tokenSrc}`,
908
915
  ];
909
916
  const client = createModalClient(cfg.vaultMind);
@@ -943,8 +950,8 @@ const handleModal = async (args, ctx, pi) => {
943
950
  ctx.ui.notify(`Syncing ${oneCollection ? `"${oneCollection}"` : "all collections"}${full ? " (full)" : ""}...`, "info");
944
951
  try {
945
952
  const results = oneCollection
946
- ? [await syncCollection(cfg, oneCollection, { full })]
947
- : await syncAll(cfg, undefined, { full });
953
+ ? [await syncCollection(cfg, oneCollection, ctx.cwd, { full })]
954
+ : await syncAll(cfg, ctx.cwd, undefined, { full });
948
955
  const lines = ["**Sync Report:**", ""];
949
956
  for (const r of results)
950
957
  lines.push(` ${r.rows > 0 ? "✅" : "•"} ${r.collection} / ${r.model} / ${r.dim}: ${r.rows} rows, watermark=${r.watermark}${r.full ? " (full)" : ""}`);
@@ -986,16 +993,16 @@ const handleModal = async (args, ctx, pi) => {
986
993
  case "auto": {
987
994
  const cfgPath = findConfig(ctx.cwd).project;
988
995
  if (!cfgPath) {
989
- ctx.ui.notify("No project config found. Run /vm init first.", "error");
996
+ ctx.ui.notify("No project config found. Run /vm setup first.", "error");
990
997
  return;
991
998
  }
992
999
  const workspace = resolveWorkspace(cfg.vaultMind);
993
- if (!workspace && !cfg.vaultMind.embedding.modal?.baseUrl) {
1000
+ if (!workspace && !cfg.vaultMind.embedding[EMBEDDING_FLAT_KEYS[0]]) {
994
1001
  ctx.ui.notify("Modal workspace or baseUrl not configured. Run:\n /vm remote config workspace <name>", "error");
995
1002
  }
996
1003
  const token = resolveModalToken(cfg.vaultMind);
997
1004
  if (!token) {
998
- ctx.ui.notify("Modal token not found. Set PVM_API_TOKEN env or write it to ~/.pi/agent/vault-mind.env", "error");
1005
+ ctx.ui.notify("Modal token not found. Set PVM_REMOTE_EMBEDDING_API_KEY env or write it to .vault-mind/vault-mind.env", "error");
999
1006
  return;
1000
1007
  }
1001
1008
  ctx.ui.notify("🔄 Auto-configuring Modal...", "info");
@@ -1082,7 +1089,7 @@ const handleModal = async (args, ctx, pi) => {
1082
1089
  ctx.ui.notify(`Canonical model → ${newModel}${newDim ? `@${newDim}` : ""} (was ${oldModel}${oldDim ? `@${oldDim}` : ""}). Old namespace kept. Starting remote re-embed...`, "info");
1083
1090
  const fresh = loadConfig(ctx.cwd);
1084
1091
  try {
1085
- const results = await reindexRemote(fresh, Object.keys(fresh.collections));
1092
+ const results = await reindexRemote(fresh, Object.keys(fresh.collections), ctx.cwd);
1086
1093
  const lines = [`**Migration → ${newModel}:**`, ""];
1087
1094
  for (const r of results)
1088
1095
  if (r.error)
@@ -1105,10 +1112,10 @@ const handleModal = async (args, ctx, pi) => {
1105
1112
  const handleContext = async (args, ctx, pi) => {
1106
1113
  const parts = args.trim().split(/\s+/g);
1107
1114
  const subcommand = parts[0]?.toLowerCase() || "status";
1108
- const value = parts[1]?.toLowerCase();
1115
+ const _value = parts[1]?.toLowerCase();
1109
1116
  const { project: cfgPath } = findConfig(ctx.cwd);
1110
1117
  if (!cfgPath) {
1111
- ctx.ui.notify("No config found. Run /vm init first.", "error");
1118
+ ctx.ui.notify("No config found. Run /vm setup first.", "error");
1112
1119
  return;
1113
1120
  }
1114
1121
  switch (subcommand) {
@@ -1226,7 +1233,65 @@ export const watcherState = createWatcherState();
1226
1233
  /** Module-level server state, referenced by the /vm server command.
1227
1234
  * Port is set when the server starts in index.ts. */
1228
1235
  export const serverState = createServerState();
1229
- const handleServer = (ctx) => {
1236
+ const handleServer = (args, ctx, pi) => {
1237
+ const subcommand = args.trim().split(/\s+/g)[0]?.toLowerCase() || "status";
1238
+ if (subcommand === "start") {
1239
+ const cfg = loadConfig(ctx.cwd);
1240
+ serverState.port = cfg.vaultMind.httpPort || 11435;
1241
+ startServer(pi, serverState, watcherState, async () => {
1242
+ const registry = ctx.modelRegistry;
1243
+ await registry.refresh();
1244
+ const error = registry.getError();
1245
+ if (error)
1246
+ return { status: "model-registry-error" };
1247
+ const all = registry.getAll();
1248
+ if (all.length === 0)
1249
+ return { status: "model-registry-empty" };
1250
+ const available = registry.getAvailable();
1251
+ if (available.length === 0) {
1252
+ const providers = [...new Set(all.map((m) => m.provider))];
1253
+ const statuses = providers.map((p) => {
1254
+ const status = registry.getProviderAuthStatus(p);
1255
+ return {
1256
+ providerId: p,
1257
+ configured: status?.configured,
1258
+ source: status?.source,
1259
+ };
1260
+ });
1261
+ const configured = statuses.filter((s) => s.configured);
1262
+ if (configured.length === 0) {
1263
+ const firstSource = statuses[0]?.source;
1264
+ return {
1265
+ status: "provider-authentication-required",
1266
+ authSource: firstSource,
1267
+ };
1268
+ }
1269
+ const firstConfigured = configured[0];
1270
+ return {
1271
+ status: "configured-provider-unavailable",
1272
+ providerId: firstConfigured.providerId,
1273
+ };
1274
+ }
1275
+ const currentModel = ctx.model;
1276
+ if (!currentModel) {
1277
+ if (available.length > 0)
1278
+ return { status: "model-selection-required" };
1279
+ }
1280
+ else if (!available.some((m) => m.id === currentModel.id && m.provider === currentModel.provider)) {
1281
+ return { status: "current-model-unavailable", modelId: currentModel.id };
1282
+ }
1283
+ return {
1284
+ status: "ready",
1285
+ model: {
1286
+ id: currentModel.id,
1287
+ name: currentModel.name,
1288
+ provider: currentModel.provider,
1289
+ },
1290
+ };
1291
+ });
1292
+ ctx.ui.notify(`Started HTTP server on http://127.0.0.1:${serverState.port}/vault-mind/status`, "info");
1293
+ return;
1294
+ }
1230
1295
  const lines = [
1231
1296
  `HTTP server: ${serverState.server ? "RUNNING" : "STOPPED"}`,
1232
1297
  `Port: ${serverState.port}`,
@@ -1255,7 +1320,7 @@ const handleWatcher = async (args, ctx, pi) => {
1255
1320
  case "start": {
1256
1321
  const cfg = loadConfig(ctx.cwd);
1257
1322
  if (!cfg.vaultMind.vaults || Object.keys(cfg.vaultMind.vaults).length === 0) {
1258
- ctx.ui.notify("No vaults configured. Add vaults to vaultMind.vaults in pi-vault-mind.config.json first.", "error");
1323
+ ctx.ui.notify("No vaults configured. Add vaults to vaultMind.vaults in .vault-mind/vault-mind.config.json first.", "error");
1259
1324
  return;
1260
1325
  }
1261
1326
  if (watcherState.running) {
@@ -1369,7 +1434,7 @@ const handleWatcher = async (args, ctx, pi) => {
1369
1434
  const handleSetup = async (args, ctx, pi) => {
1370
1435
  // Parse optional CLI-style args: --vault <path> --remoteUrl <url> --localUrl <url> --model <name> --workspace <name>
1371
1436
  const cliArgs = {};
1372
- const parts = args.trim().split(/\s+--/);
1437
+ const parts = args.trim().replace(/^--/, "").split(/\s+--/);
1373
1438
  for (const part of parts) {
1374
1439
  const trimmed = part.trim();
1375
1440
  if (trimmed.startsWith("vault "))
@@ -1385,11 +1450,28 @@ const handleSetup = async (args, ctx, pi) => {
1385
1450
  cliArgs.model = trimmed.slice(6).trim();
1386
1451
  else if (trimmed.startsWith("workspace "))
1387
1452
  cliArgs.workspace = trimmed.slice(10).trim();
1388
- }
1389
- const hasCliArgs = cliArgs.vault || cliArgs.remoteUrl || cliArgs.localUrl || cliArgs.model || cliArgs.workspace;
1453
+ else if (trimmed.startsWith("remoteApiKey "))
1454
+ cliArgs.remoteApiKey = trimmed.slice(13).trim();
1455
+ else if (trimmed.startsWith("collection "))
1456
+ cliArgs.collection = trimmed.slice(11).trim();
1457
+ }
1458
+ const hasCliArgs = cliArgs.vault ||
1459
+ cliArgs.remoteUrl ||
1460
+ cliArgs.localUrl ||
1461
+ cliArgs.model ||
1462
+ cliArgs.workspace ||
1463
+ cliArgs.collection ||
1464
+ cliArgs.remoteApiKey;
1465
+ // Merge-safe scaffold FIRST: on a truly fresh vault this populates the
1466
+ // default collections/injectors (with vault-derived naming) before
1467
+ // setupWizard ever writes the file — setupWizard writing first would
1468
+ // permanently skip this branch (it always sees an existing file).
1469
+ await handleInit(cliArgs.collection ? `--collection ${cliArgs.collection}` : "", ctx, pi);
1390
1470
  await setupWizard(ctx, hasCliArgs ? cliArgs : undefined);
1391
- // Scaffold collections, .gitignore, and project config if not already present
1392
- await handleInit("", ctx, pi);
1471
+ // Scaffold model-router.json with tuned defaults if this vault doesn't
1472
+ // have one yet — mirrors what the Obsidian plugin does today, so a
1473
+ // CLI-only setup gets the same primary/fallback model choices.
1474
+ scaffoldModelRouterConfig(ctx.cwd);
1393
1475
  if (ctx.hasUI) {
1394
1476
  ctx.ui.notify("Running personalization (system.md + AGENTS.md)...", "info");
1395
1477
  await runPersonalize(ctx, pi);
@@ -1399,7 +1481,7 @@ const handleSetup = async (args, ctx, pi) => {
1399
1481
  }
1400
1482
  };
1401
1483
  // ── /vm intake ─────────────────────────────────────────────────────────────
1402
- const handleIntake = async (args, ctx) => {
1484
+ const _handleIntake = async (args, ctx) => {
1403
1485
  const filePath = args.trim();
1404
1486
  if (!filePath) {
1405
1487
  ctx.ui.notify("Usage: /vm intake <path-to-json-spec>", "error");
@@ -1430,15 +1512,8 @@ const handleIntake = async (args, ctx) => {
1430
1512
  }
1431
1513
  };
1432
1514
  // ── /vm remote token ────────────────────────────────────────────────────────
1433
- /** Dotenv path where the Modal token is persisted. */
1434
- const MODAL_TOKEN_ENV_PATH = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent", "vault-mind.env");
1435
- const writeTokenEnv = (token) => {
1436
- const dir = path.dirname(MODAL_TOKEN_ENV_PATH);
1437
- if (!fs.existsSync(dir))
1438
- fs.mkdirSync(dir, { recursive: true });
1439
- // fs mode bits are unsupported on Windows; skip them to avoid EINVAL.
1440
- const options = process.platform === "win32" ? {} : { mode: 0o600 };
1441
- fs.writeFileSync(MODAL_TOKEN_ENV_PATH, `PVM_API_TOKEN="${token}"\n`, options);
1515
+ export const writeTokenEnv = (token, cwd, varName = "PVM_API_TOKEN") => {
1516
+ writeEnvEntry(varName, token, { cwd });
1442
1517
  };
1443
1518
  const fetchTokenFrom1Password = () => {
1444
1519
  try {
@@ -1453,32 +1528,32 @@ const fetchTokenFrom1Password = () => {
1453
1528
  };
1454
1529
  const handleModalToken = async (ctx, cliToken) => {
1455
1530
  if (cliToken) {
1456
- writeTokenEnv(cliToken);
1457
- ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1531
+ writeTokenEnv(cliToken, ctx.cwd, MODAL_TOKEN_ENV);
1532
+ ctx.ui.notify(`✅ Wrote token to ${modalTokenEnvPath(ctx.cwd)}`, "info");
1458
1533
  return;
1459
1534
  }
1460
1535
  const envToken = process.env[MODAL_TOKEN_ENV];
1461
1536
  if (envToken) {
1462
- ctx.ui.notify("PVM_API_TOKEN is already exported in env. Skipping write so we don't clobber it.", "warning");
1537
+ ctx.ui.notify("PVM_REMOTE_EMBEDDING_API_KEY is already exported in env. Skipping write so we don't clobber it.", "warning");
1463
1538
  return;
1464
1539
  }
1465
1540
  const tokenFrom1Pass = fetchTokenFrom1Password();
1466
1541
  if (tokenFrom1Pass) {
1467
- writeTokenEnv(tokenFrom1Pass);
1468
- ctx.ui.notify(`✅ Fetched token from 1Password item "pi-vault-mind-auth" and wrote it to ${MODAL_TOKEN_ENV_PATH}`, "info");
1542
+ writeTokenEnv(tokenFrom1Pass, ctx.cwd, MODAL_TOKEN_ENV);
1543
+ ctx.ui.notify(`✅ Fetched token from 1Password item "pi-vault-mind-auth" and wrote it to ${modalTokenEnvPath(ctx.cwd)}`, "info");
1469
1544
  return;
1470
1545
  }
1471
1546
  if (!ctx.hasUI) {
1472
1547
  ctx.ui.notify("No 1Password token found and no TUI available. Use:\n /vm remote token <token>", "error");
1473
1548
  return;
1474
1549
  }
1475
- const token = await ctx.ui.input("Modal API token:", "paste bearer token here (will be written to ~/.pi/agent/vault-mind.env)");
1550
+ const token = await ctx.ui.input("Modal API token:", "paste bearer token here (will be written to .vault-mind/vault-mind.env as PVM_REMOTE_EMBEDDING_API_KEY)");
1476
1551
  if (!token) {
1477
1552
  ctx.ui.notify("Token setup cancelled.", "warning");
1478
1553
  return;
1479
1554
  }
1480
- writeTokenEnv(token);
1481
- ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1555
+ writeTokenEnv(token, ctx.cwd, MODAL_TOKEN_ENV);
1556
+ ctx.ui.notify(`✅ Wrote token to ${modalTokenEnvPath(ctx.cwd)}`, "info");
1482
1557
  };
1483
1558
  // ── /vm token ──────────────────────────────────────────────────────────────
1484
1559
  const maskToken = (token) => {
@@ -1500,15 +1575,15 @@ const handleToken = (args, ctx) => {
1500
1575
  else {
1501
1576
  source = "none";
1502
1577
  try {
1503
- const text = fs.readFileSync(MODAL_TOKEN_ENV_PATH, "utf-8");
1578
+ const text = fs.readFileSync(modalTokenEnvPath(ctx.cwd), "utf-8");
1504
1579
  for (const line of text.split(/\r?\n/)) {
1505
1580
  const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s#]+)/);
1506
- if (!m || m[1] !== "PVM_API_TOKEN")
1581
+ if (m?.[1] !== "PVM_API_TOKEN")
1507
1582
  continue;
1508
1583
  const val = m[3] ?? m[4] ?? m[2];
1509
1584
  if (val) {
1510
1585
  token = val;
1511
- source = "~/.pi/agent/vault-mind.env";
1586
+ source = ".vault-mind/vault-mind.env";
1512
1587
  break;
1513
1588
  }
1514
1589
  }
@@ -1516,14 +1591,6 @@ const handleToken = (args, ctx) => {
1516
1591
  catch {
1517
1592
  // file missing or unreadable
1518
1593
  }
1519
- if (!token) {
1520
- const cfg = loadConfig(ctx.cwd);
1521
- const cfgToken = cfg?.vaultMind?.embedding?.modal?.apiToken;
1522
- if (cfgToken) {
1523
- token = cfgToken;
1524
- source = "config (vaultMind.embedding.modal.apiToken)";
1525
- }
1526
- }
1527
1594
  }
1528
1595
  const masked = token ? maskToken(token) : "(not set)";
1529
1596
  ctx.ui.notify(["**API Token**", `Source: ${source}`, `Value: ${masked}`].join("\n"), token ? "info" : "warning");
@@ -1537,7 +1604,7 @@ const handleToken = (args, ctx) => {
1537
1604
  : "";
1538
1605
  ctx.ui.notify([
1539
1606
  `\u2705 New token: ${masked}`,
1540
- "Written to ~/.pi/agent/vault-mind.env.",
1607
+ "Written to .vault-mind/vault-mind.env.",
1541
1608
  `\u26a0\ufe0f All clients (Obsidian plugin, scripts) must update to the new token — the old token is now invalid.${envNote}`,
1542
1609
  ].join("\n"), "warning");
1543
1610
  return;
@@ -1568,15 +1635,15 @@ export const registerCommands = (pi) => {
1568
1635
  getArgumentCompletions: (_prefix) => {
1569
1636
  const words = _prefix.trim().split(/\s+/);
1570
1637
  const top = [
1571
- "init",
1572
1638
  "validate",
1573
1639
  "approve",
1574
- "query",
1640
+ "search",
1575
1641
  "tombstone",
1576
1642
  "settings",
1577
1643
  "help",
1578
1644
  "collection",
1579
1645
  "discover-schema",
1646
+ "doctor",
1580
1647
  "injector",
1581
1648
  "context",
1582
1649
  "embedding",
@@ -1597,11 +1664,6 @@ export const registerCommands = (pi) => {
1597
1664
  const subcommand = words[0];
1598
1665
  if (words.length >= 2) {
1599
1666
  const prefix = words[1] || "";
1600
- if (subcommand === "init") {
1601
- return ["--collection"]
1602
- .filter((c) => c.startsWith(prefix))
1603
- .map((c) => ({ label: c, value: c, description: `init ${c}` }));
1604
- }
1605
1667
  if (subcommand === "embedding") {
1606
1668
  return ["status", "use", "model", "models", "pull", "cloud"]
1607
1669
  .filter((c) => c.startsWith(prefix))
@@ -1617,11 +1679,11 @@ export const registerCommands = (pi) => {
1617
1679
  .filter((c) => c.startsWith(prefix))
1618
1680
  .map((c) => ({ label: c, value: c, description: `discover-schema ${c}` }));
1619
1681
  }
1620
- if (subcommand === "query") {
1682
+ if (subcommand === "search") {
1621
1683
  const names = collectionNames(loadConfig(process.cwd()));
1622
1684
  return names
1623
1685
  .filter((c) => c.startsWith(prefix))
1624
- .map((c) => ({ label: c, value: c, description: `query ${c}` }));
1686
+ .map((c) => ({ label: c, value: c, description: `search ${c}` }));
1625
1687
  }
1626
1688
  if (subcommand === "collection") {
1627
1689
  return ["select", "list", "create", "reindex"]
@@ -1639,7 +1701,15 @@ export const registerCommands = (pi) => {
1639
1701
  .map((c) => ({ label: c, value: c, description: `watcher ${c}` }));
1640
1702
  }
1641
1703
  if (subcommand === "setup") {
1642
- return ["--vault", "--remoteUrl", "--localUrl", "--model", "--workspace"]
1704
+ return [
1705
+ "--vault",
1706
+ "--remoteUrl",
1707
+ "--localUrl",
1708
+ "--model",
1709
+ "--workspace",
1710
+ "--collection",
1711
+ "--remoteApiKey",
1712
+ ]
1643
1713
  .filter((c) => c.startsWith(prefix))
1644
1714
  .map((c) => ({ label: c, value: c, description: `setup ${c}` }));
1645
1715
  }
@@ -1661,14 +1731,14 @@ export const registerCommands = (pi) => {
1661
1731
  const subcommand = parts[0]?.toLowerCase() || "help";
1662
1732
  const rest = parts.slice(1).join(" ");
1663
1733
  switch (subcommand) {
1664
- case "init":
1665
- return handleInit(rest, ctx, pi);
1734
+ case "doctor":
1735
+ return handleDoctor(ctx);
1666
1736
  case "validate":
1667
1737
  return handleValidate(rest, ctx, pi);
1668
1738
  case "approve":
1669
1739
  return handleApprove(rest, ctx);
1670
- case "query":
1671
- return handleQuery(rest, ctx);
1740
+ case "search":
1741
+ return handleSearch(rest, ctx);
1672
1742
  case "tombstone":
1673
1743
  return handleTombstone(rest, ctx);
1674
1744
  case "settings":
@@ -1684,7 +1754,7 @@ export const registerCommands = (pi) => {
1684
1754
  case "embedding":
1685
1755
  return handleEmbedding(rest, ctx, pi);
1686
1756
  case "server":
1687
- return handleServer(ctx);
1757
+ return handleServer(rest, ctx, pi);
1688
1758
  case "watcher":
1689
1759
  return handleWatcher(rest, ctx, pi);
1690
1760
  case "remote":