pi-vault-mind 0.7.3 → 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.
- package/README.md +11 -5
- package/dist/src/commands.js +53 -3
- package/package.json +2 -1
- package/scripts/fetch-modal-token.sh +63 -0
- package/scripts/modal-e2e-smoke.mjs +171 -0
package/README.md
CHANGED
|
@@ -182,7 +182,15 @@ The Broadcaster agent can generate podcasts, study guides, and slide decks from
|
|
|
182
182
|
- **Embedding Provider** — choose one:
|
|
183
183
|
- `@xenova/transformers` — built-in, no external deps (uses all-MiniLM-L6-v2, offline-capable)
|
|
184
184
|
- `ollama` — requires Ollama running locally with `embeddinggemma` (higher quality)
|
|
185
|
-
- `modal` — optional
|
|
185
|
+
- `modal` — optional, **bring-your-own deploy**: offload embedding + bulk re-index to a cloud GPU service and sync vectors down for offline search (see [docs/MODAL_EMBEDDING.md](docs/MODAL_EMBEDDING.md)). Default behavior is unchanged until you opt in.
|
|
186
|
+
|
|
187
|
+
> **On the Modal provider:** the local providers above work for everyone with
|
|
188
|
+
> zero infrastructure — that's the default. `modal` is an **optional, self-hosted
|
|
189
|
+
> tier**: you deploy your own copy of the service (`uvx modal deploy modal/app.py`
|
|
190
|
+
> from a clone of this repo) to your own Modal account and point the extension at
|
|
191
|
+
> *your* URL + token. There is no shared/hosted endpoint — a deployment's URL and
|
|
192
|
+
> bearer token are private to whoever owns it. (A standalone PyPI package for the
|
|
193
|
+
> server may come later.)
|
|
186
194
|
|
|
187
195
|
### 1. Install with pi
|
|
188
196
|
|
|
@@ -375,10 +383,8 @@ Edit `pi-vault-mind.config.json` to match your domain:
|
|
|
375
383
|
|---|---|
|
|
376
384
|
| [docs/AGENTS.md](docs/AGENTS.md) | Agent Roster and Multi-Agent Architecture ("Fork & Review" model) |
|
|
377
385
|
| [docs/EXTENSION_WIRING.md](docs/EXTENSION_WIRING.md) | Extension dependencies, runtime wiring, auto-install patterns |
|
|
378
|
-
| [docs/DISPATCHER_SPEC.md](docs/DISPATCHER_SPEC.md) | Technical spec for the passive file-watcher and subagent routing |
|
|
379
|
-
| [docs/
|
|
380
|
-
| [docs/PASSIVE_INGESTION_WORKFLOW.md](docs/PASSIVE_INGESTION_WORKFLOW.md) | The "Drop & Forget" document ingestion pipeline |
|
|
381
|
-
| [docs/NOTEBOOKLM_PIPELINE.md](docs/NOTEBOOKLM_PIPELINE.md) | NotebookLM automated podcast and study guide generation |
|
|
386
|
+
| [docs/DISPATCHER_SPEC.md](docs/DISPATCHER_SPEC.md) | Technical spec for the passive file-watcher and subagent routing — incl. the "Fork & Dispatch" rationale and thread resume |
|
|
387
|
+
| [docs/AGENTS.md](docs/AGENTS.md) | Agent roster + the Miner's "Drop & Forget" ingestion pipeline |
|
|
382
388
|
| [docs/OBSIDIAN_SETUP.md](docs/OBSIDIAN_SETUP.md) | Recommended Obsidian vault structure, plugins, and CLI |
|
|
383
389
|
|
|
384
390
|
### Modal embedding service (local integration done)
|
package/dist/src/commands.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
});
|