pi-vault-mind 0.7.1 → 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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Modal vector sync engine.
3
+ *
4
+ * Drains `ModalEmbeddingClient.exportAll(collection, …)` into the local
5
+ * LanceDB via the precomputed-vector insert path (`upsertPrecomputed`), and
6
+ * persists a per-(collection, model, dim) `seq` watermark (ADR-4) so
7
+ * incremental syncs resume from where the last one left off.
8
+ *
9
+ * Idempotent: rows are keyed by `id` and applied with merge-insert, so
10
+ * re-fetching a boundary row is a no-op and re-running a sync with no new rows
11
+ * changes nothing but the (unchanged) watermark.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { upsertPrecomputed } from "./lance.js";
16
+ import { createModalClient, resolveDim, resolveModel } from "./modal-config.js";
17
+ const stateKey = (collection, model, dim) => `${collection}__${model}__${dim}`;
18
+ const resolveWatermarkFile = (cfg) => {
19
+ const wiki = cfg.wiki;
20
+ const rel = wiki.embedding.modal?.sync?.watermarkPath || "_sync_state.json";
21
+ const dataDir = wiki.dataDir;
22
+ const absDir = path.isAbsolute(dataDir) ? dataDir : path.resolve(dataDir);
23
+ return path.isAbsolute(rel) ? rel : path.join(absDir, rel);
24
+ };
25
+ export const loadSyncState = (cfg) => {
26
+ const p = resolveWatermarkFile(cfg);
27
+ try {
28
+ if (fs.existsSync(p)) {
29
+ const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
30
+ return { watermarks: raw?.watermarks ?? {} };
31
+ }
32
+ }
33
+ catch (err) {
34
+ console.warn(`[pi-vault-mind] Failed to read sync state at ${p}: ${err.message}`);
35
+ }
36
+ return { watermarks: {} };
37
+ };
38
+ export const saveSyncState = (cfg, state) => {
39
+ const p = resolveWatermarkFile(cfg);
40
+ const dir = path.dirname(p);
41
+ if (!fs.existsSync(dir))
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
44
+ };
45
+ /** Current watermark for a (collection, model, dim), default 0. */
46
+ export const getWatermark = (cfg, collection, model, dim) => loadSyncState(cfg).watermarks[stateKey(collection, model, dim)] ?? 0;
47
+ // ── Collection discovery ────────────────────────────────────────────────────
48
+ /** List the collections/tables held in the server-side vector store. */
49
+ export const listRemoteCollections = async (cfg) => {
50
+ const client = createModalClient(cfg.wiki);
51
+ if (!client)
52
+ throw new Error("Modal not configured (missing baseUrl or token).");
53
+ return client.syncCollections();
54
+ };
55
+ /**
56
+ * Resolve the dim to sync for a collection+model. Config dim wins; otherwise the
57
+ * server's table dim is discovered via `/sync/collections`. Returns null when
58
+ * the server has no table for that collection+model (nothing to sync).
59
+ */
60
+ const resolveSyncDim = async (cfg, collection, model) => {
61
+ const configured = resolveDim(cfg.wiki, collection);
62
+ if (configured != null)
63
+ return configured;
64
+ const remote = await listRemoteCollections(cfg);
65
+ const match = remote.find((c) => c.collection === collection && c.model === model);
66
+ return match?.dim ?? null;
67
+ };
68
+ /**
69
+ * Sync a single collection from the server into the local LanceDB. Incremental
70
+ * by default (from the persisted watermark); `full` re-pulls from seq 0. Rows
71
+ * are merge-inserted by `id` (idempotent). Returns the row count + new
72
+ * watermark. A re-run with no new rows is a no-op (rows: 0, watermark unchanged).
73
+ */
74
+ export const syncCollection = async (cfg, collection, opts = {}) => {
75
+ const wiki = cfg.wiki;
76
+ const client = createModalClient(wiki);
77
+ if (!client)
78
+ throw new Error("Modal not configured (missing baseUrl or token).");
79
+ const model = resolveModel(wiki, collection);
80
+ const dim = await resolveSyncDim(cfg, collection, model);
81
+ if (dim == null) {
82
+ return {
83
+ collection,
84
+ model,
85
+ dim: resolveDim(wiki, collection) ?? 0,
86
+ rows: 0,
87
+ watermark: 0,
88
+ full: !!opts.full,
89
+ };
90
+ }
91
+ const state = loadSyncState(cfg);
92
+ const key = stateKey(collection, model, dim);
93
+ const since = opts.full ? 0 : (state.watermarks[key] ?? 0);
94
+ const pageSize = wiki.embedding.modal?.sync?.pageSize ?? 500;
95
+ let rows = 0;
96
+ const watermark = await client.exportAll(collection, async (page) => {
97
+ const withVectors = page.filter((r) => Array.isArray(r.vector) && r.vector.length > 0);
98
+ if (withVectors.length < page.length) {
99
+ console.warn(`[pi-vault-mind] sync: skipped ${page.length - withVectors.length} vector-less row(s) for "${collection}"`);
100
+ }
101
+ if (withVectors.length === 0)
102
+ return;
103
+ await upsertPrecomputed(wiki.dataDir, collection, model, dim, withVectors, wiki);
104
+ rows += withVectors.length;
105
+ opts.onProgress?.(rows);
106
+ }, { model, dim, since, limit: pageSize });
107
+ state.watermarks[key] = watermark;
108
+ saveSyncState(cfg, state);
109
+ return { collection, model, dim, rows, watermark, full: !!opts.full };
110
+ };
111
+ /**
112
+ * Sync every configured collection (or a caller-supplied list). Collections
113
+ * with no server-side table report `rows: 0` and are skipped harmlessly.
114
+ */
115
+ export const syncAll = async (cfg, collections, opts = {}) => {
116
+ const syncCfg = cfg.wiki.embedding.modal?.sync;
117
+ const names = collections ?? syncCfg?.collections ?? Object.keys(cfg.collections);
118
+ const results = [];
119
+ for (const name of names) {
120
+ if (!cfg.collections[name])
121
+ continue;
122
+ try {
123
+ results.push(await syncCollection(cfg, name, opts));
124
+ }
125
+ catch (err) {
126
+ console.warn(`[pi-vault-mind] sync "${name}" failed: ${err.message}`);
127
+ results.push({
128
+ collection: name,
129
+ model: resolveModel(cfg.wiki, name),
130
+ dim: resolveDim(cfg.wiki, name) ?? 0,
131
+ rows: 0,
132
+ watermark: 0,
133
+ full: !!opts.full,
134
+ });
135
+ }
136
+ }
137
+ return results;
138
+ };
139
+ /**
140
+ * Read a collection's JSONL (the source of truth), submit a Modal bulk embedding
141
+ * job, poll it to completion, then sync the freshly-embedded vectors down into
142
+ * the local LanceDB. Used by `/wiki reindex --all --reembed --remote`. Local
143
+ * re-embed remains the default; this offloads bulk embedding to cloud GPUs.
144
+ *
145
+ * Records map the JSONL entry's embeddable field (`fact`) to `text` and the
146
+ * remaining fields to `metadata`, so sync-down can reconstruct the local row.
147
+ */
148
+ export const reindexRemote = async (cfg, collections, opts = {}) => {
149
+ const wiki = cfg.wiki;
150
+ const client = createModalClient(wiki);
151
+ if (!client)
152
+ throw new Error("Modal not configured (missing baseUrl or token).");
153
+ const model = resolveModel(wiki);
154
+ const dim = resolveDim(wiki);
155
+ const out = [];
156
+ for (const collection of collections) {
157
+ const def = cfg.collections[collection];
158
+ if (!def) {
159
+ out.push({ collection, error: "unknown collection" });
160
+ continue;
161
+ }
162
+ try {
163
+ const lines = fs.existsSync(def.path)
164
+ ? fs.readFileSync(def.path, "utf-8").split("\n").filter(Boolean)
165
+ : [];
166
+ const records = [];
167
+ for (const line of lines) {
168
+ try {
169
+ const entry = JSON.parse(line);
170
+ const text = String(entry.fact ?? entry.content ?? "");
171
+ if (!text)
172
+ continue;
173
+ const { id, fact, content, ...rest } = entry;
174
+ records.push({
175
+ id: String(id ?? crypto.randomUUID()),
176
+ text,
177
+ metadata: rest,
178
+ });
179
+ }
180
+ catch {
181
+ /* skip malformed */
182
+ }
183
+ }
184
+ if (records.length === 0) {
185
+ out.push({ collection, error: "no records to embed" });
186
+ continue;
187
+ }
188
+ const submitted = await client.submitJob(collection, records, { model, dim });
189
+ let status;
190
+ try {
191
+ status = await client.waitForJob(submitted.job_id, opts.pollMs ?? 2000);
192
+ opts.onStatus?.(status);
193
+ }
194
+ catch (err) {
195
+ out.push({ collection, error: `job poll failed: ${err.message}` });
196
+ continue;
197
+ }
198
+ if (status?.status !== "done") {
199
+ out.push({ collection, job: status, error: status?.error || `job ${status?.status}` });
200
+ continue;
201
+ }
202
+ // Pull the freshly-embedded vectors down (full, from seq 0).
203
+ const sync = await syncCollection(cfg, collection, { full: true });
204
+ out.push({ collection, job: status, sync });
205
+ }
206
+ catch (err) {
207
+ out.push({ collection, error: err.message });
208
+ }
209
+ }
210
+ return out;
211
+ };
@@ -12,10 +12,116 @@ export interface InjectorDef {
12
12
  artifactPath?: string;
13
13
  template?: string;
14
14
  }
15
+ /**
16
+ * Configuration for the Modal embedding service (see `src/modal-client.ts`
17
+ * and `docs/MODAL_EMBEDDING.md`). Optional — only needed when
18
+ * `provider: "modal"` is selected.
19
+ *
20
+ * The canonical model owns the vector space (see the shared strategy: one
21
+ * model, no cross-model alignment adapters). A single query never mixes
22
+ * `model__dim` spaces; changing the canonical model is a re-embed migration,
23
+ * not a translation.
24
+ */
25
+ export interface ModalEmbeddingConfig {
26
+ /** Base URL of the deployed Modal ASGI app (no trailing slash needed). */
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;
33
+ /**
34
+ * Bearer token matching the `pi-vault-mind-auth` Modal secret. Prefer
35
+ * setting via the `PVM_API_TOKEN` env var over committing it to config;
36
+ * the env var always wins and is preferred when both are set.
37
+ */
38
+ apiToken?: string;
39
+ /** Canonical embedder key, e.g. "embeddinggemma". Default "embeddinggemma". */
40
+ model?: string;
41
+ /** Matryoshka output dimension (omit to resolve the model's native dim). */
42
+ dim?: number;
43
+ /**
44
+ * Offline fallback policy. When Modal is unreachable the search path
45
+ * falls back to a configured local provider *only if* it produces vectors
46
+ * in the same `model__dim` space (so the same table can be queried);
47
+ * otherwise it degrades to FTS/keyword search — it never crashes a search.
48
+ */
49
+ fallback?: ModalFallbackConfig;
50
+ /** Sync-down options (pulling server-side vectors into the local LanceDB). */
51
+ sync?: ModalSyncConfig;
52
+ }
53
+ /**
54
+ * Offline fallback policy for the Modal provider.
55
+ */
56
+ export interface ModalFallbackConfig {
57
+ /** Whether to fall back at all when Modal is offline. Default true. */
58
+ enabled?: boolean;
59
+ /**
60
+ * Local provider to use when Modal is unreachable ("ollama" |
61
+ * "transformers"). Must serve the same canonical `model__dim` to query the
62
+ * same namespaced table; if the space differs, search degrades to FTS.
63
+ */
64
+ provider?: "ollama" | "transformers";
65
+ }
66
+ /**
67
+ * Sync-down options. The local side pulls server-side vectors (already
68
+ * embedded) into its own LanceDB and keeps working offline.
69
+ */
70
+ export interface ModalSyncConfig {
71
+ /** Collections to sync. Defaults to all configured collections. */
72
+ collections?: string[];
73
+ /** Page size for incremental export pulls. Default 500. */
74
+ pageSize?: number;
75
+ /** Auto-sync enabled (off by default). */
76
+ autoSync?: boolean;
77
+ /** Auto-sync interval in ms. Default 300000 (5 minutes). */
78
+ autoSyncIntervalMs?: number;
79
+ /**
80
+ * Watermark store location, relative to `wiki.dataDir` or absolute.
81
+ * Default `_sync_state.json`.
82
+ */
83
+ watermarkPath?: string;
84
+ }
85
+ /**
86
+ * Coalescer knobs (see `src/embed-queue.ts`). Applies to any provider routed
87
+ * through the coalescer (modal + local providers that opt in). Sensible
88
+ * defaults match the watcher's feel (≈1000ms debounce).
89
+ */
90
+ export interface CoalesceConfig {
91
+ /** Debounce window in ms before flushing a batch. Default 1000. */
92
+ debounceMs?: number;
93
+ /** Flush early once a task buffer reaches this size. Default 64. */
94
+ maxBatchSize?: number;
95
+ /** Max batched embed calls in flight at once. Default 2. */
96
+ maxConcurrentFlushes?: number;
97
+ /** Coalesce identical texts within a batch to a single embed. Default true. */
98
+ dedupe?: boolean;
99
+ /** Whether latency-sensitive search queries bypass batching. Default true. */
100
+ searchBypass?: boolean;
101
+ }
102
+ /**
103
+ * Per-collection canonical model override. A collection may pin a different
104
+ * canonical `model__dim` than the global default, but a single query never
105
+ * mixes spaces — each query targets exactly one `model__dim` table.
106
+ */
107
+ export interface CollectionModelOverride {
108
+ /** Canonical embedder key for this collection. */
109
+ model: string;
110
+ /** Output dimension for this collection. */
111
+ dim: number;
112
+ }
15
113
  export interface EmbeddingConfig {
16
- provider: "ollama" | "transformers";
114
+ provider: "ollama" | "transformers" | "modal";
17
115
  ollamaModel?: string;
18
116
  ollamaHost?: string;
117
+ modal?: ModalEmbeddingConfig;
118
+ /**
119
+ * Per-collection canonical `model__dim` override (optional). Keys are
120
+ * collection names; a query targets exactly one space.
121
+ */
122
+ collectionModels?: Record<string, CollectionModelOverride>;
123
+ /** Coalescer knobs (debounce + batch append/ingest embedding). */
124
+ coalesce?: CoalesceConfig;
19
125
  }
20
126
  export interface GraphConfig {
21
127
  enabled?: boolean;
@@ -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;
@@ -0,0 +1,105 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
3
+ import { EmbeddingCoalescer } from "../src/embed-queue.js";
4
+ /**
5
+ * Unit tests for the embedding coalescer (src/embed-queue.ts).
6
+ * Pure logic, no external deps — uses node:test mock timers for the debounce.
7
+ */
8
+ describe("EmbeddingCoalescer", () => {
9
+ beforeEach(() => {
10
+ mock.timers.enable({ apis: ["setTimeout"] });
11
+ });
12
+ afterEach(() => {
13
+ mock.timers.reset();
14
+ });
15
+ // A fake backend that records each batched call and returns a deterministic
16
+ // vector per text (length 2: [seq, textLength]).
17
+ function fakeBackend() {
18
+ const calls = [];
19
+ const embedFn = async (texts, task) => {
20
+ calls.push({ texts, task });
21
+ return texts.map((t, i) => [i, t.length]);
22
+ };
23
+ return { calls, embedFn };
24
+ }
25
+ it("coalesces requests in the debounce window into one batched call", async () => {
26
+ const { calls, embedFn } = fakeBackend();
27
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
28
+ const p1 = c.embed("alpha");
29
+ const p2 = c.embed("beta");
30
+ const p3 = c.embed("gamma");
31
+ assert.equal(c.size(), 3, "all three buffered before flush");
32
+ assert.equal(calls.length, 0, "nothing sent before the window elapses");
33
+ mock.timers.tick(1000);
34
+ const [v1, v2, v3] = await Promise.all([p1, p2, p3]);
35
+ assert.equal(calls.length, 1, "one batched call");
36
+ assert.deepEqual(calls[0].texts, ["alpha", "beta", "gamma"]);
37
+ assert.deepEqual(calls[0].task, "document");
38
+ assert.deepEqual(v1, [0, 5]);
39
+ assert.deepEqual(v2, [1, 4]);
40
+ assert.deepEqual(v3, [2, 5]);
41
+ });
42
+ it("dedupes identical texts within a batch", async () => {
43
+ const { calls, embedFn } = fakeBackend();
44
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
45
+ const a1 = c.embed("dup");
46
+ const a2 = c.embed("dup");
47
+ const b = c.embed("unique");
48
+ mock.timers.tick(1000);
49
+ const [r1, r2, rb] = await Promise.all([a1, a2, b]);
50
+ assert.equal(calls.length, 1);
51
+ assert.deepEqual(calls[0].texts, ["dup", "unique"], "only unique texts embedded");
52
+ assert.deepEqual(r1, r2, "both duplicate waiters get the same vector");
53
+ assert.deepEqual(rb, [1, 6]);
54
+ });
55
+ it("flushes early when maxBatchSize is reached (no timer needed)", async () => {
56
+ const { calls, embedFn } = fakeBackend();
57
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 99999, maxBatchSize: 2 });
58
+ const p1 = c.embed("one");
59
+ const p2 = c.embed("two"); // reaching size 2 triggers an immediate flush
60
+ await Promise.all([p1, p2]);
61
+ assert.equal(calls.length, 1, "flushed without waiting for the debounce");
62
+ assert.deepEqual(calls[0].texts, ["one", "two"]);
63
+ assert.equal(c.size(), 0);
64
+ });
65
+ it("keeps query and document batches separate, each with the right task", async () => {
66
+ const { calls, embedFn } = fakeBackend();
67
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
68
+ const q = c.embed("a question", "query");
69
+ const d = c.embed("a document", "document");
70
+ mock.timers.tick(1000);
71
+ await Promise.all([q, d]);
72
+ assert.equal(calls.length, 2, "one batch per task");
73
+ const tasks = calls.map((c) => c.task).sort();
74
+ assert.deepEqual(tasks, ["document", "query"]);
75
+ });
76
+ it("rejects every waiter in a batch when the backend throws", async () => {
77
+ const embedFn = async () => {
78
+ throw new Error("backend down");
79
+ };
80
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
81
+ const p1 = c.embed("x");
82
+ const p2 = c.embed("y");
83
+ mock.timers.tick(1000);
84
+ await assert.rejects(p1, /backend down/);
85
+ await assert.rejects(p2, /backend down/);
86
+ });
87
+ it("embedImmediate bypasses batching", async () => {
88
+ const { calls, embedFn } = fakeBackend();
89
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
90
+ const v = await c.embedImmediate("now", "query");
91
+ assert.equal(calls.length, 1, "sent immediately, no tick required");
92
+ assert.deepEqual(calls[0].texts, ["now"]);
93
+ assert.deepEqual(calls[0].task, "query");
94
+ assert.deepEqual(v, [0, 3]);
95
+ });
96
+ it("drain() flushes buffered work and awaits it", async () => {
97
+ const { calls, embedFn } = fakeBackend();
98
+ const c = new EmbeddingCoalescer({ embedFn, debounceMs: 1000 });
99
+ c.embed("p");
100
+ c.embed("q");
101
+ await c.drain();
102
+ assert.equal(calls.length, 1, "drain forced the flush without a timer tick");
103
+ assert.equal(c.size(), 0);
104
+ });
105
+ });
@@ -365,4 +365,39 @@ describe("pi-vault-mind", () => {
365
365
  process.chdir(cwd);
366
366
  }
367
367
  });
368
+ it("planGitignore creates a vault .gitignore when none exists", async () => {
369
+ const { planGitignore } = await import("../src/commands.js");
370
+ const plan = planGitignore(null);
371
+ assert.equal(plan.action, "create");
372
+ assert.ok(plan.content.includes(".lancedb/"), "must ignore the binary index");
373
+ assert.ok(plan.content.includes(".obsidian/workspace*.json"), "must ignore churny workspace state");
374
+ });
375
+ it("planGitignore appends only missing entries, tolerating slash-format differences", async () => {
376
+ const { planGitignore } = await import("../src/commands.js");
377
+ // obsidian-git already ignores the index, but in a different slash format
378
+ // (leading slash, no trailing slash) and without the workspace-state rule.
379
+ const plan = planGitignore("node_modules/\n/.lancedb\n");
380
+ assert.equal(plan.action, "append");
381
+ assert.ok(plan.content.includes(".obsidian/workspace*.json"), "should add the missing entry");
382
+ assert.ok(!plan.content.includes(".lancedb"), "should not duplicate an entry already present under a different slash format");
383
+ });
384
+ it("planGitignore skips when all entries are already present", async () => {
385
+ const { planGitignore } = await import("../src/commands.js");
386
+ const plan = planGitignore(".lancedb/\n.obsidian/workspace*.json\n");
387
+ assert.equal(plan.action, "skip");
388
+ assert.equal(plan.content, "");
389
+ });
390
+ it("planGitignore avoids gluing a new entry onto an unterminated last line", async () => {
391
+ const { planGitignore } = await import("../src/commands.js");
392
+ const plan = planGitignore("node_modules/"); // no trailing newline
393
+ assert.equal(plan.action, "append");
394
+ assert.ok(plan.content.startsWith("\n"), "should insert a separating newline first");
395
+ });
396
+ it("planGitignore appends to an empty file without a leading blank line", async () => {
397
+ const { planGitignore } = await import("../src/commands.js");
398
+ const plan = planGitignore("");
399
+ assert.equal(plan.action, "append");
400
+ assert.ok(!plan.content.startsWith("\n"), "empty file needs no leading blank line");
401
+ assert.ok(plan.content.includes(".lancedb/"), "should still add the index rule");
402
+ });
368
403
  });
@@ -0,0 +1,95 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import * as path from "node:path";
6
+ import { afterEach, beforeEach, describe, it } from "node:test";
7
+ import { connect, resetConnection, searchFts, searchHybrid, upsertPrecomputed, } from "../src/lance.js";
8
+ import { MODAL_TOKEN_ENV } from "../src/modal-config.js";
9
+ const MODEL = "testmodel";
10
+ const DIM = 3;
11
+ const wiki = (dataDir) => ({
12
+ dataDir,
13
+ embedding: {
14
+ provider: "modal",
15
+ modal: { baseUrl: "https://pvm.modal.run", model: MODEL },
16
+ coalesce: { debounceMs: 5, maxBatchSize: 1, maxConcurrentFlushes: 1 },
17
+ },
18
+ ftsEnabled: true,
19
+ });
20
+ const syncRow = (id, vector, fact = `fact ${id}`) => ({
21
+ id,
22
+ text: fact,
23
+ vector,
24
+ metadata: { domain: "d", tag: "t" },
25
+ model: MODEL,
26
+ dim: DIM,
27
+ seq: 1,
28
+ created_at: "",
29
+ });
30
+ describe("Modal lance path (precomputed + search + fallback)", () => {
31
+ let dir;
32
+ let origToken;
33
+ let origFetch;
34
+ beforeEach(() => {
35
+ dir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ origToken = process.env[MODAL_TOKEN_ENV];
38
+ process.env[MODAL_TOKEN_ENV] = "tok";
39
+ origFetch = globalThis.fetch;
40
+ resetConnection();
41
+ });
42
+ afterEach(() => {
43
+ if (origToken === undefined)
44
+ delete process.env[MODAL_TOKEN_ENV];
45
+ else
46
+ process.env[MODAL_TOKEN_ENV] = origToken;
47
+ globalThis.fetch = origFetch;
48
+ resetConnection();
49
+ });
50
+ it("upsertPrecomputed imports rows into the namespaced table", async () => {
51
+ const cfg = wiki(dir);
52
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0])], cfg);
53
+ const conn = await connect(dir);
54
+ const table = await conn.openTable(`col_main__${MODEL}__${DIM}`);
55
+ assert.equal(await table.countRows(), 2);
56
+ });
57
+ it("upsertPrecomputed is idempotent (merge-insert by id)", async () => {
58
+ const cfg = wiki(dir);
59
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
60
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
61
+ const conn = await connect(dir);
62
+ const table = await conn.openTable(`col_main__${MODEL}__${DIM}`);
63
+ assert.equal(await table.countRows(), 1);
64
+ });
65
+ it("searchHybrid (modal) returns nearest results via the /embed query path", async () => {
66
+ const cfg = wiki(dir);
67
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0]), syncRow("3", [0, 0, 1])], cfg);
68
+ // Mock /embed to return a query vector matching row "1".
69
+ globalThis.fetch = (async () => ({
70
+ ok: true,
71
+ status: 200,
72
+ json: async () => ({ model: MODEL, dim: DIM, vectors: [[1, 0, 0]] }),
73
+ text: async () => "",
74
+ }));
75
+ const results = (await searchHybrid(dir, "main", "anything", 2, cfg));
76
+ assert.ok(results.length > 0, "search returned results");
77
+ assert.equal(results[0].id, "1", "nearest neighbor is row 1");
78
+ });
79
+ it("searchHybrid degrades gracefully (no crash) when Modal is offline", async () => {
80
+ const cfg = wiki(dir);
81
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "authentication tokens")], cfg);
82
+ // Offline: every fetch rejects.
83
+ globalThis.fetch = (async () => {
84
+ throw new Error("network unreachable");
85
+ });
86
+ const results = await searchHybrid(dir, "main", "authentication", 5, cfg);
87
+ assert.ok(Array.isArray(results), "offline search returns an array, never throws");
88
+ });
89
+ it("searchFts (modal) targets the namespaced table without throwing", async () => {
90
+ const cfg = wiki(dir);
91
+ await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "rare keyword match here")], cfg);
92
+ const results = await searchFts(dir, "main", "rare", 5, cfg);
93
+ assert.ok(Array.isArray(results));
94
+ });
95
+ });