pi-vault-mind 0.7.2 → 0.7.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.
@@ -7,7 +7,7 @@ import { DEFAULT_CONFIG } from "./types.js";
7
7
  import { CONFIG_FILES, EXT_ROOT, collectionNames, ensureDir, findConfig, getPiContextConfig, hasPiContextTools, loadConfig, } from "./utils.js";
8
8
  import { updateActiveCollectionWidget } from "./widget.js";
9
9
  import { connect, pullOllamaModel, testOllamaConnection } from "./lance.js";
10
- import { MODAL_TOKEN_ENV, createModalClient, isModalConfigured } from "./modal-config.js";
10
+ import { MODAL_TOKEN_ENV, createModalClient, isModalConfigured, resolveBaseUrl, resolveModalToken, resolveWorkspace, } from "./modal-config.js";
11
11
  import { createServerState } from "./server.js";
12
12
  import { createCollectionWizard, createInjectorWizard, openSettingsDashboard, setupWizard, } from "./settings-ui.js";
13
13
  import { reindexRemote, syncAll, syncCollection } from "./sync.js";
@@ -649,6 +649,7 @@ const MODAL_CONFIG_USAGE = [
649
649
  "**/wiki modal config**",
650
650
  "",
651
651
  " /wiki modal config baseUrl <url> Set the Modal ASGI base URL",
652
+ " /wiki modal config workspace <name> Derive the Modal URL from workspace slug",
652
653
  " /wiki modal config model <name> Set the canonical embedder (default embeddinggemma)",
653
654
  " /wiki modal config dim <n> Set output dimension (omit for native)",
654
655
  " /wiki modal config fallback ollama|none Set offline fallback provider",
@@ -692,15 +693,17 @@ const handleModalConfig = async (args, ctx) => {
692
693
  const key = parts[0]?.toLowerCase();
693
694
  const modal = cfg.wiki.embedding.modal ?? {};
694
695
  if (!key) {
695
- const tokenSrc = process.env[MODAL_TOKEN_ENV]
696
- ? `env ${MODAL_TOKEN_ENV} ✅`
696
+ const tokenSrc = resolveModalToken(cfg.wiki)
697
+ ? "env or ~/.pi/agent/vault-mind.env ✅"
697
698
  : modal.apiToken
698
- ? "config (set PVM_API_TOKEN env to override)"
699
- : "❌ none (set PVM_API_TOKEN env)";
699
+ ? "config (set PVM_API_TOKEN env/dotenv to override)"
700
+ : "❌ none (set PVM_API_TOKEN env or ~/.pi/agent/vault-mind.env)";
701
+ const derivedUrl = resolveBaseUrl(cfg.wiki);
700
702
  const lines = [
701
703
  "**Modal Config:**",
702
704
  "",
703
- ` baseUrl: ${modal.baseUrl || "not set"}`,
705
+ ` workspace: ${resolveWorkspace(cfg.wiki) || "(not set)"}`,
706
+ ` baseUrl: ${modal.baseUrl || (derivedUrl ? `${derivedUrl} (derived)` : "❌ not set")}`,
704
707
  ` model: ${modal.model || "(default embeddinggemma)"}`,
705
708
  ` dim: ${modal.dim ?? "(native)"}`,
706
709
  ` token: ${tokenSrc}`,
@@ -837,16 +840,18 @@ const handleModal = async (args, ctx, pi) => {
837
840
  switch (sub) {
838
841
  case "status": {
839
842
  const modal = cfg.wiki.embedding.modal;
840
- const tokenSrc = process.env[MODAL_TOKEN_ENV]
841
- ? `env ${MODAL_TOKEN_ENV} ✅`
843
+ const tokenSrc = resolveModalToken(cfg.wiki)
844
+ ? "env or ~/.pi/agent/vault-mind.env ✅"
842
845
  : modal?.apiToken
843
846
  ? "config"
844
847
  : "❌ none";
848
+ const derivedUrl = resolveBaseUrl(cfg.wiki);
845
849
  const lines = [
846
850
  "**Modal Status**",
847
851
  "",
848
852
  `Configured: ${isModalConfigured(cfg.wiki) ? "✅" : "❌"}`,
849
- ` baseUrl: ${modal?.baseUrl || "(not set)"}`,
853
+ ` workspace: ${resolveWorkspace(cfg.wiki) || "(not set)"}`,
854
+ ` baseUrl: ${modal?.baseUrl || (derivedUrl ? `${derivedUrl} (derived)` : "(not set)")}`,
850
855
  ` model: ${modal?.model || "(default embeddinggemma)"}`,
851
856
  ` dim: ${modal?.dim ?? "(native)"}`,
852
857
  ` token: ${tokenSrc}`,
@@ -928,6 +933,78 @@ const handleModal = async (args, ctx, pi) => {
928
933
  }
929
934
  return;
930
935
  }
936
+ case "auto": {
937
+ const cfgPath = findConfig(ctx.cwd).project;
938
+ if (!cfgPath) {
939
+ ctx.ui.notify("No project config found. Run /wiki init first.", "error");
940
+ return;
941
+ }
942
+ const workspace = resolveWorkspace(cfg.wiki);
943
+ if (!workspace && !cfg.wiki.embedding.modal?.baseUrl) {
944
+ ctx.ui.notify("Modal workspace or baseUrl not configured. Run:\n /wiki modal config workspace <name>", "error");
945
+ }
946
+ const token = resolveModalToken(cfg.wiki);
947
+ if (!token) {
948
+ ctx.ui.notify("Modal token not found. Set PVM_API_TOKEN env or write it to ~/.pi/agent/vault-mind.env", "error");
949
+ return;
950
+ }
951
+ ctx.ui.notify("🔄 Auto-configuring Modal...", "info");
952
+ const client = createModalClient(cfg.wiki);
953
+ if (!client) {
954
+ ctx.ui.notify("Could not create Modal client (missing workspace/baseUrl or token).", "error");
955
+ return;
956
+ }
957
+ let health;
958
+ let models;
959
+ try {
960
+ health = await client.health();
961
+ ctx.ui.notify(`✅ Modal reachable (default model: ${health.default_model})`, "info");
962
+ }
963
+ catch (err) {
964
+ ctx.ui.notify(`❌ Modal health check failed: ${err.message}`, "error");
965
+ return;
966
+ }
967
+ try {
968
+ models = await client.models();
969
+ }
970
+ catch (err) {
971
+ ctx.ui.notify(`❌ Could not fetch model registry: ${err.message}`, "error");
972
+ return;
973
+ }
974
+ const defaultModel = models.default || health.default_model || "embeddinggemma";
975
+ const obj = readProjectConfig(cfgPath);
976
+ const wiki = obj.wiki || {};
977
+ const emb = wiki.embedding || {};
978
+ emb.provider = "modal";
979
+ const modalSec = emb.modal || {};
980
+ if (workspace)
981
+ modalSec.workspace = workspace;
982
+ modalSec.model = defaultModel;
983
+ if (models.default_dim != null)
984
+ modalSec.dim = models.default_dim;
985
+ modalSec.fallback = modalSec.fallback || {
986
+ enabled: true,
987
+ provider: "ollama",
988
+ };
989
+ modalSec.sync = modalSec.sync || {
990
+ autoSync: false,
991
+ autoSyncIntervalMs: 300000,
992
+ };
993
+ emb.modal = modalSec;
994
+ wiki.embedding = emb;
995
+ obj.wiki = wiki;
996
+ writeProjectConfig(cfgPath, obj);
997
+ const lines = [
998
+ "✅ Modal auto-configured:",
999
+ ` baseUrl: ${resolveBaseUrl(cfg.wiki)}`,
1000
+ ` model: ${defaultModel}`,
1001
+ ` dim: ${models.default_dim ?? "native"}`,
1002
+ "",
1003
+ "Run /wiki modal status to verify.",
1004
+ ];
1005
+ ctx.ui.notify(lines.join("\n"), "info");
1006
+ return;
1007
+ }
931
1008
  case "migrate": {
932
1009
  const newModel = parts[1];
933
1010
  if (!newModel) {
@@ -1133,7 +1210,7 @@ const handleWatcher = async (args, ctx, pi) => {
1133
1210
  };
1134
1211
  // ── Setup ────────────────────────────────────────────────────────────────────
1135
1212
  const handleSetup = async (args, ctx) => {
1136
- // Parse optional CLI-style args: --vault <path> --provider <name> --model <name>
1213
+ // Parse optional CLI-style args: --vault <path> --provider <name> --model <name> --workspace <name>
1137
1214
  const cliArgs = {};
1138
1215
  const parts = args.trim().split(/\s+--/);
1139
1216
  for (const part of parts) {
@@ -1147,8 +1224,10 @@ const handleSetup = async (args, ctx) => {
1147
1224
  cliArgs.provider = trimmed.slice(9).trim();
1148
1225
  else if (trimmed.startsWith("model "))
1149
1226
  cliArgs.model = trimmed.slice(6).trim();
1227
+ else if (trimmed.startsWith("workspace "))
1228
+ cliArgs.workspace = trimmed.slice(10).trim();
1150
1229
  }
1151
- const hasCliArgs = cliArgs.vault || cliArgs.provider || cliArgs.model;
1230
+ const hasCliArgs = cliArgs.vault || cliArgs.provider || cliArgs.model || cliArgs.workspace;
1152
1231
  await setupWizard(ctx, hasCliArgs ? cliArgs : undefined);
1153
1232
  };
1154
1233
  // ── Main /wiki command ───────────────────────────────────────────────────────
@@ -12,13 +12,25 @@ import { ModalEmbeddingClient } from "./modal-client.js";
12
12
  import type { WikiConfig } from "./types.js";
13
13
  /** Env var name for the Modal bearer token (preferred over config). */
14
14
  export declare const MODAL_TOKEN_ENV = "PVM_API_TOKEN";
15
+ /** Dotenv-style fallback path for the Modal token. */
16
+ export declare const MODAL_TOKEN_ENV_PATH: string;
17
+ /** Canonical deployed Modal app name. */
18
+ export declare const MODAL_APP_NAME = "pi-vault-mind-embed";
19
+ /** Derive the Modal service URL from a workspace slug and canonical app name. */
20
+ export declare const modalUrl: (workspace: string) => string;
15
21
  /**
16
- * Resolve the Modal API token. `PVM_API_TOKEN` env always wins and is
17
- * preferred; config `wiki.embedding.modal.apiToken` is a fallback. Never log
18
- * the resolved token.
22
+ * Resolve the Modal API token. Resolution order:
23
+ * 1. `PVM_API_TOKEN` env var
24
+ * 2. `~/.pi/agent/vault-mind.env`
25
+ * 3. `wiki.embedding.modal.apiToken` in config
26
+ * Never log the resolved token.
19
27
  */
20
28
  export declare const resolveModalToken: (cfg: WikiConfig) => string | undefined;
21
- /** True when Modal is usable: a base URL and a resolvable token are present. */
29
+ /** Resolve the explicit baseUrl or derive it from workspace. */
30
+ export declare const resolveBaseUrl: (cfg: WikiConfig) => string | undefined;
31
+ /** Resolve the configured workspace slug, if any. */
32
+ export declare const resolveWorkspace: (cfg: WikiConfig) => string | undefined;
33
+ /** True when Modal is usable: a base URL (explicit or derived) and a resolvable token are present. */
22
34
  export declare const isModalConfigured: (cfg: WikiConfig) => boolean;
23
35
  /**
24
36
  * Build a `ModalEmbeddingClient` from config. Returns null when Modal is not
@@ -8,29 +8,82 @@
8
8
  *
9
9
  * No HTTP is reimplemented here — `ModalEmbeddingClient` owns that.
10
10
  */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
11
13
  import { ModalEmbeddingClient } from "./modal-client.js";
12
14
  /** Env var name for the Modal bearer token (preferred over config). */
13
15
  export const MODAL_TOKEN_ENV = "PVM_API_TOKEN";
16
+ /** Dotenv-style fallback path for the Modal token. */
17
+ export const MODAL_TOKEN_ENV_PATH = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent", "vault-mind.env");
18
+ /** Canonical deployed Modal app name. */
19
+ export const MODAL_APP_NAME = "pi-vault-mind-embed";
14
20
  /**
15
- * Resolve the Modal API token. `PVM_API_TOKEN` env always wins and is
16
- * preferred; config `wiki.embedding.modal.apiToken` is a fallback. Never log
17
- * the resolved token.
21
+ * Read a dotenv-style file and return a record of KEY=VALUE pairs.
22
+ * Ignores comments, blank lines, and unquoted values.
18
23
  */
19
- export const resolveModalToken = (cfg) => process.env[MODAL_TOKEN_ENV] || cfg.embedding.modal?.apiToken;
20
- /** True when Modal is usable: a base URL and a resolvable token are present. */
21
- export const isModalConfigured = (cfg) => cfg.embedding.provider === "modal" && !!cfg.embedding.modal?.baseUrl && !!resolveModalToken(cfg);
24
+ const readDotEnv = (filePath) => {
25
+ if (!fs.existsSync(filePath))
26
+ return {};
27
+ const out = {};
28
+ for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith("#"))
31
+ continue;
32
+ const eq = trimmed.indexOf("=");
33
+ if (eq === -1)
34
+ continue;
35
+ const key = trimmed.slice(0, eq).trim();
36
+ let value = trimmed.slice(eq + 1).trim();
37
+ if ((value.startsWith('"') && value.endsWith('"')) ||
38
+ (value.startsWith("'") && value.endsWith("'"))) {
39
+ value = value.slice(1, -1);
40
+ }
41
+ out[key] = value;
42
+ }
43
+ return out;
44
+ };
45
+ /** Derive the Modal service URL from a workspace slug and canonical app name. */
46
+ export const modalUrl = (workspace) => `https://${workspace}--${MODAL_APP_NAME}-embeddingservice-fastapi-app.modal.run`;
47
+ /**
48
+ * Resolve the Modal API token. Resolution order:
49
+ * 1. `PVM_API_TOKEN` env var
50
+ * 2. `~/.pi/agent/vault-mind.env`
51
+ * 3. `wiki.embedding.modal.apiToken` in config
52
+ * Never log the resolved token.
53
+ */
54
+ export const resolveModalToken = (cfg) => {
55
+ if (process.env[MODAL_TOKEN_ENV])
56
+ return process.env[MODAL_TOKEN_ENV];
57
+ const dotenv = readDotEnv(MODAL_TOKEN_ENV_PATH);
58
+ if (dotenv[MODAL_TOKEN_ENV])
59
+ return dotenv[MODAL_TOKEN_ENV];
60
+ return cfg.embedding.modal?.apiToken;
61
+ };
62
+ /** Resolve the explicit baseUrl or derive it from workspace. */
63
+ export const resolveBaseUrl = (cfg) => {
64
+ const modal = cfg.embedding.modal;
65
+ if (modal?.baseUrl)
66
+ return modal.baseUrl;
67
+ if (modal?.workspace)
68
+ return modalUrl(modal.workspace);
69
+ return undefined;
70
+ };
71
+ /** Resolve the configured workspace slug, if any. */
72
+ export const resolveWorkspace = (cfg) => cfg.embedding.modal?.workspace;
73
+ /** True when Modal is usable: a base URL (explicit or derived) and a resolvable token are present. */
74
+ export const isModalConfigured = (cfg) => cfg.embedding.provider === "modal" && !!resolveBaseUrl(cfg) && !!resolveModalToken(cfg);
22
75
  /**
23
76
  * Build a `ModalEmbeddingClient` from config. Returns null when Modal is not
24
77
  * configured (no base URL / token) so callers can degrade gracefully.
25
78
  */
26
79
  export const createModalClient = (cfg) => {
27
- const modal = cfg.embedding.modal;
28
- if (!modal?.baseUrl)
80
+ const baseUrl = resolveBaseUrl(cfg);
81
+ if (!baseUrl)
29
82
  return null;
30
83
  const apiToken = resolveModalToken(cfg);
31
84
  if (!apiToken)
32
85
  return null;
33
- const clientCfg = { baseUrl: modal.baseUrl, apiToken };
86
+ const clientCfg = { baseUrl, apiToken };
34
87
  return new ModalEmbeddingClient(clientCfg);
35
88
  };
36
89
  /**
@@ -5,12 +5,14 @@ export declare const setupWizard: (ctx: ExtensionContext, cliArgs?: {
5
5
  vault?: string;
6
6
  provider?: string;
7
7
  model?: string;
8
+ workspace?: string;
8
9
  }) => Promise<void>;
9
10
  /**
10
11
  * Interactive Modal embedding configuration + "Test connection" action.
11
- * Walks base URL, canonical model, dim, offline fallback, and auto-sync
12
- * (off by default). Token is read from `PVM_API_TOKEN` env (preferred) — not
13
- * collected here; only an optional config fallback is offered.
12
+ * Walks workspace/base URL, canonical model, dim, offline fallback, and auto-sync
13
+ * (off by default). Token is read from `PVM_API_TOKEN` env or
14
+ * `~/.pi/agent/vault-mind.env` (preferred) — not collected here; only an optional
15
+ * config fallback is offered.
14
16
  */
15
17
  export declare const configureModalWizard: (ctx: ExtensionContext) => Promise<void>;
16
18
  export declare const openSettingsDashboard: (ctx: ExtensionContext) => Promise<void>;
@@ -1,8 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { testOllamaConnection } from "./lance.js";
4
- import { MODAL_TOKEN_ENV, createModalClient } from "./modal-config.js";
5
- import { GLOBAL_CONFIG_PATH, collectionNames, findConfig, loadConfig, } from "./utils.js";
4
+ import { MODAL_TOKEN_ENV, createModalClient, modalUrl, resolveModalToken } from "./modal-config.js";
5
+ import { GLOBAL_CONFIG_PATH, collectionNames, findConfig, loadConfig, shrinkHome, } from "./utils.js";
6
6
  export const createCollectionWizard = async (ctx) => {
7
7
  const { project: cfgPath } = findConfig(ctx.cwd);
8
8
  if (!cfgPath) {
@@ -86,7 +86,7 @@ export const createInjectorWizard = async (ctx) => {
86
86
  export const setupWizard = async (ctx, cliArgs) => {
87
87
  const existingGlobal = fs.existsSync(GLOBAL_CONFIG_PATH);
88
88
  // ── Non-interactive / CLI mode ──────────────────────────────────────────
89
- if (cliArgs && (cliArgs.vault || cliArgs.provider)) {
89
+ if (cliArgs && (cliArgs.vault || cliArgs.provider || cliArgs.workspace)) {
90
90
  const config = existingGlobal
91
91
  ? JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, "utf-8"))
92
92
  : { wiki: { embedding: {}, vaults: {} } };
@@ -94,7 +94,7 @@ export const setupWizard = async (ctx, cliArgs) => {
94
94
  config.wiki.embedding = config.wiki.embedding || {};
95
95
  config.wiki.vaults = config.wiki.vaults || {};
96
96
  if (cliArgs.vault) {
97
- config.wiki.vaults.default = { path: cliArgs.vault };
97
+ config.wiki.vaults.default = { path: shrinkHome(cliArgs.vault), autoSync: true };
98
98
  }
99
99
  if (cliArgs.provider && ["ollama", "transformers", "modal"].includes(cliArgs.provider)) {
100
100
  config.wiki.embedding.provider = cliArgs.provider;
@@ -102,6 +102,10 @@ export const setupWizard = async (ctx, cliArgs) => {
102
102
  if (cliArgs.model) {
103
103
  config.wiki.embedding.ollamaModel = cliArgs.model;
104
104
  }
105
+ if (cliArgs.workspace) {
106
+ config.wiki.embedding.modal = config.wiki.embedding.modal || {};
107
+ config.wiki.embedding.modal.workspace = cliArgs.workspace;
108
+ }
105
109
  const dir = path.dirname(GLOBAL_CONFIG_PATH);
106
110
  if (!fs.existsSync(dir))
107
111
  fs.mkdirSync(dir, { recursive: true });
@@ -113,6 +117,8 @@ export const setupWizard = async (ctx, cliArgs) => {
113
117
  lines.push(` Embedding: ${cliArgs.provider}`);
114
118
  if (cliArgs.model)
115
119
  lines.push(` Model: ${cliArgs.model}`);
120
+ if (cliArgs.workspace)
121
+ lines.push(` Modal workspace: ${cliArgs.workspace}`);
116
122
  ctx.ui.notify(lines.join("\n"), "info");
117
123
  return;
118
124
  }
@@ -163,8 +169,8 @@ export const setupWizard = async (ctx, cliArgs) => {
163
169
  return;
164
170
  }
165
171
  if (provider.startsWith("modal")) {
166
- // Delegate to the dedicated Modal wizard (base URL, model, dim, fallback,
167
- // auto-sync, test connection) and return — it writes the project config.
172
+ // Delegate to the dedicated Modal wizard (workspace/baseUrl, model, dim,
173
+ // fallback, auto-sync, test connection) and return — it writes the project config.
168
174
  await configureModalWizard(ctx);
169
175
  return;
170
176
  }
@@ -193,7 +199,7 @@ export const setupWizard = async (ctx, cliArgs) => {
193
199
  config.wiki.embedding.ollamaModel = ollamaModel;
194
200
  config.wiki.embedding.ollamaHost = config.wiki.embedding.ollamaHost || "http://127.0.0.1:11434";
195
201
  }
196
- config.wiki.vaults.default = { path: vaultPath, autoSync: true };
202
+ config.wiki.vaults.default = { path: shrinkHome(vaultPath), autoSync: true };
197
203
  config.wiki.graph = config.wiki.graph || { enabled: true, canvasSync: true };
198
204
  config.wiki.ftsEnabled = config.wiki.ftsEnabled !== false;
199
205
  const dir = path.dirname(GLOBAL_CONFIG_PATH);
@@ -215,9 +221,10 @@ export const setupWizard = async (ctx, cliArgs) => {
215
221
  };
216
222
  /**
217
223
  * Interactive Modal embedding configuration + "Test connection" action.
218
- * Walks base URL, canonical model, dim, offline fallback, and auto-sync
219
- * (off by default). Token is read from `PVM_API_TOKEN` env (preferred) — not
220
- * collected here; only an optional config fallback is offered.
224
+ * Walks workspace/base URL, canonical model, dim, offline fallback, and auto-sync
225
+ * (off by default). Token is read from `PVM_API_TOKEN` env or
226
+ * `~/.pi/agent/vault-mind.env` (preferred) — not collected here; only an optional
227
+ * config fallback is offered.
221
228
  */
222
229
  export const configureModalWizard = async (ctx) => {
223
230
  const { project: cfgPath } = findConfig(ctx.cwd);
@@ -231,10 +238,20 @@ export const configureModalWizard = async (ctx) => {
231
238
  }
232
239
  const cfg = loadConfig(ctx.cwd);
233
240
  const modal = cfg.wiki.embedding.modal ?? {};
234
- const baseUrl = await ctx.ui.input("Modal base URL (deployed ASGI app):", modal.baseUrl ||
235
- "https://<workspace>--pi-vault-mind-embed-embeddingservice-fastapi-app.modal.run");
236
- if (!baseUrl)
241
+ // Prefer workspace input; fall back to full URL if user already has one.
242
+ const workspace = await ctx.ui.input("Modal workspace slug (blank if you want to enter the full URL):", modal.workspace || "");
243
+ if (workspace === undefined)
237
244
  return;
245
+ let baseUrl;
246
+ if (workspace) {
247
+ baseUrl = modalUrl(workspace);
248
+ }
249
+ else {
250
+ baseUrl = await ctx.ui.input("Modal base URL (deployed ASGI app):", modal.baseUrl ||
251
+ "https://<workspace>--pi-vault-mind-embed-embeddingservice-fastapi-app.modal.run");
252
+ if (!baseUrl)
253
+ return;
254
+ }
238
255
  const model = await ctx.ui.input("Canonical embedder model:", modal.model || "embeddinggemma");
239
256
  if (model === undefined)
240
257
  return;
@@ -262,6 +279,11 @@ export const configureModalWizard = async (ctx) => {
262
279
  const intervalStr = await ctx.ui.input("Auto-sync interval (ms):", "300000");
263
280
  intervalMs = Number.parseInt(intervalStr || "300000", 10) || 300000;
264
281
  }
282
+ // Token guidance (env/dotenv preferred; config apiToken is a fallback)
283
+ const tokenEnv = resolveModalToken(loadConfig(ctx.cwd).wiki);
284
+ if (!tokenEnv) {
285
+ ctx.ui.notify(`⚠️ No ${MODAL_TOKEN_ENV} env var or ~/.pi/agent/vault-mind.env found. Set it in your shell:\n export ${MODAL_TOKEN_ENV}=<bearer token>\nOr write it to ~/.pi/agent/vault-mind.env. (Env/dotenv preferred; config apiToken is a fallback only.)`, "warning");
286
+ }
265
287
  // Persist
266
288
  const existing = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
267
289
  existing.wiki = existing.wiki || {};
@@ -269,6 +291,7 @@ export const configureModalWizard = async (ctx) => {
269
291
  existing.wiki.embedding.provider = "modal";
270
292
  existing.wiki.embedding.modal = {
271
293
  ...(existing.wiki.embedding.modal || {}),
294
+ ...(workspace ? { workspace } : {}),
272
295
  baseUrl: baseUrl.replace(/\/$/, ""),
273
296
  model: model || "embeddinggemma",
274
297
  ...(dim != null ? { dim } : {}),
@@ -280,11 +303,6 @@ export const configureModalWizard = async (ctx) => {
280
303
  },
281
304
  };
282
305
  fs.writeFileSync(cfgPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
283
- // Token guidance + optional config fallback (env always wins)
284
- const tokenEnv = process.env[MODAL_TOKEN_ENV];
285
- if (!tokenEnv) {
286
- ctx.ui.notify(`⚠️ No ${MODAL_TOKEN_ENV} env var detected. Set it in your shell:\n export ${MODAL_TOKEN_ENV}=<bearer token>\n(Env is preferred; config apiToken is a fallback only.)`, "warning");
287
- }
288
306
  // Test connection against /health
289
307
  const test = await ctx.ui.confirm("Test connection", "Call /health now to verify the Modal service?");
290
308
  if (test) {
@@ -24,7 +24,12 @@ export interface InjectorDef {
24
24
  */
25
25
  export interface ModalEmbeddingConfig {
26
26
  /** Base URL of the deployed Modal ASGI app (no trailing slash needed). */
27
- baseUrl: string;
27
+ baseUrl?: string;
28
+ /**
29
+ * Modal workspace slug. When set, the extension derives `baseUrl` from the
30
+ * canonical app name if `baseUrl` itself is not set.
31
+ */
32
+ workspace?: string;
28
33
  /**
29
34
  * Bearer token matching the `pi-vault-mind-auth` Modal secret. Prefer
30
35
  * setting via the `PVM_API_TOKEN` env var over committing it to config;
@@ -4,6 +4,10 @@ export declare const CONFIG_FILES: string[];
4
4
  export declare const GLOBAL_CONFIG_DIR: string;
5
5
  export declare const GLOBAL_CONFIG_PATH: string;
6
6
  export declare const EXT_ROOT: string;
7
+ /** Expand a leading `~` to the user's home directory. */
8
+ export declare const expandHome: (p: string) => string;
9
+ /** Store a path as `~/...` when it lives under the user's home directory. */
10
+ export declare const shrinkHome: (p: string) => string;
7
11
  export declare const resolvePath: (cwd: string, p: string) => string;
8
12
  export declare const ensureDir: (filePath: string) => void;
9
13
  export declare const findConfig: (cwd: string) => {
package/dist/src/utils.js CHANGED
@@ -6,6 +6,22 @@ export const CONFIG_FILES = ["pi-vault-mind.config.json", ".pi/vault-mind.config
6
6
  export const GLOBAL_CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent");
7
7
  export const GLOBAL_CONFIG_PATH = path.join(GLOBAL_CONFIG_DIR, "vault-mind.config.json");
8
8
  export const EXT_ROOT = path.join(import.meta.dirname, "..");
9
+ /** Expand a leading `~` to the user's home directory. */
10
+ export const expandHome = (p) => {
11
+ if (p.startsWith("~/") || p === "~") {
12
+ const home = process.env.HOME || process.env.USERPROFILE || "";
13
+ return home ? path.join(home, p.slice(1)) : p;
14
+ }
15
+ return p;
16
+ };
17
+ /** Store a path as `~/...` when it lives under the user's home directory. */
18
+ export const shrinkHome = (p) => {
19
+ const home = process.env.HOME || process.env.USERPROFILE || "";
20
+ if (home && p.startsWith(home)) {
21
+ return `~${p.slice(home.length)}`;
22
+ }
23
+ return p;
24
+ };
9
25
  export const resolvePath = (cwd, p) => path.isAbsolute(p) ? p : path.join(cwd, p);
10
26
  export const ensureDir = (filePath) => {
11
27
  const dir = path.dirname(filePath);
@@ -38,6 +54,15 @@ const readConfigFile = (p) => {
38
54
  return {};
39
55
  }
40
56
  };
57
+ /** Expand `~` in all vault paths after loading a config layer. */
58
+ const expandVaultPaths = (layer) => {
59
+ if (!layer.wiki?.vaults)
60
+ return;
61
+ for (const vault of Object.values(layer.wiki.vaults)) {
62
+ if (vault.path)
63
+ vault.path = expandHome(vault.path);
64
+ }
65
+ };
41
66
  const mergeConfigLayer = (base, layer, cwd) => {
42
67
  const rawLayer = layer;
43
68
  const merged = {
@@ -80,6 +105,8 @@ export const loadConfig = (cwd) => {
80
105
  const paths = findConfig(cwd);
81
106
  const globalCfg = readConfigFile(paths.global);
82
107
  const projectCfg = readConfigFile(paths.project);
108
+ expandVaultPaths(globalCfg);
109
+ expandVaultPaths(projectCfg);
83
110
  let merged = mergeConfigLayer(DEFAULT_CONFIG, globalCfg, cwd);
84
111
  merged = mergeConfigLayer(merged, projectCfg, cwd);
85
112
  return merged;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
+ import { expandHome } from "./utils.js";
19
20
  // ── Constants ────────────────────────────────────────────────────────────────────
20
21
  const MARKER_REGEX = /@agent-(\w+)(?::(\S+))?\s*(.*)/;
21
22
  const WRITEBACK_MARKER_REGEX = /<!--\s*pi-dispatch:(\S+)\s*-->/;
@@ -231,7 +232,7 @@ export function startWatcher(pi, vaults, state) {
231
232
  return;
232
233
  }
233
234
  for (const [name, vault] of vaultEntries) {
234
- const vaultPath = vault.path;
235
+ const vaultPath = expandHome(vault.path);
235
236
  if (!fs.existsSync(vaultPath)) {
236
237
  console.warn(`[pi-vault-mind] Vault "${name}" path does not exist: ${vaultPath}`);
237
238
  continue;
@@ -1,6 +1,6 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
- import { MODAL_TOKEN_ENV, createModalClient, isModalConfigured, namespacedTableName, resolveDim, resolveModalToken, resolveModel, } from "../src/modal-config.js";
3
+ import { MODAL_TOKEN_ENV, MODAL_TOKEN_ENV_PATH, createModalClient, isModalConfigured, modalUrl, namespacedTableName, resolveBaseUrl, resolveDim, resolveModalToken, resolveModel, resolveWorkspace, } from "../src/modal-config.js";
4
4
  const wiki = (over = {}) => ({
5
5
  dataDir: ".lancedb",
6
6
  embedding: { provider: "modal" },
@@ -79,6 +79,59 @@ describe("modal-config", () => {
79
79
  assert.equal(resolveDim(cfg, "special"), 128);
80
80
  assert.equal(resolveDim(wiki()), undefined);
81
81
  });
82
+ it("resolveModalToken: dotenv file is a fallback when env is unset", async () => {
83
+ delete process.env[MODAL_TOKEN_ENV];
84
+ const fs = await import("node:fs");
85
+ const path = await import("node:path");
86
+ const dir = path.dirname(MODAL_TOKEN_ENV_PATH);
87
+ if (!fs.existsSync(dir))
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ fs.writeFileSync(MODAL_TOKEN_ENV_PATH, 'PVM_API_TOKEN="dotenv-token"\n', "utf-8");
90
+ try {
91
+ const cfg = wiki({ embedding: { provider: "modal", modal: { baseUrl: "https://x" } } });
92
+ assert.equal(resolveModalToken(cfg), "dotenv-token");
93
+ }
94
+ finally {
95
+ fs.rmSync(MODAL_TOKEN_ENV_PATH, { force: true });
96
+ }
97
+ });
98
+ it("modalUrl derives the canonical service URL from workspace", () => {
99
+ assert.equal(modalUrl("kylebrodeur"), "https://kylebrodeur--pi-vault-mind-embed-embeddingservice-fastapi-app.modal.run");
100
+ });
101
+ it("resolveBaseUrl: explicit baseUrl wins over derived workspace URL", () => {
102
+ const cfg = wiki({
103
+ embedding: {
104
+ provider: "modal",
105
+ modal: {
106
+ baseUrl: "https://explicit.example.com",
107
+ workspace: "kylebrodeur",
108
+ },
109
+ },
110
+ });
111
+ assert.equal(resolveBaseUrl(cfg), "https://explicit.example.com");
112
+ });
113
+ it("resolveBaseUrl: derives URL from workspace when baseUrl is missing", () => {
114
+ const cfg = wiki({
115
+ embedding: { provider: "modal", modal: { workspace: "kylebrodeur" } },
116
+ });
117
+ assert.equal(resolveBaseUrl(cfg), modalUrl("kylebrodeur"));
118
+ });
119
+ it("resolveWorkspace returns the configured workspace slug", () => {
120
+ const cfg = wiki({
121
+ embedding: { provider: "modal", modal: { workspace: "kylebrodeur" } },
122
+ });
123
+ assert.equal(resolveWorkspace(cfg), "kylebrodeur");
124
+ assert.equal(resolveWorkspace(wiki()), undefined);
125
+ });
126
+ it("createModalClient: builds a client from workspace-derived URL", () => {
127
+ process.env[MODAL_TOKEN_ENV] = "tok";
128
+ const c = createModalClient(wiki({ embedding: { provider: "modal", modal: { workspace: "kylebrodeur" } } }));
129
+ assert.ok(c);
130
+ });
131
+ it("isModalConfigured: true when workspace + token are present (no explicit baseUrl)", () => {
132
+ process.env[MODAL_TOKEN_ENV] = "tok";
133
+ assert.equal(isModalConfigured(wiki({ embedding: { provider: "modal", modal: { workspace: "kylebrodeur" } } })), true);
134
+ });
82
135
  it("namespacedTableName mirrors the server's ADR-3 scheme", () => {
83
136
  assert.equal(namespacedTableName("main", "embeddinggemma", 768), "col_main__embeddinggemma__768");
84
137
  assert.equal(namespacedTableName("ctx", "minilm-l6", 384), "col_ctx__minilm-l6__384");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vault-mind",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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",