pi-vault-mind 0.8.7 → 0.9.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.
@@ -5,7 +5,7 @@ import { Container, SelectList, Text } from "@earendil-works/pi-tui";
5
5
  import * as lancedb from "@lancedb/lancedb";
6
6
  import { getActiveCollection, setActiveCollection } from "./state.js";
7
7
  import { DEFAULT_CONFIG } from "./types.js";
8
- import { CONFIG_FILES, EXT_ROOT, collectionNames, ensureDir, findConfig, getPiContextConfig, hasPiContextTools, loadConfig, } from "./utils.js";
8
+ import { CONFIG_FILES, EXT_ROOT, collectionNames, ensureDir, findConfig, getPiContextConfig, hasPiContextTools, loadConfig, resolveInitCollectionName, } from "./utils.js";
9
9
  import { updateActiveCollectionWidget } from "./widget.js";
10
10
  import { cancelJobHelper, listJobs, retryJob, } from "./agent-queue.js";
11
11
  import { revokeToken, rotateToken } from "./auth.js";
@@ -23,8 +23,7 @@ import { createWatcherState, getWatcherStatus, startWatcher, stopWatcher } from
23
23
  // ── Shared helpers ───────────────────────────────────────────────────────────
24
24
  const VM_USAGE = [
25
25
  "**pi-vault-mind Commands**",
26
- "",
27
- " /vm init Scaffold config + collections",
26
+ " /vm init [--collection <name>] Scaffold config + collections (default collection path uses vault name)",
28
27
  " /vm validate Check LanceDB, config, and collection health",
29
28
  " /vm validate --audit Audit config for missing defaults",
30
29
  " /vm approve [collection] Batch-review pending entries",
@@ -200,6 +199,15 @@ const handleInit = async (_args, ctx, pi) => {
200
199
  const created = [];
201
200
  const updated = [];
202
201
  const skipped = [];
202
+ const initTokens = _args.trim().split(/\s+/).filter(Boolean);
203
+ let requestedCollection;
204
+ for (let i = 0; i < initTokens.length; i++) {
205
+ if (initTokens[i] === "--collection") {
206
+ requestedCollection = initTokens[i + 1];
207
+ i++;
208
+ }
209
+ }
210
+ const initCollection = resolveInitCollectionName(ctx.cwd, requestedCollection);
203
211
  const ensureFile = (dest, tmpl) => {
204
212
  if (fs.existsSync(dest)) {
205
213
  skipped.push(dest);
@@ -216,7 +224,7 @@ const handleInit = async (_args, ctx, pi) => {
216
224
  version: 2,
217
225
  collections: {
218
226
  main: {
219
- path: "collections/main.jsonl",
227
+ path: `collections/${initCollection}.jsonl`,
220
228
  schema: ["id", "domain", "source", "fact", "tag", "artifact"],
221
229
  dedupField: "fact",
222
230
  },
@@ -284,7 +292,7 @@ const handleInit = async (_args, ctx, pi) => {
284
292
  if (skipped.length) {
285
293
  msg.push("", "Skipped (already exist):", ...skipped.map((s) => ` • ${path.relative(ctx.cwd, s)}`));
286
294
  }
287
- msg.push("", '💡 Tip: Tell the agent "Remember: [fact]" to auto-append to the main collection!');
295
+ msg.push("", `💡 Tip: default collection file is collections/${initCollection}.jsonl (override with /vm init --collection <name>).`);
288
296
  if (ctx.hasUI) {
289
297
  ctx.ui.notify(msg.join("\n"), "info");
290
298
  }
@@ -1584,6 +1592,11 @@ export const registerCommands = (pi) => {
1584
1592
  const subcommand = words[0];
1585
1593
  if (words.length >= 2) {
1586
1594
  const prefix = words[1] || "";
1595
+ if (subcommand === "init") {
1596
+ return ["--collection"]
1597
+ .filter((c) => c.startsWith(prefix))
1598
+ .map((c) => ({ label: c, value: c, description: `init ${c}` }));
1599
+ }
1587
1600
  if (subcommand === "embedding") {
1588
1601
  return ["status", "use", "model", "models", "pull", "cloud"]
1589
1602
  .filter((c) => c.startsWith(prefix))
@@ -30,7 +30,7 @@ import { authoriseRequest, resolveAuthToken } from "./auth.js";
30
30
  import { GITIGNORE_ENTRIES } from "./commands.js";
31
31
  import { searchHybrid, upsertEntry } from "./lance.js";
32
32
  import { DEFAULT_CONFIG } from "./types.js";
33
- import { EXT_ROOT, ensureDir, expandHome, findConfig, loadConfig, shrinkHome } from "./utils.js";
33
+ import { ensureDir, expandHome, findConfig, loadConfig, resolveInitCollectionName, shrinkHome, } from "./utils.js";
34
34
  import { processQueue, scanFile, startWatcher, stopWatcher } from "./watcher.js";
35
35
  export function createServerState(port = 11435) {
36
36
  return { server: null, wss: null, port, startTime: 0 };
@@ -493,22 +493,43 @@ function handleVmInit(req, res) {
493
493
  res.end(JSON.stringify({ error: `Vault path does not exist: ${vaultPath}` }));
494
494
  return;
495
495
  }
496
+ const initCollection = resolveInitCollectionName(vaultPath, parsed.collection);
496
497
  // Scaffold project config
497
498
  const cfgPath = path.join(vaultPath, "pi-vault-mind.config.json");
498
499
  if (!fs.existsSync(cfgPath)) {
499
- const tmpl = path.join(EXT_ROOT, "templates", "pi-vault-mind.config.json");
500
- if (fs.existsSync(tmpl)) {
501
- fs.copyFileSync(tmpl, cfgPath);
502
- }
503
- else {
504
- fs.writeFileSync(cfgPath, `${JSON.stringify({ version: 2, collections: DEFAULT_CONFIG.collections, injectors: DEFAULT_CONFIG.injectors, vaultMind: DEFAULT_CONFIG.vaultMind }, null, 2)}\n`, "utf-8");
505
- }
506
- }
507
- // Scaffold collections dir
508
- const collDir = path.join(vaultPath, "collections");
509
- if (!fs.existsSync(collDir))
510
- fs.mkdirSync(collDir, { recursive: true });
511
- for (const [name, def] of Object.entries(DEFAULT_CONFIG.collections)) {
500
+ const tmpl = {
501
+ version: 2,
502
+ collections: {
503
+ main: {
504
+ path: `collections/${initCollection}.jsonl`,
505
+ schema: ["id", "domain", "source", "fact", "tag", "artifact"],
506
+ dedupField: "fact",
507
+ },
508
+ pending: { path: "collections/pending.jsonl", schema: "main" },
509
+ context_events: {
510
+ path: "collections/context_events.jsonl",
511
+ schema: ["id", "type", "session_entry_id", "content", "timestamp", "tags"],
512
+ dedupField: "id",
513
+ },
514
+ },
515
+ injectors: [
516
+ {
517
+ name: "draft-context",
518
+ regex: "draft\\s+(\\S+)",
519
+ collection: "main",
520
+ filterField: "tag",
521
+ artifactPath: "collections/artifact.md",
522
+ },
523
+ ],
524
+ vaultMind: DEFAULT_CONFIG.vaultMind,
525
+ };
526
+ fs.writeFileSync(cfgPath, `${JSON.stringify(tmpl, null, 2)}\n`, "utf-8");
527
+ }
528
+ // Scaffold collections dir from config
529
+ const projectCfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
530
+ for (const def of Object.values(projectCfg.collections || {})) {
531
+ if (!def.path)
532
+ continue;
512
533
  const p = path.join(vaultPath, def.path);
513
534
  if (!fs.existsSync(p)) {
514
535
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -1,4 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export interface ObsidianPluginRequirement {
3
+ id: string;
4
+ label: string;
5
+ required: boolean;
6
+ reason: string;
7
+ }
8
+ export declare const getMissingOnboardingPlugins: (vaultPath: string) => ObsidianPluginRequirement[];
9
+ export declare const buildPluginInstallGuidance: (plugins: ObsidianPluginRequirement[]) => string;
2
10
  export declare const detectVaultFromCwd: (cwd: string) => string | null;
3
11
  export declare const createCollectionWizard: (ctx: ExtensionContext) => Promise<void>;
4
12
  export declare const createInjectorWizard: (ctx: ExtensionContext) => Promise<void>;
@@ -1,7 +1,158 @@
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 { MODAL_TOKEN_ENV, createModalClient, modalUrl, resolveModalToken } from "./modal-config.js";
4
5
  import { collectionNames, findConfig, getGlobalConfigPath, loadConfig, shrinkHome, } from "./utils.js";
6
+ const ONBOARDING_PLUGIN_REQUIREMENTS = [
7
+ {
8
+ id: "obsidian42-brat",
9
+ label: "BRAT (Beta Reviewer's Auto-update Tester)",
10
+ required: true,
11
+ reason: "required to install obsidian-pi-vault-mind (not yet in community registry)",
12
+ },
13
+ {
14
+ id: "obsidian-pi-vault-mind",
15
+ label: "Vault Mind plugin",
16
+ required: true,
17
+ reason: "native setup/status/chat UI and local bridge controls",
18
+ },
19
+ {
20
+ id: "actions-uri",
21
+ label: "Actions URI",
22
+ required: false,
23
+ reason: "deep-link automation and capture shortcuts",
24
+ },
25
+ {
26
+ id: "obsidian-git",
27
+ label: "Obsidian Git",
28
+ required: false,
29
+ reason: "vault backup and change history safety net",
30
+ },
31
+ ];
32
+ const readEnabledCommunityPlugins = (vaultPath) => {
33
+ const communityPluginsPath = path.join(vaultPath, ".obsidian", "community-plugins.json");
34
+ try {
35
+ const parsed = JSON.parse(fs.readFileSync(communityPluginsPath, "utf-8"));
36
+ if (!Array.isArray(parsed))
37
+ return new Set();
38
+ return new Set(parsed.filter((value) => typeof value === "string"));
39
+ }
40
+ catch {
41
+ return new Set();
42
+ }
43
+ };
44
+ export const getMissingOnboardingPlugins = (vaultPath) => {
45
+ const enabled = readEnabledCommunityPlugins(vaultPath);
46
+ return ONBOARDING_PLUGIN_REQUIREMENTS.filter((plugin) => !enabled.has(plugin.id));
47
+ };
48
+ const hasObsidianCli = () => {
49
+ try {
50
+ execFileSync("obsidian", ["help"], {
51
+ encoding: "utf-8",
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ };
60
+ const formatExecError = (err) => {
61
+ if (err && typeof err === "object" && "stderr" in err) {
62
+ const stderr = err.stderr;
63
+ if (typeof stderr === "string" && stderr.trim())
64
+ return stderr.trim();
65
+ if (stderr instanceof Buffer && stderr.length > 0)
66
+ return stderr.toString("utf-8").trim();
67
+ }
68
+ return err instanceof Error ? err.message : String(err);
69
+ };
70
+ const BRAT_REPO = "kylebrodeur/obsidian-pi-vault-mind";
71
+ const installViaBrat = () => {
72
+ try {
73
+ execFileSync("obsidian", ["eval", `app.plugins.getPlugin("obsidian42-brat").addPlugin("${BRAT_REPO}")`], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
74
+ return { ok: true };
75
+ }
76
+ catch (err) {
77
+ return { ok: false, error: formatExecError(err) };
78
+ }
79
+ };
80
+ const installObsidianPlugin = (pluginId) => {
81
+ // vault-mind plugin must be installed via BRAT (not in community registry)
82
+ if (pluginId === "obsidian-pi-vault-mind") {
83
+ return installViaBrat();
84
+ }
85
+ try {
86
+ execFileSync("obsidian", ["plugin:install", `id=${pluginId}`, "enable"], {
87
+ encoding: "utf-8",
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ });
90
+ return { ok: true };
91
+ }
92
+ catch (err) {
93
+ return { ok: false, error: formatExecError(err) };
94
+ }
95
+ };
96
+ export const buildPluginInstallGuidance = (plugins) => {
97
+ const bratPlugins = plugins.filter((p) => p.id === "obsidian-pi-vault-mind");
98
+ const registryPlugins = plugins.filter((p) => p.id !== "obsidian-pi-vault-mind");
99
+ const lines = ["Install missing Obsidian plugins:", ""];
100
+ if (registryPlugins.length > 0) {
101
+ lines.push("CLI (when Obsidian CLI is available):", ...registryPlugins.map((plugin) => ` obsidian plugin:install id=${plugin.id} enable`), "", "Deep links (open each URI):", ...registryPlugins.map((plugin) => ` obsidian://show-plugin?id=${encodeURIComponent(plugin.id)}`), "", "GUI fallback: Obsidian → Settings → Community plugins → Browse");
102
+ }
103
+ if (bratPlugins.length > 0) {
104
+ lines.push("", "Via BRAT (not yet in community registry):", ` Settings → BRAT → "Add Beta plugin" → ${BRAT_REPO}`, ` Or CLI: obsidian eval 'app.plugins.getPlugin("obsidian42-brat").addPlugin("${BRAT_REPO}")'`);
105
+ }
106
+ return lines.join("\n");
107
+ };
108
+ const runPluginOnboarding = async (ctx, vaultPath) => {
109
+ const missingPlugins = getMissingOnboardingPlugins(vaultPath);
110
+ if (missingPlugins.length === 0) {
111
+ ctx.ui.notify("Obsidian plugin check: required integration plugins already enabled.", "info");
112
+ return;
113
+ }
114
+ ctx.ui.notify([
115
+ "Missing Obsidian plugins detected:",
116
+ ...missingPlugins.map((plugin) => `- ${plugin.label} (${plugin.id})${plugin.required ? " [required]" : " [recommended]"} — ${plugin.reason}`),
117
+ ].join("\n"), "warning");
118
+ const cliAvailable = hasObsidianCli();
119
+ const options = cliAvailable
120
+ ? [
121
+ "Install missing plugins now (Obsidian CLI)",
122
+ "I'll install manually (show commands + URIs)",
123
+ "Skip plugin setup for now",
124
+ ]
125
+ : ["I'll install manually (show commands + URIs)", "Skip plugin setup for now"];
126
+ const choice = await ctx.ui.select("Obsidian plugin onboarding", options);
127
+ if (!choice || choice === "Skip plugin setup for now")
128
+ return;
129
+ if (choice === "Install missing plugins now (Obsidian CLI)") {
130
+ const installed = [];
131
+ const failed = [];
132
+ for (const plugin of missingPlugins) {
133
+ const result = installObsidianPlugin(plugin.id);
134
+ if (result.ok) {
135
+ installed.push(plugin.id);
136
+ }
137
+ else {
138
+ failed.push({ plugin, error: result.error || "Unknown error" });
139
+ }
140
+ }
141
+ if (installed.length > 0) {
142
+ ctx.ui.notify([`Installed plugins (${installed.length}):`, ...installed.map((id) => `- ${id}`)].join("\n"), "info");
143
+ }
144
+ if (failed.length > 0) {
145
+ ctx.ui.notify([
146
+ "Some plugin installs failed:",
147
+ ...failed.map(({ plugin, error }) => `- ${plugin.id}: ${error}`),
148
+ "",
149
+ buildPluginInstallGuidance(failed.map(({ plugin }) => plugin)),
150
+ ].join("\n"), "warning");
151
+ }
152
+ return;
153
+ }
154
+ ctx.ui.notify(buildPluginInstallGuidance(missingPlugins), "info");
155
+ };
5
156
  export const detectVaultFromCwd = (cwd) => {
6
157
  const obsidianDir = path.join(cwd, ".obsidian");
7
158
  try {
@@ -91,6 +242,60 @@ export const createInjectorWizard = async (ctx) => {
91
242
  fs.writeFileSync(cfgPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
92
243
  ctx.ui.notify(`✅ Injector "${name}" created and configured!`, "info");
93
244
  };
245
+ /** Probe Ollama at localhost for available embedding models. */
246
+ const probeOllamaModels = async () => {
247
+ try {
248
+ const res = await fetch("http://127.0.0.1:11434/api/tags", {
249
+ signal: AbortSignal.timeout(3000),
250
+ });
251
+ if (!res.ok)
252
+ return [];
253
+ const data = (await res.json());
254
+ if (!Array.isArray(data.models))
255
+ return [];
256
+ // Filter to embedding-capable models (heuristic: name contains "embed" or known embedding models)
257
+ const embeddingHints = ["embed", "minilm", "nomic", "bge", "gte", "e5", "mxbai"];
258
+ const all = data.models
259
+ .filter((m) => embeddingHints.some((h) => m.name.toLowerCase().includes(h)))
260
+ .map((m) => ({
261
+ name: m.name,
262
+ size: m.size > 1e9 ? `${(m.size / 1e9).toFixed(1)}GB` : `${(m.size / 1e6).toFixed(0)}MB`,
263
+ }));
264
+ // Also include all models as secondary options (user might know what they want)
265
+ const nonEmbedding = data.models
266
+ .filter((m) => !embeddingHints.some((h) => m.name.toLowerCase().includes(h)))
267
+ .map((m) => ({
268
+ name: m.name,
269
+ size: m.size > 1e9 ? `${(m.size / 1e9).toFixed(1)}GB` : `${(m.size / 1e6).toFixed(0)}MB`,
270
+ }));
271
+ return [...all, ...nonEmbedding];
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ };
277
+ /** Probe a Modal deployment for available embedding models via GET /models. */
278
+ const probeModalModels = async (baseUrl, cfg) => {
279
+ try {
280
+ const token = resolveModalToken(cfg);
281
+ const headers = {};
282
+ if (token)
283
+ headers.Authorization = `Bearer ${token}`;
284
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
285
+ headers,
286
+ signal: AbortSignal.timeout(8000),
287
+ });
288
+ if (!res.ok)
289
+ return [];
290
+ const data = (await res.json());
291
+ if (!Array.isArray(data.models))
292
+ return [];
293
+ return data.models.filter((m) => m.enabled);
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ };
94
299
  export const setupWizard = async (ctx, cliArgs) => {
95
300
  const existingGlobal = fs.existsSync(getGlobalConfigPath());
96
301
  const detectedVaultPath = detectVaultFromCwd(ctx.cwd);
@@ -142,6 +347,21 @@ export const setupWizard = async (ctx, cliArgs) => {
142
347
  lines.push(` Dim: ${cliArgs.dim}`);
143
348
  if (cliArgs.workspace)
144
349
  lines.push(` Modal workspace: ${cliArgs.workspace}`);
350
+ if (effectiveVaultPath) {
351
+ const obsidianDir = path.join(effectiveVaultPath, ".obsidian");
352
+ if (fs.existsSync(obsidianDir)) {
353
+ const missingPlugins = getMissingOnboardingPlugins(effectiveVaultPath);
354
+ if (missingPlugins.length === 0) {
355
+ lines.push("", "Obsidian plugin check: required integration plugins already enabled.");
356
+ }
357
+ else {
358
+ lines.push("", "Missing Obsidian plugins:", ...missingPlugins.map((plugin) => `- ${plugin.label} (${plugin.id})`), "", buildPluginInstallGuidance(missingPlugins));
359
+ }
360
+ }
361
+ else {
362
+ lines.push("", "Obsidian plugin check skipped: .obsidian/ not found.", "Open the vault in Obsidian once, then re-run /vm setup for plugin guidance.");
363
+ }
364
+ }
145
365
  ctx.ui.notify(lines.join("\n"), "info");
146
366
  return;
147
367
  }
@@ -186,55 +406,86 @@ export const setupWizard = async (ctx, cliArgs) => {
186
406
  return;
187
407
  }
188
408
  }
409
+ // ── Step 1.5: Obsidian plugin readiness ────────────────────────────────
410
+ await runPluginOnboarding(ctx, vaultPath);
189
411
  // ── Step 2: Embedding config ──────────────────────────────────────────
190
- let remoteUrl = await ctx.ui.input("Remote Embedding URL (e.g. https://.../v1, optional):", "");
191
- const localUrl = await ctx.ui.input("Local Embedding URL (e.g. http://127.0.0.1:11434/v1, optional):", "");
412
+ let remoteUrl = "";
413
+ let localUrl = "";
192
414
  let useTransformers = false;
193
415
  let guidedModalWorkspace;
194
- if (!remoteUrl && !localUrl) {
195
- const route = await ctx.ui.select("No embedding URL configured", [
196
- "Use local transformers (offline)",
197
- "Guide me through Modal setup",
198
- "Cancel setup",
199
- ]);
200
- if (!route || route === "Cancel setup") {
201
- ctx.ui.notify("Setup cancelled — at least one URL or transformers is required.", "warning");
416
+ let model = "";
417
+ let dim;
418
+ // Probe Ollama for local embedding models
419
+ const ollamaModels = await probeOllamaModels();
420
+ const providerOptions = [
421
+ ...(ollamaModels.length > 0 ? ["Local (Ollama — models detected)"] : []),
422
+ "Cloud (Modal workspace)",
423
+ "Custom URL (any /v1/embeddings endpoint)",
424
+ "Offline (transformers.js — no external service)",
425
+ ];
426
+ const provider = await ctx.ui.select("Embedding provider:", providerOptions);
427
+ if (!provider) {
428
+ ctx.ui.notify("Setup cancelled.", "warning");
429
+ return;
430
+ }
431
+ if (provider.startsWith("Local")) {
432
+ localUrl = "http://127.0.0.1:11434/v1";
433
+ // Let user pick from detected Ollama embedding models
434
+ const modelChoice = await ctx.ui.select("Select embedding model:", ollamaModels.map((m) => `${m.name} (${m.size})`));
435
+ if (!modelChoice) {
436
+ ctx.ui.notify("Setup cancelled.", "warning");
202
437
  return;
203
438
  }
204
- if (route === "Use local transformers (offline)") {
205
- useTransformers = true;
439
+ model = modelChoice.replace(/\s*\(.*\)$/, "");
440
+ }
441
+ else if (provider.startsWith("Cloud")) {
442
+ const workspaceSlug = await ctx.ui.input("Modal workspace slug:", "");
443
+ if (!workspaceSlug) {
444
+ ctx.ui.notify("Setup cancelled — workspace is required.", "warning");
445
+ return;
206
446
  }
207
- else {
208
- const workspaceSlug = await ctx.ui.input("Modal workspace slug:", "");
209
- if (!workspaceSlug) {
210
- ctx.ui.notify("Setup cancelled workspace is required for Modal guidance.", "warning");
211
- return;
447
+ guidedModalWorkspace = workspaceSlug.trim();
448
+ remoteUrl = modalUrl(guidedModalWorkspace);
449
+ // Probe Modal /models for available embedders
450
+ const modalModels = await probeModalModels(remoteUrl, loadConfig(ctx.cwd).vaultMind);
451
+ if (modalModels.length > 0) {
452
+ const modalChoice = await ctx.ui.select("Select cloud embedding model:", modalModels.map((m) => `${m.key} (dim ${m.native_dim}${m.gated ? ", gated" : ""})`));
453
+ if (modalChoice) {
454
+ model = modalChoice.replace(/\s*\(.*\)$/, "");
455
+ const matched = modalModels.find((m) => m.key === model);
456
+ if (matched)
457
+ dim = matched.native_dim;
212
458
  }
213
- guidedModalWorkspace = workspaceSlug.trim();
214
- remoteUrl = modalUrl(guidedModalWorkspace);
215
- ctx.ui.notify([
216
- "Modal endpoint discovery:",
217
- `- Derived remote URL: ${remoteUrl}`,
218
- "- Set token now with one of:",
219
- " /vm remote token",
220
- " ./scripts/fetch-modal-token.sh --write",
221
- "- Reference docs:",
222
- " docs/getting-started/NEW_VAULT_WALKTHROUGH.md",
223
- " docs/integrations/MODAL_EMBEDDING.md",
224
- ].join("\n"), "info");
225
459
  }
460
+ else {
461
+ ctx.ui.notify("Could not reach Modal /models — using default (embeddinggemma @ 768).", "warning");
462
+ model = "embeddinggemma";
463
+ dim = 768;
464
+ }
465
+ ctx.ui.notify([
466
+ "Modal endpoint discovery:",
467
+ `- Remote URL: ${remoteUrl}`,
468
+ "- Set token with: /vm remote token or ./scripts/fetch-modal-token.sh --write",
469
+ ].join("\n"), "info");
226
470
  }
227
- const model = (await ctx.ui.input("Embedding model key (optional):", ""))?.trim();
228
- if (model === undefined) {
229
- ctx.ui.notify("Setup cancelled.", "warning");
230
- return;
471
+ else if (provider.startsWith("Custom")) {
472
+ const url = await ctx.ui.input("Embedding URL (e.g. https://my-server.com/v1):", "");
473
+ if (!url) {
474
+ ctx.ui.notify("Setup cancelled — URL required.", "warning");
475
+ return;
476
+ }
477
+ remoteUrl = url;
478
+ model = (await ctx.ui.input("Model name (optional):", ""))?.trim() || "";
479
+ const dimStr = await ctx.ui.input("Dimensions (optional):", "");
480
+ dim = dimStr ? Number.parseInt(dimStr, 10) : undefined;
231
481
  }
232
- const dimStr = await ctx.ui.input("Dimensions (optional):", "");
233
- const dim = dimStr ? Number.parseInt(dimStr, 10) : undefined;
234
- let workspace = guidedModalWorkspace;
235
- if (!workspace && remoteUrl?.includes("modal.run")) {
236
- workspace = await ctx.ui.input("Modal workspace (optional):", "");
482
+ else {
483
+ // Offline transformers
484
+ useTransformers = true;
485
+ model = "Xenova/all-MiniLM-L6-v2";
486
+ dim = 384;
237
487
  }
488
+ const workspace = guidedModalWorkspace;
238
489
  // ── Step 3: Deterministic runtime settings ──────────────────────────────
239
490
  const enableContextAutomation = await ctx.ui.confirm("Context automation", "Enable pi-context integration with auto-/acm trigger and event indexing?");
240
491
  const enableAutoStart = await ctx.ui.confirm("Vault auto-start", "Auto-start watcher/server for this vault when pi session starts?");
@@ -21,6 +21,9 @@ export declare const hasPiContextTools: (pi: ExtensionAPI) => boolean;
21
21
  export declare const isPiContextEnabled: (cfg: UniversalConfig) => boolean;
22
22
  export declare const getPiContextConfig: (cfg: UniversalConfig) => PiContextDef;
23
23
  export declare const collectionNames: (cfg: UniversalConfig) => string[];
24
+ export declare const normalizeCollectionName: (value: string) => string;
25
+ export declare const deriveCollectionNameFromVaultPath: (vaultPath: string) => string;
26
+ export declare const resolveInitCollectionName: (vaultPath: string, override?: string) => string;
24
27
  /** Resolve a configured vault folder, falling back to the default layout. */
25
28
  export declare function resolveVaultFolder(cfg: VaultMindConfig, key: "inbox" | "library" | "presentations" | "journal"): string;
26
29
  /** Resolve the configured default vault path, or null if none is configured. */
package/dist/src/utils.js CHANGED
@@ -132,6 +132,25 @@ export const getPiContextConfig = (cfg) => ({
132
132
  });
133
133
  // Backward compat alias
134
134
  export const collectionNames = (cfg) => Object.keys(cfg.collections);
135
+ export const normalizeCollectionName = (value) => value
136
+ .trim()
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, "_")
139
+ .replace(/^_+|_+$/g, "")
140
+ .slice(0, 64);
141
+ export const deriveCollectionNameFromVaultPath = (vaultPath) => {
142
+ const base = path.basename(path.resolve(vaultPath));
143
+ const normalized = normalizeCollectionName(base);
144
+ return normalized || "main";
145
+ };
146
+ export const resolveInitCollectionName = (vaultPath, override) => {
147
+ if (override && override.trim().length > 0) {
148
+ const normalized = normalizeCollectionName(override);
149
+ if (normalized)
150
+ return normalized;
151
+ }
152
+ return deriveCollectionNameFromVaultPath(vaultPath);
153
+ };
135
154
  /** Default vault folder layout. Paths are relative to the vault root. */
136
155
  const DEFAULT_VAULT_FOLDERS = {
137
156
  inbox: "Agent/Inbox",
@@ -114,16 +114,30 @@ describe("REST setup routes", () => {
114
114
  const { status } = await fetchJson("127.0.0.1", port, "GET", "/vm/status");
115
115
  assert.equal(status, 401);
116
116
  });
117
- it("POST /vm/init scaffolds config and collections", async () => {
117
+ it("POST /vm/init scaffolds config and vault-named collection file", async () => {
118
118
  const { status, body } = await fetchJson("127.0.0.1", port, "POST", "/vm/init", { vaultPath }, "test-token");
119
119
  assert.equal(status, 200);
120
120
  const b = body;
121
121
  assert.equal(b.ok, true);
122
122
  assert.equal(b.path, vaultPath);
123
- assert.ok(fs.existsSync(path.join(vaultPath, "pi-vault-mind.config.json")));
124
- assert.ok(fs.existsSync(path.join(vaultPath, "collections", "main.jsonl")));
123
+ const configPath = path.join(vaultPath, "pi-vault-mind.config.json");
124
+ assert.ok(fs.existsSync(configPath));
125
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
126
+ const mainPath = String(cfg.collections?.main?.path || "");
127
+ assert.notEqual(mainPath, "collections/main.jsonl");
128
+ assert.match(mainPath, /^collections\/.+\.jsonl$/);
129
+ assert.ok(fs.existsSync(path.join(vaultPath, mainPath)));
125
130
  assert.ok(fs.existsSync(path.join(vaultPath, ".gitignore")));
126
131
  });
132
+ it("POST /vm/init accepts collection override", async () => {
133
+ const { status, body } = await fetchJson("127.0.0.1", port, "POST", "/vm/init", { vaultPath, collection: "returnvape" }, "test-token");
134
+ assert.equal(status, 200);
135
+ const b = body;
136
+ assert.equal(b.ok, true);
137
+ const cfg = JSON.parse(fs.readFileSync(path.join(vaultPath, "pi-vault-mind.config.json"), "utf-8"));
138
+ assert.equal(cfg.collections?.main?.path, "collections/returnvape.jsonl");
139
+ assert.ok(fs.existsSync(path.join(vaultPath, "collections", "returnvape.jsonl")));
140
+ });
127
141
  it("POST /vm/init is idempotent", async () => {
128
142
  await fetchJson("127.0.0.1", port, "POST", "/vm/init", { vaultPath }, "test-token");
129
143
  const { status, body } = await fetchJson("127.0.0.1", port, "POST", "/vm/init", { vaultPath }, "test-token");
@@ -3,8 +3,8 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { afterEach, describe, it } from "node:test";
6
- import { detectVaultFromCwd, setupWizard } from "../src/settings-ui.js";
7
6
  import { modalUrl } from "../src/modal-config.js";
7
+ import { buildPluginInstallGuidance, detectVaultFromCwd, getMissingOnboardingPlugins, setupWizard, } from "../src/settings-ui.js";
8
8
  import { getGlobalConfigPath } from "../src/utils.js";
9
9
  const savedHome = process.env.HOME;
10
10
  const mkTmpDir = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -37,6 +37,27 @@ describe("settings-ui vault detection", () => {
37
37
  fs.rmSync(dir, { recursive: true, force: true });
38
38
  });
39
39
  });
40
+ describe("setupWizard plugin onboarding helpers", () => {
41
+ it("reports only missing community plugins", () => {
42
+ const vault = mkTmpDir("pvm-plugin-check-");
43
+ const obsidianDir = path.join(vault, ".obsidian");
44
+ fs.mkdirSync(obsidianDir, { recursive: true });
45
+ fs.writeFileSync(path.join(obsidianDir, "community-plugins.json"), JSON.stringify(["obsidian-pi-vault-mind"], null, 2), "utf-8");
46
+ const missing = getMissingOnboardingPlugins(vault).map((plugin) => plugin.id);
47
+ assert.equal(missing.includes("obsidian-pi-vault-mind"), false);
48
+ assert.equal(missing.includes("actions-uri"), true);
49
+ assert.equal(missing.includes("obsidian-git"), true);
50
+ fs.rmSync(vault, { recursive: true, force: true });
51
+ });
52
+ it("builds manual guidance with CLI commands and Obsidian URIs", () => {
53
+ const vault = mkTmpDir("pvm-plugin-guide-");
54
+ const guidance = buildPluginInstallGuidance(getMissingOnboardingPlugins(vault));
55
+ assert.match(guidance, /obsidian plugin:install id=obsidian42-brat enable/);
56
+ assert.match(guidance, /obsidian plugin:install id=actions-uri enable/);
57
+ assert.match(guidance, /BRAT.*kylebrodeur\/obsidian-pi-vault-mind/);
58
+ fs.rmSync(vault, { recursive: true, force: true });
59
+ });
60
+ });
40
61
  describe("setupWizard CLI defaults", () => {
41
62
  it("auto-uses cwd as vault when cwd is an Obsidian vault", async () => {
42
63
  const home = mkTmpDir("pvm-home-");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vault-mind",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Passive Obsidian vault extension for pi. Watches @agent markers, dispatches forked subagents (Miner, Broadcaster, Heavy-Lifter), stores in LanceDB with vector + FTS + graph. Multi-agent 'Drop & Forget' workflow for the pi agent ecosystem.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",