pi-vault-mind 0.7.4 → 0.7.5

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.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { Container, SelectList, Text } from "@earendil-works/pi-tui";
@@ -825,7 +826,7 @@ const handleModalConfig = async (args, ctx) => {
825
826
  return;
826
827
  }
827
828
  case "token": {
828
- ctx.ui.notify(`Token resolution: env ${MODAL_TOKEN_ENV} is preferred (never committed).\nSet it in your shell: export ${MODAL_TOKEN_ENV}=...\nConfig wiki.embedding.modal.apiToken is a fallback only.`, "info");
829
+ await handleModalToken(ctx, parts[1]);
829
830
  return;
830
831
  }
831
832
  default:
@@ -1230,6 +1231,55 @@ const handleSetup = async (args, ctx) => {
1230
1231
  const hasCliArgs = cliArgs.vault || cliArgs.provider || cliArgs.model || cliArgs.workspace;
1231
1232
  await setupWizard(ctx, hasCliArgs ? cliArgs : undefined);
1232
1233
  };
1234
+ // ── /wiki modal token ────────────────────────────────────────────────────────
1235
+ /** Dotenv path where the Modal token is persisted. */
1236
+ const MODAL_TOKEN_ENV_PATH = path.join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "agent", "vault-mind.env");
1237
+ const writeTokenEnv = (token) => {
1238
+ const dir = path.dirname(MODAL_TOKEN_ENV_PATH);
1239
+ if (!fs.existsSync(dir))
1240
+ fs.mkdirSync(dir, { recursive: true });
1241
+ fs.writeFileSync(MODAL_TOKEN_ENV_PATH, `PVM_API_TOKEN="${token}"\n`, { mode: 0o600 });
1242
+ };
1243
+ const fetchTokenFrom1Password = () => {
1244
+ try {
1245
+ return execFileSync("op", ["item", "get", "pi-vault-mind-auth", "--reveal", "--field", "password"], {
1246
+ encoding: "utf-8",
1247
+ stdio: ["ignore", "pipe", "pipe"],
1248
+ }).trim();
1249
+ }
1250
+ catch {
1251
+ return null;
1252
+ }
1253
+ };
1254
+ const handleModalToken = async (ctx, cliToken) => {
1255
+ if (cliToken) {
1256
+ writeTokenEnv(cliToken);
1257
+ ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1258
+ return;
1259
+ }
1260
+ const envToken = process.env[MODAL_TOKEN_ENV];
1261
+ if (envToken) {
1262
+ ctx.ui.notify("PVM_API_TOKEN is already exported in env. Skipping write so we don't clobber it.", "warning");
1263
+ return;
1264
+ }
1265
+ const tokenFrom1Pass = fetchTokenFrom1Password();
1266
+ if (tokenFrom1Pass) {
1267
+ writeTokenEnv(tokenFrom1Pass);
1268
+ ctx.ui.notify(`✅ Fetched token from 1Password item "pi-vault-mind-auth" and wrote it to ${MODAL_TOKEN_ENV_PATH}`, "info");
1269
+ return;
1270
+ }
1271
+ if (!ctx.hasUI) {
1272
+ ctx.ui.notify("No 1Password token found and no TUI available. Use:\n /wiki modal token <token>", "error");
1273
+ return;
1274
+ }
1275
+ const token = await ctx.ui.input("Modal API token:", "paste bearer token here (will be written to ~/.pi/agent/vault-mind.env)");
1276
+ if (!token) {
1277
+ ctx.ui.notify("Token setup cancelled.", "warning");
1278
+ return;
1279
+ }
1280
+ writeTokenEnv(token);
1281
+ ctx.ui.notify(`✅ Wrote token to ${MODAL_TOKEN_ENV_PATH}`, "info");
1282
+ };
1233
1283
  // ── Main /wiki command ───────────────────────────────────────────────────────
1234
1284
  export const registerCommands = (pi) => {
1235
1285
  pi.registerCommand("wiki", {
@@ -1287,7 +1337,7 @@ export const registerCommands = (pi) => {
1287
1337
  .map((c) => ({ label: c, value: c, description: `reindex ${c}` }));
1288
1338
  }
1289
1339
  if (subcommand === "modal") {
1290
- return ["status", "config", "sync", "jobs", "migrate"]
1340
+ return ["status", "config", "auto", "token", "sync", "jobs", "migrate"]
1291
1341
  .filter((c) => c.startsWith(prefix))
1292
1342
  .map((c) => ({ label: c, value: c, description: `modal ${c}` }));
1293
1343
  }
@@ -1297,7 +1347,7 @@ export const registerCommands = (pi) => {
1297
1347
  .map((c) => ({ label: c, value: c, description: `watcher ${c}` }));
1298
1348
  }
1299
1349
  if (subcommand === "setup") {
1300
- return ["--vault", "--provider", "--model"]
1350
+ return ["--vault", "--provider", "--model", "--workspace"]
1301
1351
  .filter((c) => c.startsWith(prefix))
1302
1352
  .map((c) => ({ label: c, value: c, description: `setup ${c}` }));
1303
1353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vault-mind",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
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",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "skills",
11
+ "scripts",
11
12
  "CHANGELOG.md",
12
13
  "README.md",
13
14
  "LICENSE"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Fetch the Modal API token from 1Password and export or persist it.
3
+ # Usage:
4
+ # ./scripts/fetch-modal-token.sh --export # prints an export line
5
+ # ./scripts/fetch-modal-token.sh --write # writes to ~/.pi/agent/vault-mind.env (default)
6
+ #
7
+ # The 1Password item must be titled "pi-vault-mind-auth" with the token in the
8
+ # password field.
9
+
10
+ set -euo pipefail
11
+
12
+ OP_ITEM="pi-vault-mind-auth"
13
+ OP_FIELD="password"
14
+ ENV_DIR="${HOME}/.pi/agent"
15
+ ENV_FILE="${ENV_DIR}/vault-mind.env"
16
+
17
+ mode="write"
18
+ if [ "${1:-}" = "--export" ]; then
19
+ mode="export"
20
+ elif [ "${1:-}" = "--write" ]; then
21
+ mode="write"
22
+ elif [ -n "${1:-}" ]; then
23
+ echo "Unknown option: $1" >&2
24
+ echo "Usage: $0 [--export | --write]" >&2
25
+ exit 1
26
+ fi
27
+
28
+ if ! command -v op >/dev/null 2>&1; then
29
+ echo "❌ 1Password CLI (op) not found. Install: brew install 1password-cli" >&2
30
+ exit 1
31
+ fi
32
+
33
+ # Check if already exported to avoid clobbering a live session.
34
+ if [ -n "${PVM_API_TOKEN:-}" ]; then
35
+ echo "⚠️ PVM_API_TOKEN is already exported; skipping 1Password fetch." >&2
36
+ if [ "$mode" = "export" ]; then
37
+ echo "export PVM_API_TOKEN=\"${PVM_API_TOKEN}\""
38
+ else
39
+ echo "Token already available in environment."
40
+ fi
41
+ exit 0
42
+ fi
43
+
44
+ # Try to read without explicit account; op will use the default signed-in account.
45
+ TOKEN=$(op item get "$OP_ITEM" --reveal --field "$OP_FIELD" 2>/dev/null) || {
46
+ echo "❌ Could not read 1Password item '$OP_ITEM' field '$OP_FIELD'." >&2
47
+ echo " Make sure you are signed in (op signin) and the item exists." >&2
48
+ exit 1
49
+ }
50
+
51
+ if [ -z "$TOKEN" ]; then
52
+ echo "❌ Token is empty." >&2
53
+ exit 1
54
+ fi
55
+
56
+ if [ "$mode" = "export" ]; then
57
+ echo "export PVM_API_TOKEN=\"${TOKEN}\""
58
+ else
59
+ mkdir -p "$ENV_DIR"
60
+ printf 'PVM_API_TOKEN="%s"\n' "$TOKEN" > "$ENV_FILE"
61
+ chmod 600 "$ENV_FILE"
62
+ echo "✅ Wrote PVM_API_TOKEN to ${ENV_FILE}"
63
+ fi
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live E2E smoke for the Modal embedding service — drives the *shipped* client.
4
+ *
5
+ * This is the TypeScript-client counterpart to `modal/client_example.py`: it
6
+ * imports the compiled `dist/src/modal-client.js` (the exact class the extension
7
+ * ships) and exercises the full HTTP contract against a real deployment, so a
8
+ * pass proves the production client ↔ live server round-trip — not a parallel
9
+ * reimplementation.
10
+ *
11
+ * It is HTTP-only (no LanceDB), so it runs anywhere Node can reach the deploy.
12
+ * The local-store half of sync (export → `.lancedb`) is exercised by the
13
+ * `/wiki modal sync` walkthrough in docs/E2E_MANUAL_TEST.md.
14
+ *
15
+ * Usage:
16
+ * pnpm build # produces dist/src/modal-client.js
17
+ * export PVM_MODAL_URL="https://<workspace>--pi-vault-mind-embed-embeddingservice-fastapi-app.modal.run"
18
+ * export PVM_API_TOKEN="<the pi-vault-mind-auth secret>"
19
+ * pnpm e2e:modal # or: node scripts/modal-e2e-smoke.mjs
20
+ *
21
+ * Optional env:
22
+ * PVM_MODEL embedder key (default: embeddinggemma)
23
+ * PVM_COLLECTION collection for the bulk-job + export (default: unique per run)
24
+ *
25
+ * Exit code 0 = all checks passed, 1 = a check failed or env is missing.
26
+ */
27
+
28
+ import { ModalEmbeddingClient } from "../dist/src/modal-client.js";
29
+
30
+ const URL = process.env.PVM_MODAL_URL?.replace(/\/$/, "");
31
+ const TOKEN = process.env.PVM_API_TOKEN;
32
+ const MODEL = process.env.PVM_MODEL || "embeddinggemma";
33
+ // Unique per run by default: the server-side LanceDB is persistent on the
34
+ // Volume, so a shared/static collection could carry leftover or concurrent
35
+ // rows and break the exact row-count + paging assertions. Override with
36
+ // PVM_COLLECTION to target a known collection.
37
+ const COLLECTION =
38
+ process.env.PVM_COLLECTION || `e2e-smoke-${Math.random().toString(36).slice(2, 9)}`;
39
+
40
+ if (!URL || !TOKEN) {
41
+ console.error(
42
+ "Missing env. Set PVM_MODAL_URL and PVM_API_TOKEN, then re-run.\n" +
43
+ ' export PVM_MODAL_URL="https://<workspace>--pi-vault-mind-embed-embeddingservice-fastapi-app.modal.run"\n' +
44
+ ' export PVM_API_TOKEN="<pi-vault-mind-auth secret>"'
45
+ );
46
+ process.exit(1);
47
+ }
48
+
49
+ const client = new ModalEmbeddingClient({ baseUrl: URL, apiToken: TOKEN });
50
+
51
+ let failures = 0;
52
+ const pass = (msg) => console.log(` ✓ ${msg}`);
53
+ const fail = (msg) => {
54
+ failures++;
55
+ console.log(` ✗ ${msg}`);
56
+ };
57
+ const step = (msg) => console.log(`\n${msg}`);
58
+
59
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
60
+
61
+ async function main() {
62
+ console.log(
63
+ `Modal live E2E smoke\n url: ${URL}\n model: ${MODEL}\n collection: ${COLLECTION}`
64
+ );
65
+
66
+ // 1. health
67
+ step("1. health");
68
+ const health = await client.health();
69
+ health?.ok
70
+ ? pass(`ok, default_model=${health.default_model}`)
71
+ : fail(`unexpected: ${JSON.stringify(health)}`);
72
+
73
+ // 2. models registry — resolve the native dim up front
74
+ step("2. models");
75
+ const reg = await client.models();
76
+ const info = reg.models.find((m) => m.key === MODEL);
77
+ if (!info) {
78
+ fail(`model "${MODEL}" not in registry (have: ${reg.models.map((m) => m.key).join(", ")})`);
79
+ } else {
80
+ pass(`${MODEL}: backend=${info.backend} native_dim=${info.native_dim} enabled=${info.enabled}`);
81
+ }
82
+ const expectedDim = info?.native_dim;
83
+
84
+ // 3. on-demand embed (query)
85
+ step("3. embed (task=query)");
86
+ const emb = await client.embed(["how long do tokens last?"], { model: MODEL, task: "query" });
87
+ const dimOk =
88
+ emb.dim === emb.vectors[0]?.length && (expectedDim == null || emb.dim === expectedDim);
89
+ dimOk
90
+ ? pass(`model=${emb.model} dim=${emb.dim} (matches native + vector length)`)
91
+ : fail(`dim mismatch: dim=${emb.dim} vec.len=${emb.vectors[0]?.length} native=${expectedDim}`);
92
+
93
+ // 4. bulk job → wait → status
94
+ step("4. bulk job submit + wait");
95
+ const records = [
96
+ { id: "e2e-1", text: "JWT tokens expire after one hour.", metadata: { tag: "auth" } },
97
+ { id: "e2e-2", text: "Refresh tokens live for 30 days.", metadata: { tag: "auth" } },
98
+ { id: "e2e-3", text: "Sessions are revoked on password change.", metadata: { tag: "auth" } },
99
+ ];
100
+ const submit = await client.submitJob(COLLECTION, records, { model: MODEL });
101
+ pass(`submitted job_id=${submit.job_id} total=${submit.total}`);
102
+ const final = await client.waitForJob(submit.job_id);
103
+ final.status === "done" && final.processed === records.length
104
+ ? pass(`job done ${final.processed}/${final.total} (model=${final.model} dim=${final.dim})`)
105
+ : fail(
106
+ `job ended status=${final.status} ${final.processed}/${final.total} err=${final.error ?? ""}`
107
+ );
108
+
109
+ // 5. listJobs — the job we just ran should be present
110
+ step("5. listJobs");
111
+ const list = await client.listJobs(10);
112
+ list.jobs.some((j) => j.collection === COLLECTION)
113
+ ? pass(`listed ${list.count} job(s); collection present`)
114
+ : fail(`our collection "${COLLECTION}" not in job list`);
115
+
116
+ // 6. sync collections — the namespace should now exist
117
+ step("6. sync/collections");
118
+ const cols = await client.syncCollections();
119
+ const ns = cols.find((c) => c.collection === COLLECTION && c.model === final.model);
120
+ ns
121
+ ? pass(`namespace ${ns.table} rows=${ns.rows} dim=${ns.dim}`)
122
+ : fail(`namespace for ${COLLECTION}/${final.model} missing`);
123
+
124
+ // 7. export drain — every row carries a vector of the right dim, watermark advances
125
+ step("7. sync/export (drain)");
126
+ const seen = new Map();
127
+ let pages = 0;
128
+ const finalWatermark = await client.exportAll(
129
+ COLLECTION,
130
+ (rows) => {
131
+ pages++;
132
+ for (const r of rows) seen.set(r.id, r);
133
+ },
134
+ { model: final.model, dim: final.dim, limit: 2 }
135
+ );
136
+ const all = [...seen.values()];
137
+ const everyVector =
138
+ all.length > 0 && all.every((r) => Array.isArray(r.vector) && r.vector.length === final.dim);
139
+ const gotAll = all.length === records.length;
140
+ const idsOk = eq([...seen.keys()].sort(), records.map((r) => r.id).sort());
141
+ gotAll && idsOk
142
+ ? pass(
143
+ `drained ${all.length} row(s) over ${pages} page(s); all ids present; watermark=${finalWatermark}`
144
+ )
145
+ : fail(
146
+ `expected ${records.length} ids ${JSON.stringify(records.map((r) => r.id))}, got ${JSON.stringify([...seen.keys()])}`
147
+ );
148
+ everyVector
149
+ ? pass(`every row carries a ${final.dim}-dim vector`)
150
+ : fail("some rows missing a vector or wrong dim");
151
+
152
+ // 8. incremental re-export from the final watermark → nothing new
153
+ step("8. incremental re-export (idempotent)");
154
+ const inc = await client.exportSince(COLLECTION, {
155
+ model: final.model,
156
+ dim: final.dim,
157
+ since: finalWatermark,
158
+ });
159
+ inc.rows.length === 0 && inc.done
160
+ ? pass("re-export from watermark returns 0 rows (idempotent)")
161
+ : fail(`expected 0 new rows, got ${inc.rows.length} (done=${inc.done})`);
162
+
163
+ // summary
164
+ console.log(`\n${failures === 0 ? "✅ ALL CHECKS PASSED" : `❌ ${failures} CHECK(S) FAILED`}`);
165
+ process.exit(failures === 0 ? 0 : 1);
166
+ }
167
+
168
+ main().catch((err) => {
169
+ console.error(`\n❌ smoke aborted: ${err?.message ?? err}`);
170
+ process.exit(1);
171
+ });
@@ -1 +0,0 @@
1
- export {};