pi-vault-mind 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +21 -21
  2. package/dist/src/agent-queue.d.ts +108 -0
  3. package/dist/src/agent-queue.js +225 -0
  4. package/dist/src/auth.d.ts +30 -0
  5. package/dist/src/auth.js +93 -0
  6. package/dist/src/auto-index.d.ts +47 -0
  7. package/dist/src/auto-index.js +122 -0
  8. package/dist/src/commands.js +265 -4
  9. package/dist/src/discover-schema.d.ts +27 -0
  10. package/dist/src/discover-schema.js +255 -0
  11. package/dist/src/index.js +2 -0
  12. package/dist/src/intake.d.ts +4 -0
  13. package/dist/src/intake.js +248 -0
  14. package/dist/src/server.d.ts +31 -0
  15. package/dist/src/server.js +561 -4
  16. package/dist/src/settings-ui.js +11 -11
  17. package/dist/src/tombstone.d.ts +13 -0
  18. package/dist/src/tombstone.js +103 -0
  19. package/dist/src/tools.d.ts +8 -0
  20. package/dist/src/tools.js +58 -4
  21. package/dist/src/types.d.ts +47 -0
  22. package/dist/src/types.js +1 -0
  23. package/dist/src/utils.d.ts +4 -2
  24. package/dist/src/utils.js +9 -4
  25. package/dist/src/watcher.d.ts +7 -1
  26. package/dist/src/watcher.js +170 -11
  27. package/dist/test/agent-queue.test.js +204 -0
  28. package/dist/test/auth.test.js +136 -0
  29. package/dist/test/auto-indexer.test.js +2 -0
  30. package/dist/test/dedup.test.js +62 -0
  31. package/dist/test/discover-schema.test.js +201 -0
  32. package/dist/test/modal-config.test.js +17 -2
  33. package/dist/test/queue-events.test.js +146 -0
  34. package/dist/test/rest-queue.test.js +234 -0
  35. package/dist/test/rest-setup.test.js +213 -0
  36. package/dist/test/rest-wiki.test.js +161 -0
  37. package/dist/test/server-ws.test.js +219 -0
  38. package/dist/test/tombstone.test.js +233 -0
  39. package/package.json +6 -2
  40. package/scripts/e2e-ws.mjs +364 -0
  41. package/scripts/fetch-modal-token.sh +66 -0
  42. package/scripts/modal-e2e-smoke.mjs +171 -0
  43. package/scripts/setup-test-config.sh +66 -0
  44. package/dist/test/index.test.d.ts +0 -1
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * E2E smoke for the WebSocket /agent/stream endpoint and the REST
4
+ * queue/wiki routes it streams events for.
5
+ *
6
+ * Boots a real server on an ephemeral port, opens an authenticated ws
7
+ * client, exercises retry/cancel/search over HTTP, asserts the expected
8
+ * queue events arrive over the socket, then exits 0 on success or 1 on
9
+ * failure.
10
+ *
11
+ * By default it uses the recycvape vault at ../recycvape/ReturnVape if it
12
+ * exists, otherwise a fresh temp vault. Override with PVM_E2E_VAULT.
13
+ *
14
+ * Usage:
15
+ * pnpm build # produces dist/src/*.js
16
+ * pnpm e2e:ws # or: node scripts/e2e-ws.mjs
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { WebSocket } from "ws";
23
+
24
+ const { createServerState, startServer, stopServer } = await import("../dist/src/server.js");
25
+ const { createWatcherState } = await import("../dist/src/watcher.js");
26
+ const { createJob, markJobDone } = await import("../dist/src/agent-queue.js");
27
+ const { _resetAuthCache } = await import("../dist/src/auth.js");
28
+
29
+ const TOKEN = process.env.PVM_API_TOKEN || `e2e-ws-${Math.random().toString(36).slice(2)}`;
30
+ const API_HEADERS = {
31
+ "Content-Type": "application/json",
32
+ Authorization: `Bearer ${TOKEN}`,
33
+ };
34
+
35
+ let failures = 0;
36
+ const pass = (msg) => console.log(` ✓ ${msg}`);
37
+ const fail = (msg) => {
38
+ failures++;
39
+ console.log(` ✗ ${msg}`);
40
+ };
41
+ const step = (msg) => console.log(`\n${msg}`);
42
+
43
+ function tmpDir(prefix) {
44
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
45
+ }
46
+
47
+ function defaultVaultPath() {
48
+ if (process.env.PVM_E2E_VAULT) {
49
+ return path.resolve(process.env.PVM_E2E_VAULT);
50
+ }
51
+ const recycvape = path.resolve(import.meta.dirname, "../../recycvape/ReturnVape");
52
+ if (fs.existsSync(recycvape)) return recycvape;
53
+ return tmpDir("pvm-e2e-vault-");
54
+ }
55
+
56
+ function ensureProjectConfig(vaultPath) {
57
+ const cfgPath = path.join(vaultPath, "pi-vault-mind.config.json");
58
+ if (!fs.existsSync(cfgPath)) {
59
+ fs.writeFileSync(
60
+ cfgPath,
61
+ JSON.stringify(
62
+ {
63
+ version: 2,
64
+ collections: {
65
+ main: {
66
+ path: "collections/main.jsonl",
67
+ schema: ["id", "domain", "source", "fact", "tag", "artifact"],
68
+ dedupField: "fact",
69
+ },
70
+ },
71
+ injectors: [],
72
+ wiki: {
73
+ dataDir: ".lancedb",
74
+ embedding: {
75
+ provider: "modal",
76
+ modal: {
77
+ workspace: "kylebrodeur",
78
+ model: "embeddinggemma",
79
+ },
80
+ },
81
+ vaults: { default: { path: vaultPath } },
82
+ },
83
+ },
84
+ null,
85
+ 2
86
+ ),
87
+ "utf-8"
88
+ );
89
+ }
90
+ const collDir = path.join(vaultPath, "collections");
91
+ if (!fs.existsSync(collDir)) fs.mkdirSync(collDir, { recursive: true });
92
+ const mainColl = path.join(collDir, "main.jsonl");
93
+ if (!fs.existsSync(mainColl)) fs.writeFileSync(mainColl, "", "utf-8");
94
+ }
95
+
96
+ function createCollectingWs(url, headers) {
97
+ const ws = new WebSocket(url, { headers });
98
+ const events = [];
99
+ ws.on("message", (data) => {
100
+ try {
101
+ events.push(JSON.parse(data.toString()));
102
+ } catch {
103
+ events.push(data.toString());
104
+ }
105
+ });
106
+ return {
107
+ ws,
108
+ events,
109
+ waitFor(count, timeoutMs = 15000) {
110
+ return new Promise((resolve, reject) => {
111
+ if (events.length >= count) return resolve(events.slice(0, count));
112
+ const timer = setTimeout(() => {
113
+ if (events.length >= count) {
114
+ resolve(events.slice(0, count));
115
+ } else {
116
+ reject(
117
+ new Error(
118
+ `expected ${count} ws events, got ${events.length}: ${JSON.stringify(events)}`
119
+ )
120
+ );
121
+ }
122
+ }, timeoutMs);
123
+ const check = () => {
124
+ if (events.length >= count) {
125
+ clearTimeout(timer);
126
+ ws.off("message", check);
127
+ resolve(events.slice(0, count));
128
+ }
129
+ };
130
+ ws.on("message", check);
131
+ });
132
+ },
133
+ };
134
+ }
135
+
136
+ function waitForOpen(ws, timeoutMs = 10000) {
137
+ return new Promise((resolve, reject) => {
138
+ if (ws.readyState === ws.OPEN) return resolve();
139
+ const timer = setTimeout(() => reject(new Error("ws open timeout")), timeoutMs);
140
+ ws.once("open", () => {
141
+ clearTimeout(timer);
142
+ resolve();
143
+ });
144
+ ws.once("error", (err) => {
145
+ clearTimeout(timer);
146
+ reject(err);
147
+ });
148
+ });
149
+ }
150
+
151
+ function waitForClose(ws, timeoutMs = 5000) {
152
+ return new Promise((resolve, reject) => {
153
+ if (ws.readyState === ws.CLOSED) return resolve();
154
+ const timer = setTimeout(() => reject(new Error("ws close timeout")), timeoutMs);
155
+ ws.once("close", () => {
156
+ clearTimeout(timer);
157
+ resolve();
158
+ });
159
+ ws.once("error", (err) => {
160
+ clearTimeout(timer);
161
+ reject(err);
162
+ });
163
+ });
164
+ }
165
+
166
+ async function httpJson(port, method, path_, body) {
167
+ const res = await fetch(`http://127.0.0.1:${port}${path_}`, {
168
+ method,
169
+ headers: API_HEADERS,
170
+ body: body ? JSON.stringify(body) : undefined,
171
+ });
172
+ const text = await res.text();
173
+ let json;
174
+ try {
175
+ json = JSON.parse(text);
176
+ } catch {
177
+ json = { raw: text };
178
+ }
179
+ return { status: res.status, body: json };
180
+ }
181
+
182
+ async function main() {
183
+ console.log("WebSocket + REST E2E smoke");
184
+
185
+ const savedHome = process.env.HOME;
186
+ const savedToken = process.env.PVM_API_TOKEN;
187
+ const savedCwd = process.cwd();
188
+ const vaultPath = defaultVaultPath();
189
+ const isTempVault = !process.env.PVM_E2E_VAULT && vaultPath.includes(os.tmpdir());
190
+
191
+ process.chdir(vaultPath);
192
+ process.env.HOME = vaultPath;
193
+ process.env.PVM_API_TOKEN = TOKEN;
194
+ _resetAuthCache();
195
+
196
+ let serverState;
197
+ let wsClient;
198
+ try {
199
+ ensureProjectConfig(vaultPath);
200
+
201
+ step("1. boot server on ephemeral port");
202
+ const watcherState = createWatcherState();
203
+ serverState = createServerState(0);
204
+ startServer({}, serverState, watcherState);
205
+ await new Promise((resolve, reject) => {
206
+ const srv = serverState.server;
207
+ if (!srv) return reject(new Error("server not created"));
208
+ if (srv.listening) return resolve();
209
+ srv.once("listening", resolve);
210
+ srv.once("error", reject);
211
+ });
212
+ const addr = serverState.server.address();
213
+ const port = typeof addr === "object" && addr ? addr.port : serverState.port;
214
+ pass(`server listening on http://127.0.0.1:${port} (vault: ${vaultPath})`);
215
+
216
+ step("2. connect ws client to /agent/stream");
217
+ wsClient = createCollectingWs(`ws://127.0.0.1:${port}/agent/stream`, {
218
+ Authorization: `Bearer ${TOKEN}`,
219
+ });
220
+ await waitForOpen(wsClient.ws);
221
+ pass("ws client connected");
222
+
223
+ step("3. receive queue/snapshot on connect");
224
+ const snapshot = await wsClient.waitFor(1, 5000);
225
+ if (snapshot.length === 1 && snapshot[0].type === "queue/snapshot") {
226
+ pass(`snapshot received with ${snapshot[0].jobs?.length ?? 0} jobs`);
227
+ } else {
228
+ fail(`unexpected snapshot: ${JSON.stringify(snapshot)}`);
229
+ }
230
+
231
+ step("4. create a job and observe job-created event");
232
+ const createdPromise = wsClient.waitFor(2, 5000);
233
+ createJob(vaultPath, {
234
+ id: "e2e-job-1",
235
+ vaultPath,
236
+ filePath: path.join(vaultPath, "note.md"),
237
+ role: "miner",
238
+ agentName: "vault-mind-miner",
239
+ instruction: "E2E smoke job",
240
+ markerCount: 1,
241
+ });
242
+ const created = await createdPromise;
243
+ const createdEvent = created.find((e) => e.type === "job-created");
244
+ if (createdEvent?.job?.id === "e2e-job-1") {
245
+ pass("job-created event received");
246
+ } else {
247
+ fail(`unexpected create event: ${JSON.stringify(created)}`);
248
+ }
249
+
250
+ step("5. REST POST /agent/jobs/:id/cancel and observe completion event");
251
+ const cancelPromise = wsClient.waitFor(3, 5000);
252
+ const cancelRes = await httpJson(port, "POST", "/agent/jobs/e2e-job-1/cancel");
253
+ if (cancelRes.status === 200 && cancelRes.body?.status === "cancelled") {
254
+ pass("cancel REST returned 200 with cancelled job");
255
+ } else {
256
+ fail(`cancel REST failed: ${cancelRes.status} ${JSON.stringify(cancelRes.body)}`);
257
+ }
258
+ const cancelled = await cancelPromise;
259
+ const cancelEvent = cancelled.find(
260
+ (e) => e.type === "job-completed" && e.job?.status === "cancelled"
261
+ );
262
+ if (cancelEvent) {
263
+ pass("job-completed event received after cancel");
264
+ } else {
265
+ fail(`unexpected cancel event: ${JSON.stringify(cancelled)}`);
266
+ }
267
+
268
+ step("6. REST POST /agent/jobs/:id/retry and observe updated event");
269
+ const retryPromise = wsClient.waitFor(4, 5000);
270
+ const retryRes = await httpJson(port, "POST", "/agent/jobs/e2e-job-1/retry");
271
+ if (retryRes.status === 200 && retryRes.body?.status === "running") {
272
+ pass("retry REST returned 200 with running job");
273
+ } else {
274
+ fail(`retry REST failed: ${retryRes.status} ${JSON.stringify(retryRes.body)}`);
275
+ }
276
+ const retried = await retryPromise;
277
+ const retryEvent = retried.find((e) => e.type === "job-updated" && e.job?.status === "running");
278
+ if (retryEvent) {
279
+ pass("job-updated event received after retry");
280
+ } else {
281
+ fail(`unexpected retry event: ${JSON.stringify(retried)}`);
282
+ }
283
+
284
+ step("7. complete the job and observe completed event");
285
+ const donePromise = wsClient.waitFor(5, 5000);
286
+ const doneJob = markJobDone(vaultPath, "e2e-job-1");
287
+ if (doneJob?.status === "done") {
288
+ pass("job marked done");
289
+ } else {
290
+ fail(`markJobDone did not return done job: ${JSON.stringify(doneJob)}`);
291
+ }
292
+ const done = await donePromise;
293
+ const doneEvent = done.find((e) => e.type === "job-completed" && e.job?.status === "done");
294
+ if (doneEvent) {
295
+ pass("job-completed event received after markJobDone");
296
+ } else {
297
+ fail(`unexpected done event: ${JSON.stringify(done)}`);
298
+ }
299
+
300
+ step("8. REST POST /wiki/append and /wiki/search");
301
+ const appendRes = await httpJson(port, "POST", "/wiki/append", {
302
+ collection: "main",
303
+ entry: {
304
+ id: "e2e-fact-1",
305
+ domain: "e2e",
306
+ source: "smoke",
307
+ fact: "JWT tokens expire after one hour.",
308
+ tag: "auth",
309
+ artifact: "",
310
+ },
311
+ });
312
+ if (appendRes.status === 200 && appendRes.body?.ok === true) {
313
+ pass("append REST returned 200");
314
+ } else {
315
+ fail(`append REST failed: ${appendRes.status} ${JSON.stringify(appendRes.body)}`);
316
+ }
317
+
318
+ const searchRes = await httpJson(port, "POST", "/wiki/search", {
319
+ collection: "main",
320
+ query: "JWT expire",
321
+ limit: 5,
322
+ });
323
+ if (searchRes.status === 200 && Array.isArray(searchRes.body?.hits)) {
324
+ pass(`search REST returned 200 with ${searchRes.body.hits.length} hit(s)`);
325
+ } else {
326
+ fail(`search REST failed: ${searchRes.status} ${JSON.stringify(searchRes.body)}`);
327
+ }
328
+
329
+ step("9. close ws and stop server");
330
+ wsClient.ws.close();
331
+ await waitForClose(wsClient.ws);
332
+ pass("ws closed cleanly");
333
+ } finally {
334
+ if (wsClient && wsClient.ws.readyState !== wsClient.ws.CLOSED) {
335
+ try {
336
+ wsClient.ws.terminate();
337
+ } catch {
338
+ // ignore
339
+ }
340
+ }
341
+ if (serverState) stopServer(serverState);
342
+ if (savedHome === undefined) process.env.HOME = undefined;
343
+ else process.env.HOME = savedHome;
344
+ if (savedToken === undefined) process.env.PVM_API_TOKEN = undefined;
345
+ else process.env.PVM_API_TOKEN = savedToken;
346
+ process.chdir(savedCwd);
347
+ _resetAuthCache();
348
+ if (isTempVault) {
349
+ try {
350
+ fs.rmSync(vaultPath, { recursive: true, force: true });
351
+ } catch {
352
+ // ignore cleanup errors
353
+ }
354
+ }
355
+ }
356
+
357
+ console.log(`\n${failures === 0 ? "✅ ALL CHECKS PASSED" : `❌ ${failures} CHECK(S) FAILED`}`);
358
+ process.exit(failures === 0 ? 0 : 1);
359
+ }
360
+
361
+ main().catch((err) => {
362
+ console.error("\n❌ e2e-ws aborted:", err?.message ?? err);
363
+ process.exit(1);
364
+ });
@@ -0,0 +1,66 @@
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 is a no-op on Windows (Git Bash / MINGW); skip it to avoid errors.
62
+ if [[ "$(uname -s)" != MINGW* ]]; then
63
+ chmod 600 "$ENV_FILE"
64
+ fi
65
+ echo "✅ Wrote PVM_API_TOKEN to ${ENV_FILE}"
66
+ 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
+ });
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Create an isolated pi testing config with pi-vault-mind and its dependencies.
3
+ #
4
+ # Usage:
5
+ # source scripts/setup-test-config.sh
6
+ # # Then run pi from your test vault:
7
+ # PI_CODING_AGENT_DIR="$TEST_CONFIG_DIR" pi
8
+ #
9
+ # This creates a clean ~/.pi-test/agent/ directory with:
10
+ # - models.json (copied from your real config so providers work)
11
+ # - The required extensions installed: pi-subagents, pi-context, pi-vault-mind
12
+ #
13
+ # The test config is isolated from your real ~/.pi/agent/ — no cross-contamination.
14
+
15
+ set -euo pipefail
16
+
17
+ TEST_CONFIG_DIR="${HOME}/.pi-test/agent"
18
+ REAL_CONFIG_DIR="${HOME}/.pi/agent"
19
+
20
+ echo "=== Setting up test pi config at ${TEST_CONFIG_DIR} ==="
21
+
22
+ # 1. Create directory structure
23
+ mkdir -p "${TEST_CONFIG_DIR}/npm/node_modules"
24
+ mkdir -p "${TEST_CONFIG_DIR}/extensions"
25
+ mkdir -p "${TEST_CONFIG_DIR}/sessions"
26
+
27
+ # 2. Copy models.json so providers work
28
+ if [ -f "${REAL_CONFIG_DIR}/models.json" ]; then
29
+ cp "${REAL_CONFIG_DIR}/models.json" "${TEST_CONFIG_DIR}/models.json"
30
+ echo "✓ Copied models.json"
31
+ else
32
+ echo "⚠ No models.json found in ${REAL_CONFIG_DIR} — you'll need to configure providers manually"
33
+ fi
34
+
35
+ # 3. Install required extensions into the test config
36
+ # We use PI_CODING_AGENT_DIR to target the test directory.
37
+ echo ""
38
+ echo "=== Installing extensions into test config ==="
39
+
40
+ export PI_CODING_AGENT_DIR="${TEST_CONFIG_DIR}"
41
+
42
+ # Install pi-subagents (nicobailon — the one that registers subagent() tool)
43
+ echo "Installing pi-subagents..."
44
+ pi install npm:pi-subagents
45
+
46
+ # Install pi-context
47
+ echo "Installing pi-context..."
48
+ pi install npm:pi-context
49
+
50
+ # Install pi-vault-mind from local checkout
51
+ echo "Installing pi-vault-mind from local checkout..."
52
+ pi install "$(pwd)"
53
+
54
+ echo ""
55
+ echo "=== Test config ready ==="
56
+ echo ""
57
+ echo "To use it, run pi from your test vault with:"
58
+ echo " PI_CODING_AGENT_DIR=\"${TEST_CONFIG_DIR}\" pi"
59
+ echo ""
60
+ echo "Or export it for the session:"
61
+ echo " export PI_CODING_AGENT_DIR=\"${TEST_CONFIG_DIR}\""
62
+ echo " cd /path/to/test-vault"
63
+ echo " pi"
64
+ echo ""
65
+ echo "Verify with:"
66
+ echo " PI_CODING_AGENT_DIR=\"${TEST_CONFIG_DIR}\" pi list"
@@ -1 +0,0 @@
1
- export {};