pi-vault-mind 0.8.0 → 0.8.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.
- package/CHANGELOG.md +20 -0
- package/README.md +44 -44
- package/agents/broadcaster.agent.md +30 -0
- package/agents/heavy-lifter.agent.md +30 -0
- package/agents/manager.agent.md +29 -0
- package/agents/miner.agent.md +29 -0
- package/dist/src/agent-bus/Agent.d.ts +49 -0
- package/dist/src/agent-bus/Agent.js +97 -0
- package/dist/src/agent-bus/AgentLoader.d.ts +33 -0
- package/dist/src/agent-bus/AgentLoader.js +102 -0
- package/dist/src/agent-bus/AgentRegistry.d.ts +17 -0
- package/dist/src/agent-bus/AgentRegistry.js +20 -0
- package/dist/src/agent-bus/LLMProvider.d.ts +36 -0
- package/dist/src/agent-bus/LLMProvider.js +125 -0
- package/dist/src/agent-bus/MessageBus.d.ts +47 -0
- package/dist/src/agent-bus/MessageBus.js +81 -0
- package/dist/src/agent-bus/TaskQueue.d.ts +27 -0
- package/dist/src/agent-bus/TaskQueue.js +57 -0
- package/dist/src/agent-bus/index.d.ts +6 -0
- package/dist/src/agent-bus/index.js +6 -0
- package/dist/src/agents/BroadcasterAgent.d.ts +19 -0
- package/dist/src/agents/BroadcasterAgent.js +70 -0
- package/dist/src/agents/HeavyLifterAgent.d.ts +26 -0
- package/dist/src/agents/HeavyLifterAgent.js +73 -0
- package/dist/src/agents/ManagerAgent.d.ts +20 -0
- package/dist/src/agents/ManagerAgent.js +71 -0
- package/dist/src/agents/MinerAgent.d.ts +19 -0
- package/dist/src/agents/MinerAgent.js +70 -0
- package/dist/src/agents/index.d.ts +4 -0
- package/dist/src/agents/index.js +4 -0
- package/dist/src/auth.d.ts +49 -2
- package/dist/src/auth.js +149 -7
- package/dist/src/autosync.d.ts +1 -1
- package/dist/src/autosync.js +3 -3
- package/dist/src/bridge.d.ts +45 -0
- package/dist/src/bridge.js +189 -0
- package/dist/src/commands.d.ts +1 -1
- package/dist/src/commands.js +419 -295
- package/dist/src/context-capture.d.ts +22 -0
- package/dist/src/context-capture.js +41 -0
- package/dist/src/discover-schema.d.ts +1 -1
- package/dist/src/discover-schema.js +4 -4
- package/dist/src/embed-queue.d.ts +1 -1
- package/dist/src/embed-queue.js +1 -1
- package/dist/src/engine.d.ts +65 -0
- package/dist/src/engine.js +244 -0
- package/dist/src/events.js +1 -1
- package/dist/src/graph.d.ts +3 -3
- package/dist/src/identity-injector.d.ts +15 -0
- package/dist/src/identity-injector.js +102 -0
- package/dist/src/index.js +88 -49
- package/dist/src/intake.js +4 -4
- package/dist/src/lance.d.ts +11 -37
- package/dist/src/lance.js +183 -240
- package/dist/src/modal-client.d.ts +14 -4
- package/dist/src/modal-client.js +50 -34
- package/dist/src/modal-config.d.ts +26 -10
- package/dist/src/modal-config.js +36 -5
- package/dist/src/personalize.d.ts +47 -0
- package/dist/src/personalize.js +352 -0
- package/dist/src/server.d.ts +30 -4
- package/dist/src/server.js +146 -64
- package/dist/src/settings-ui.d.ts +3 -1
- package/dist/src/settings-ui.js +84 -77
- package/dist/src/status.d.ts +25 -0
- package/dist/src/status.js +49 -0
- package/dist/src/sync.d.ts +1 -1
- package/dist/src/sync.js +19 -19
- package/dist/src/tombstone.js +2 -2
- package/dist/src/tools.js +112 -130
- package/dist/src/types.d.ts +86 -18
- package/dist/src/types.js +7 -4
- package/dist/src/utils.d.ts +5 -1
- package/dist/src/utils.js +24 -8
- package/dist/src/vault-tools.d.ts +29 -0
- package/dist/src/vault-tools.js +314 -0
- package/dist/src/vault-writer.d.ts +4 -5
- package/dist/src/vault-writer.js +13 -13
- package/dist/src/vm-handlers.d.ts +15 -0
- package/dist/src/vm-handlers.js +469 -0
- package/dist/src/watcher.d.ts +18 -4
- package/dist/src/watcher.js +109 -98
- package/dist/test/auth.test.js +57 -7
- package/dist/test/bridge-identity.test.js +388 -0
- package/dist/test/discover-schema.test.js +2 -2
- package/dist/test/engine-identity.test.js +119 -0
- package/dist/test/index.test.js +5 -6
- package/dist/test/lance-modal.test.js +7 -7
- package/dist/test/modal-config.test.js +51 -29
- package/dist/test/personalize.test.js +212 -0
- package/dist/test/rest-queue.test.js +1 -1
- package/dist/test/rest-setup.test.js +21 -21
- package/dist/test/{rest-wiki.test.js → rest-vm.test.js} +27 -23
- package/dist/test/server-ws.test.js +4 -4
- package/dist/test/sync.test.js +2 -2
- package/dist/test/tombstone.test.js +1 -1
- package/dist/test/vault-tools.test.js +224 -0
- package/package.json +2 -1
- package/scripts/check-deps.sh +207 -0
- package/scripts/e2e-ws.mjs +4 -4
- package/scripts/modal-e2e-smoke.mjs +1 -1
- package/scripts/setup-test-config.sh +1 -5
- package/scripts/test-slash-commands.mjs +197 -0
- package/skills/.agents/broadcaster.agent.md +31 -0
- package/skills/.agents/heavy-lifter.agent.md +31 -0
- package/skills/.agents/manager.agent.md +30 -0
- package/skills/.agents/miner.agent.md +30 -0
- package/skills/vault-mind/SKILL.md +50 -50
- package/skills/vault-mind/references/tool-reference.md +25 -25
- package/skills/vault-mind-broadcaster/SKILL.md +1 -1
- package/skills/vault-mind-heavy-lifter/SKILL.md +3 -3
- package/skills/vault-mind-manager/SKILL.md +7 -7
- package/skills/vault-mind-miner/SKILL.md +11 -11
- package/skills/vault-mind-setup/SKILL.md +65 -265
- package/skills/vault-mind-setup/references/obsidian-vault-structure.md +6 -6
- package/skills/vault-mind-setup/references/pi-extension-wiring.md +24 -33
- package/skills/vault-mind-setup/references/troubleshooting-tree.md +25 -26
- package/dist/src/auto-index.d.ts +0 -47
- package/dist/src/auto-index.js +0 -122
package/dist/src/lance.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import * as lancedb from "@lancedb/lancedb";
|
|
5
4
|
import { LanceSchema, TextEmbeddingFunction } from "@lancedb/lancedb/embedding";
|
|
@@ -27,17 +26,19 @@ export const resetConnection = () => {
|
|
|
27
26
|
};
|
|
28
27
|
// ── Embedding Functions ─────────────────────────────────────────────────────
|
|
29
28
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* OpenAI-compatible embedding function that POSTs to /v1/embeddings.
|
|
30
|
+
* Works with any OpenAI-compatible endpoint (Ollama, Modal, cloud providers).
|
|
32
31
|
*/
|
|
33
|
-
class
|
|
32
|
+
class OpenAICompatibleEmbeddingFunction extends TextEmbeddingFunction {
|
|
33
|
+
url;
|
|
34
34
|
model;
|
|
35
|
-
|
|
35
|
+
apiKey;
|
|
36
36
|
ndimsValue;
|
|
37
|
-
constructor(options
|
|
37
|
+
constructor(options) {
|
|
38
38
|
super();
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
39
|
+
this.url = options.url;
|
|
40
|
+
this.model = options.model;
|
|
41
|
+
this.apiKey = options.apiKey;
|
|
41
42
|
this.ndimsValue = options.dims;
|
|
42
43
|
}
|
|
43
44
|
ndims() {
|
|
@@ -47,40 +48,39 @@ class OllamaEmbeddingFunction extends TextEmbeddingFunction {
|
|
|
47
48
|
return new arrow.Float32();
|
|
48
49
|
}
|
|
49
50
|
async init() {
|
|
50
|
-
//
|
|
51
|
+
// Best-effort connectivity check
|
|
51
52
|
try {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!modelNames.some((n) => n.startsWith(this.model))) {
|
|
58
|
-
console.warn(`[pi-vault-mind] Ollama model "${this.model}" not found. Available: ${modelNames.join(", ")}. Pull it with: ollama pull ${this.model}`);
|
|
59
|
-
}
|
|
53
|
+
const baseUrl = this.url.replace(/\/v1\/embeddings\/?$/, "").replace(/\/+$/, "");
|
|
54
|
+
const headers = {};
|
|
55
|
+
if (this.apiKey)
|
|
56
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
57
|
+
await fetch(`${baseUrl}/v1/models`, { headers });
|
|
60
58
|
}
|
|
61
59
|
catch (err) {
|
|
62
|
-
console.warn(`[pi-vault-mind] Cannot reach
|
|
60
|
+
console.warn(`[pi-vault-mind] Cannot reach embedding endpoint at ${this.url}: ${err}`);
|
|
63
61
|
}
|
|
64
62
|
}
|
|
65
63
|
async generateEmbeddings(texts) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
embeddings.push(data.embeddings[0]);
|
|
64
|
+
const endpoint = this.url.endsWith("/v1/embeddings")
|
|
65
|
+
? this.url
|
|
66
|
+
: `${this.url.replace(/\/+$/, "")}/v1/embeddings`;
|
|
67
|
+
const headers = { "Content-Type": "application/json" };
|
|
68
|
+
if (this.apiKey)
|
|
69
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
70
|
+
const resp = await fetch(endpoint, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers,
|
|
73
|
+
body: JSON.stringify({ model: this.model, input: texts }),
|
|
74
|
+
});
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const body = await resp.text().catch(() => "");
|
|
77
|
+
throw new Error(`Embed failed: ${resp.status} ${body}`);
|
|
82
78
|
}
|
|
83
|
-
|
|
79
|
+
const data = (await resp.json());
|
|
80
|
+
if (!data.data || !Array.isArray(data.data)) {
|
|
81
|
+
throw new Error("Invalid embedding response");
|
|
82
|
+
}
|
|
83
|
+
return data.data.map((d) => d.embedding);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
@@ -243,6 +243,9 @@ function makeModalCollectionSchema(dim) {
|
|
|
243
243
|
new arrow.Field("tag", new arrow.Utf8(), true),
|
|
244
244
|
new arrow.Field("artifact", new arrow.Utf8(), true),
|
|
245
245
|
new arrow.Field("created_at", new arrow.Utf8(), true),
|
|
246
|
+
new arrow.Field("origin_identity", new arrow.Utf8(), true),
|
|
247
|
+
new arrow.Field("visibility", new arrow.Utf8(), true),
|
|
248
|
+
new arrow.Field("identity", new arrow.Utf8(), true),
|
|
246
249
|
new arrow.Field("vector", vec, true),
|
|
247
250
|
]);
|
|
248
251
|
}
|
|
@@ -269,6 +272,7 @@ const getModalCollectionTable = async (dataDir, collection, model, dim, cfg) =>
|
|
|
269
272
|
let t;
|
|
270
273
|
if (existing.includes(tableName)) {
|
|
271
274
|
t = await conn.openTable(tableName);
|
|
275
|
+
await ensureIdentityColumn(t);
|
|
272
276
|
}
|
|
273
277
|
else {
|
|
274
278
|
const schema = makeModalCollectionSchema(dim);
|
|
@@ -318,44 +322,47 @@ const findModalTable = async (dataDir, collection, model) => {
|
|
|
318
322
|
* (offline + no same-space local fallback) so a search degrades to keyword
|
|
319
323
|
* search instead of crashing.
|
|
320
324
|
*/
|
|
321
|
-
const modalFtsFallback = async (dataDir, collection, model, query, limit) => {
|
|
325
|
+
const modalFtsFallback = async (dataDir, collection, model, query, limit, identity) => {
|
|
322
326
|
const found = await findModalTable(dataDir, collection, model);
|
|
323
327
|
if (!found)
|
|
324
328
|
return [];
|
|
325
329
|
try {
|
|
326
|
-
|
|
330
|
+
const results = await found.table.search(query).limit(limit).toArray();
|
|
331
|
+
return filterByVisibility(results, identity);
|
|
327
332
|
}
|
|
328
333
|
catch {
|
|
329
334
|
return [];
|
|
330
335
|
}
|
|
331
336
|
};
|
|
332
337
|
/**
|
|
333
|
-
* Offline fallback embed for a query. Only used when
|
|
334
|
-
* same-space local provider is configured (same canonical
|
|
335
|
-
* space → can query the same namespaced table). Returns
|
|
336
|
-
* fallback is available, so the caller degrades to FTS.
|
|
338
|
+
* Offline fallback embed for a query. Only used when the remote endpoint is
|
|
339
|
+
* unreachable and a same-space local provider is configured (same canonical
|
|
340
|
+
* model → same vector space → can query the same namespaced table). Returns
|
|
341
|
+
* null when no same-space fallback is available, so the caller degrades to FTS.
|
|
337
342
|
*/
|
|
338
343
|
const fallbackQueryEmbed = async (cfg, collection, text) => {
|
|
339
344
|
const fb = cfg.embedding.modal?.fallback;
|
|
340
345
|
if (fb?.enabled === false)
|
|
341
346
|
return null;
|
|
342
|
-
if (fb?.provider !== "
|
|
343
|
-
return null;
|
|
347
|
+
if (fb?.provider !== "openai-compatible")
|
|
348
|
+
return null;
|
|
349
|
+
if (!cfg.embedding.localUrl)
|
|
350
|
+
return null;
|
|
344
351
|
const canonical = resolveModel(cfg, collection);
|
|
345
|
-
|
|
346
|
-
if ((cfg.embedding.ollamaModel || "embeddinggemma") !== canonical)
|
|
352
|
+
if ((cfg.embedding.model || "embeddinggemma") !== canonical)
|
|
347
353
|
return null;
|
|
348
354
|
try {
|
|
349
|
-
const fn = new
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
const fn = new OpenAICompatibleEmbeddingFunction({
|
|
356
|
+
url: cfg.embedding.localUrl,
|
|
357
|
+
model: cfg.embedding.model || "embeddinggemma",
|
|
358
|
+
apiKey: cfg.embedding.apiKey,
|
|
352
359
|
});
|
|
353
360
|
await fn.init();
|
|
354
361
|
const vecs = await fn.generateEmbeddings([text]);
|
|
355
362
|
return vecs[0];
|
|
356
363
|
}
|
|
357
364
|
catch (err) {
|
|
358
|
-
console.warn(`[pi-vault-mind]
|
|
365
|
+
console.warn(`[pi-vault-mind] Remote offline and local fallback failed: ${err.message}`);
|
|
359
366
|
return null;
|
|
360
367
|
}
|
|
361
368
|
};
|
|
@@ -372,170 +379,27 @@ const disposeCoalescers = async () => {
|
|
|
372
379
|
}
|
|
373
380
|
coalescerCache.clear();
|
|
374
381
|
};
|
|
375
|
-
const getModelsJsonPath = () => path.join(homedir(), ".pi", "agent", "models.json");
|
|
376
|
-
/** Read Pi's models.json for already-registered Ollama models. */
|
|
377
|
-
const loadPiModelsJson = () => {
|
|
378
|
-
const p = getModelsJsonPath();
|
|
379
|
-
if (!fs.existsSync(p))
|
|
380
|
-
return null;
|
|
381
|
-
try {
|
|
382
|
-
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
383
|
-
return data?.providers?.ollama?.models || null;
|
|
384
|
-
}
|
|
385
|
-
catch {
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
};
|
|
389
|
-
/** Parse `ollama list` output (same format pi-model-router uses). */
|
|
390
|
-
const parseOllamaList = (output) => {
|
|
391
|
-
const lines = output.trim().split("\n");
|
|
392
|
-
const models = [];
|
|
393
|
-
for (let i = 1; i < lines.length; i++) {
|
|
394
|
-
const line = lines[i].trim();
|
|
395
|
-
if (!line)
|
|
396
|
-
continue;
|
|
397
|
-
const parts = line.split(/\s{2,}/);
|
|
398
|
-
if (parts.length >= 3) {
|
|
399
|
-
models.push({
|
|
400
|
-
name: parts[0],
|
|
401
|
-
size: parts[2],
|
|
402
|
-
modified: parts.slice(3).join(" "),
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return models;
|
|
407
|
-
};
|
|
408
|
-
/**
|
|
409
|
-
* Discover Ollama models using multiple paths (matching pi-model-router pattern):
|
|
410
|
-
* 1. Try `pi.exec('ollama', ['list'])` — Pi's managed shell execution
|
|
411
|
-
* 2. Fall back to HTTP /api/tags
|
|
412
|
-
* 3. Fall back to Pi's cached models.json
|
|
413
|
-
*/
|
|
414
|
-
export const discoverOllamaModels = async (piOrHost) => {
|
|
415
|
-
const host = typeof piOrHost === "string" ? piOrHost : "http://127.0.0.1:11434";
|
|
416
|
-
const pi = typeof piOrHost === "object" ? piOrHost : undefined;
|
|
417
|
-
// Path 1: pi.exec (preferred — uses Pi's managed process execution)
|
|
418
|
-
if (pi) {
|
|
419
|
-
try {
|
|
420
|
-
const result = await pi.exec("ollama", ["list"], { timeout: 10000 });
|
|
421
|
-
if (result.code === 0 && result.stdout) {
|
|
422
|
-
return parseOllamaList(result.stdout);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
/* exec failed, try HTTP */
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
// Path 2: HTTP API
|
|
430
|
-
try {
|
|
431
|
-
const resp = await fetch(`${host}/api/tags`);
|
|
432
|
-
if (resp.ok) {
|
|
433
|
-
const data = (await resp.json());
|
|
434
|
-
return (data.models || []).map((m) => ({
|
|
435
|
-
name: m.name,
|
|
436
|
-
size: m.size ? `${(m.size / 1e9).toFixed(1)} GB` : "unknown",
|
|
437
|
-
modified: m.modified_at || "",
|
|
438
|
-
details: m.details,
|
|
439
|
-
}));
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
/* HTTP failed */
|
|
444
|
-
}
|
|
445
|
-
// Path 3: cached models.json (stale but no network needed)
|
|
446
|
-
const cached = loadPiModelsJson();
|
|
447
|
-
if (cached) {
|
|
448
|
-
return cached.map((m) => ({ name: m.id, size: "cached", modified: "" }));
|
|
449
|
-
}
|
|
450
|
-
return [];
|
|
451
|
-
};
|
|
452
|
-
/**
|
|
453
|
-
* Test Ollama connectivity. Uses pi.exec first, then HTTP.
|
|
454
|
-
*/
|
|
455
|
-
export const testOllamaConnection = async (hostOrPi) => {
|
|
456
|
-
const models = await discoverOllamaModels(hostOrPi);
|
|
457
|
-
if (models.length > 0)
|
|
458
|
-
return { reachable: true, models };
|
|
459
|
-
// Last-ditch: raw connectivity test
|
|
460
|
-
const host = typeof hostOrPi === "string" ? hostOrPi : undefined;
|
|
461
|
-
if (host) {
|
|
462
|
-
try {
|
|
463
|
-
const resp = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
|
464
|
-
if (resp.ok) {
|
|
465
|
-
const data = (await resp.json());
|
|
466
|
-
return {
|
|
467
|
-
reachable: true,
|
|
468
|
-
models: (data.models || []).map((m) => ({ name: m.name, size: "", modified: "" })),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
return { reachable: false, models: [], error: `HTTP ${resp.status}` };
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
return { reachable: false, models: [], error: err.message };
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
return { reachable: false, models: [], error: "No connectivity paths available" };
|
|
478
|
-
};
|
|
479
|
-
/**
|
|
480
|
-
* Pull a model from Ollama. Uses pi.exec for managed timeout, falls back to HTTP.
|
|
481
|
-
*/
|
|
482
|
-
export const pullOllamaModel = async (model, piOrHost) => {
|
|
483
|
-
const host = typeof piOrHost === "string" ? piOrHost : "http://127.0.0.1:11434";
|
|
484
|
-
const pi = typeof piOrHost === "object" ? piOrHost : undefined;
|
|
485
|
-
// Check if model already exists
|
|
486
|
-
const models = await discoverOllamaModels(piOrHost);
|
|
487
|
-
if (models.some((m) => m.name === model)) {
|
|
488
|
-
return { success: true, message: `Model "${model}" already exists` };
|
|
489
|
-
}
|
|
490
|
-
// Pull via pi.exec (preferred)
|
|
491
|
-
if (pi) {
|
|
492
|
-
try {
|
|
493
|
-
const result = await pi.exec("ollama", ["pull", model], { timeout: 120000 });
|
|
494
|
-
if (result.code === 0) {
|
|
495
|
-
return { success: true, message: `Successfully pulled "${model}"` };
|
|
496
|
-
}
|
|
497
|
-
return { success: false, message: `Pull exited with code ${result.code}: ${result.stderr}` };
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
500
|
-
return { success: false, message: `Pull timed out or failed: ${err.message}` };
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
// Fall back to HTTP
|
|
504
|
-
try {
|
|
505
|
-
const resp = await fetch(`${host}/api/pull`, {
|
|
506
|
-
method: "POST",
|
|
507
|
-
headers: { "Content-Type": "application/json" },
|
|
508
|
-
body: JSON.stringify({ name: model, stream: false }),
|
|
509
|
-
});
|
|
510
|
-
if (!resp.ok) {
|
|
511
|
-
const body = await resp.text().catch(() => "");
|
|
512
|
-
return { success: false, message: `Pull failed: ${resp.status} ${body}` };
|
|
513
|
-
}
|
|
514
|
-
return { success: true, message: `Successfully pulled "${model}"` };
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
return { success: false, message: `Error: ${err.message}` };
|
|
518
|
-
}
|
|
519
|
-
};
|
|
520
382
|
// ── Embedding Provider Factory ──────────────────────────────────────────────
|
|
521
383
|
const getEmbeddingFunction = async (cfg) => {
|
|
522
|
-
if (cfg.embedding.
|
|
523
|
-
// Used by graph tables (auto-embed via sourceField). The main collection
|
|
524
|
-
// path bypasses this and inserts precomputed vectors (see upsertEntry).
|
|
384
|
+
if (cfg.embedding.remoteUrl) {
|
|
525
385
|
const dim = effectiveDim(cfg);
|
|
526
386
|
return new ModalEmbeddingFunction(cfg, undefined, dim);
|
|
527
387
|
}
|
|
528
|
-
if (cfg.embedding.
|
|
529
|
-
const fn = new
|
|
530
|
-
|
|
531
|
-
|
|
388
|
+
if (cfg.embedding.localUrl) {
|
|
389
|
+
const fn = new OpenAICompatibleEmbeddingFunction({
|
|
390
|
+
url: cfg.embedding.localUrl,
|
|
391
|
+
model: cfg.embedding.model || "embeddinggemma",
|
|
392
|
+
apiKey: cfg.embedding.apiKey,
|
|
532
393
|
});
|
|
533
394
|
await fn.init();
|
|
534
395
|
return fn;
|
|
535
396
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
397
|
+
if (cfg.embedding.useTransformers !== false) {
|
|
398
|
+
const fn = new TransformersEmbeddingFunction();
|
|
399
|
+
await fn.init();
|
|
400
|
+
return fn;
|
|
401
|
+
}
|
|
402
|
+
throw new Error("No embedding provider configured. Set remoteUrl, localUrl, or useTransformers: true.");
|
|
539
403
|
};
|
|
540
404
|
// ── Schema Builders ─────────────────────────────────────────────────────────
|
|
541
405
|
function makeCollectionSchema(embeddingFn) {
|
|
@@ -547,9 +411,22 @@ function makeCollectionSchema(embeddingFn) {
|
|
|
547
411
|
tag: new arrow.Utf8(),
|
|
548
412
|
artifact: new arrow.Utf8(),
|
|
549
413
|
created_at: new arrow.Utf8(),
|
|
414
|
+
origin_identity: new arrow.Utf8(),
|
|
415
|
+
visibility: new arrow.Utf8(),
|
|
416
|
+
identity: new arrow.Utf8(),
|
|
550
417
|
vector: embeddingFn.vectorField(),
|
|
551
418
|
});
|
|
552
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Backwards-compat: ensure the `identity` column exists on tables created before
|
|
422
|
+
* this field was added. The column is nullable so old rows remain readable.
|
|
423
|
+
*/
|
|
424
|
+
async function ensureIdentityColumn(table) {
|
|
425
|
+
const schema = await table.schema();
|
|
426
|
+
if (!schema.fields.find((f) => f.name === "identity")) {
|
|
427
|
+
await table.addColumns(new arrow.Field("identity", new arrow.Utf8(), true));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
553
430
|
function makeEntitySchema(embeddingFn) {
|
|
554
431
|
return LanceSchema({
|
|
555
432
|
id: new arrow.Utf8(),
|
|
@@ -589,6 +466,7 @@ const getCollectionTable = async (dataDir, collectionName, cfg) => {
|
|
|
589
466
|
const existing = await conn.tableNames();
|
|
590
467
|
if (existing.includes(tableName)) {
|
|
591
468
|
tables[tableName] = await conn.openTable(tableName);
|
|
469
|
+
await ensureIdentityColumn(tables[tableName]);
|
|
592
470
|
return tables[tableName];
|
|
593
471
|
}
|
|
594
472
|
tables[tableName] = await conn.createEmptyTable(tableName, schema);
|
|
@@ -604,14 +482,14 @@ const getCollectionTable = async (dataDir, collectionName, cfg) => {
|
|
|
604
482
|
};
|
|
605
483
|
/**
|
|
606
484
|
* Graph (entity/relation) schemas embed a fixed-size vector field, so they need
|
|
607
|
-
* a concrete dimension. Under the
|
|
485
|
+
* a concrete dimension. Under the remote provider the dim may be unresolved until
|
|
608
486
|
* the first embed/sync caches it — fail with a clear message instead of building
|
|
609
487
|
* a schema with an undefined dimension. (These tables are not yet wired into the
|
|
610
488
|
* active graph path; this guards them for when they are.)
|
|
611
489
|
*/
|
|
612
490
|
const assertGraphDim = (cfg, kind) => {
|
|
613
|
-
if (cfg.embedding.
|
|
614
|
-
throw new Error(`Graph ${kind} table needs a known embedding dimension
|
|
491
|
+
if (cfg.embedding.remoteUrl && effectiveDim(cfg) == null) {
|
|
492
|
+
throw new Error(`Graph ${kind} table needs a known embedding dimension. Set vaultMind.embedding.dim, or run an embed/sync first so the native dim is cached.`);
|
|
615
493
|
}
|
|
616
494
|
};
|
|
617
495
|
const getEntityTable = async (dataDir, cfg) => {
|
|
@@ -647,10 +525,10 @@ const getRelationTable = async (dataDir, cfg) => {
|
|
|
647
525
|
return tables[tableName];
|
|
648
526
|
};
|
|
649
527
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
650
|
-
const isModal = (cfg) => cfg.embedding.
|
|
651
|
-
export const upsertEntry = async (dataDir, collectionName, entry, cfg) => {
|
|
528
|
+
const isModal = (cfg) => !!cfg.embedding.remoteUrl && !!cfg.embedding.modal;
|
|
529
|
+
export const upsertEntry = async (dataDir, collectionName, entry, cfg, identity) => {
|
|
652
530
|
if (isModal(cfg))
|
|
653
|
-
return upsertEntryModal(dataDir, collectionName, entry, cfg);
|
|
531
|
+
return upsertEntryModal(dataDir, collectionName, entry, cfg, identity);
|
|
654
532
|
const table = await getCollectionTable(dataDir, collectionName, cfg);
|
|
655
533
|
const row = {
|
|
656
534
|
id: entry.id || crypto.randomUUID(),
|
|
@@ -660,18 +538,21 @@ export const upsertEntry = async (dataDir, collectionName, entry, cfg) => {
|
|
|
660
538
|
tag: entry.tag || "",
|
|
661
539
|
artifact: entry.artifact || "",
|
|
662
540
|
created_at: entry.created_at || new Date().toISOString(),
|
|
541
|
+
origin_identity: identity?.id || "",
|
|
542
|
+
visibility: identity?.visibility || "vault",
|
|
543
|
+
identity: entry.identity || identity?.id || "",
|
|
663
544
|
};
|
|
664
545
|
await table.add([row]);
|
|
665
546
|
};
|
|
666
547
|
/**
|
|
667
548
|
* Modal append path: embed the document text via the coalescer (debounced +
|
|
668
549
|
* batched), then merge-insert the row with its precomputed vector into the
|
|
669
|
-
* namespaced `col_{collection}__{model}__{dim}` table. If
|
|
670
|
-
* no same-space local fallback is configured, it warns and skips
|
|
671
|
-
* index (the JSONL append is the source of truth; reindex recovers)
|
|
672
|
-
* not crash. Keyed by `id` (upsert, idempotent).
|
|
550
|
+
* namespaced `col_{collection}__{model}__{dim}` table. If the remote endpoint is
|
|
551
|
+
* offline and no same-space local fallback is configured, it warns and skips
|
|
552
|
+
* the vector index (the JSONL append is the source of truth; reindex recovers)
|
|
553
|
+
* — it does not crash. Keyed by `id` (upsert, idempotent).
|
|
673
554
|
*/
|
|
674
|
-
const upsertEntryModal = async (dataDir, collectionName, entry, cfg) => {
|
|
555
|
+
const upsertEntryModal = async (dataDir, collectionName, entry, cfg, identity) => {
|
|
675
556
|
const model = resolveModel(cfg, collectionName);
|
|
676
557
|
const text = entry.fact || "";
|
|
677
558
|
let vector;
|
|
@@ -685,7 +566,7 @@ const upsertEntryModal = async (dataDir, collectionName, entry, cfg) => {
|
|
|
685
566
|
vector = fb;
|
|
686
567
|
}
|
|
687
568
|
else {
|
|
688
|
-
console.warn(`[pi-vault-mind]
|
|
569
|
+
console.warn(`[pi-vault-mind] Remote embed failed for "${collectionName}" and no same-space fallback — skipping vector index: ${err.message}`);
|
|
689
570
|
return;
|
|
690
571
|
}
|
|
691
572
|
}
|
|
@@ -700,31 +581,37 @@ const upsertEntryModal = async (dataDir, collectionName, entry, cfg) => {
|
|
|
700
581
|
tag: entry.tag || "",
|
|
701
582
|
artifact: entry.artifact || "",
|
|
702
583
|
created_at: entry.created_at || new Date().toISOString(),
|
|
584
|
+
origin_identity: identity?.id || "",
|
|
585
|
+
visibility: identity?.visibility || "vault",
|
|
586
|
+
identity: entry.identity || identity?.id || "",
|
|
703
587
|
vector,
|
|
704
588
|
};
|
|
705
589
|
await table.mergeInsert(["id"]).whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([row]);
|
|
706
590
|
};
|
|
707
|
-
/** Offline fallback embed for a document (storage). Same-space
|
|
591
|
+
/** Offline fallback embed for a document (storage). Same-space local only. */
|
|
708
592
|
const fallbackDocumentEmbed = async (cfg, collection, text) => {
|
|
709
593
|
const fb = cfg.embedding.modal?.fallback;
|
|
710
594
|
if (fb?.enabled === false)
|
|
711
595
|
return null;
|
|
712
|
-
if (fb?.provider !== "
|
|
596
|
+
if (fb?.provider !== "openai-compatible")
|
|
597
|
+
return null;
|
|
598
|
+
if (!cfg.embedding.localUrl)
|
|
713
599
|
return null;
|
|
714
600
|
const canonical = resolveModel(cfg, collection);
|
|
715
|
-
if ((cfg.embedding.
|
|
601
|
+
if ((cfg.embedding.model || "embeddinggemma") !== canonical)
|
|
716
602
|
return null;
|
|
717
603
|
try {
|
|
718
|
-
const fn = new
|
|
719
|
-
|
|
720
|
-
|
|
604
|
+
const fn = new OpenAICompatibleEmbeddingFunction({
|
|
605
|
+
url: cfg.embedding.localUrl,
|
|
606
|
+
model: cfg.embedding.model || "embeddinggemma",
|
|
607
|
+
apiKey: cfg.embedding.apiKey,
|
|
721
608
|
});
|
|
722
609
|
await fn.init();
|
|
723
610
|
const vecs = await fn.generateEmbeddings([text]);
|
|
724
611
|
return vecs[0];
|
|
725
612
|
}
|
|
726
613
|
catch (err) {
|
|
727
|
-
console.warn(`[pi-vault-mind]
|
|
614
|
+
console.warn(`[pi-vault-mind] Remote offline and local document fallback failed: ${err.message}`);
|
|
728
615
|
return null;
|
|
729
616
|
}
|
|
730
617
|
};
|
|
@@ -734,7 +621,7 @@ const fallbackDocumentEmbed = async (cfg, collection, text) => {
|
|
|
734
621
|
* `id` (merge-insert). Bypasses the auto-embed source field entirely. Carries
|
|
735
622
|
* text + metadata. Idempotent (re-fetching a boundary row is a no-op).
|
|
736
623
|
*/
|
|
737
|
-
export const upsertPrecomputed = async (dataDir, collectionName, model, dim, rows, cfg) => {
|
|
624
|
+
export const upsertPrecomputed = async (dataDir, collectionName, model, dim, rows, cfg, identity) => {
|
|
738
625
|
if (rows.length === 0)
|
|
739
626
|
return;
|
|
740
627
|
const table = await getModalCollectionTable(dataDir, collectionName, model, dim, cfg);
|
|
@@ -751,27 +638,81 @@ export const upsertPrecomputed = async (dataDir, collectionName, model, dim, row
|
|
|
751
638
|
tag: String(meta.tag ?? r.tag ?? ""),
|
|
752
639
|
artifact: String(meta.artifact ?? r.artifact ?? ""),
|
|
753
640
|
created_at: String(r.created_at ?? meta.created_at ?? new Date().toISOString()),
|
|
641
|
+
origin_identity: identity?.id || "",
|
|
642
|
+
visibility: identity?.visibility || "vault",
|
|
643
|
+
identity: String(meta.identity ?? r.identity ?? identity?.id ?? ""),
|
|
754
644
|
vector: vec,
|
|
755
645
|
};
|
|
756
646
|
});
|
|
757
647
|
await table.mergeInsert(["id"]).whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(mapped);
|
|
758
648
|
};
|
|
759
|
-
|
|
649
|
+
/** Filter results by visibility: show public entries + entries owned by the reader. No identity = no filter (backward compat). */
|
|
650
|
+
function filterByVisibility(results, identity) {
|
|
651
|
+
if (!identity)
|
|
652
|
+
return results;
|
|
653
|
+
return results.filter((r) => {
|
|
654
|
+
const vis = r.visibility;
|
|
655
|
+
const origin = r.origin_identity;
|
|
656
|
+
return vis === "public" || origin === identity.id;
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Deterministic JSONL query for a collection. Applies optional substring search,
|
|
661
|
+
* exact field filters, identity visibility filter, and pagination.
|
|
662
|
+
*/
|
|
663
|
+
export const queryCollection = async (collectionPath, schema, query, filters, limit, offset, identity) => {
|
|
664
|
+
const safeLimit = Math.max(0, Number.isFinite(limit) ? limit : 50);
|
|
665
|
+
const safeOffset = Math.max(0, Number.isFinite(offset) ? offset : 0);
|
|
666
|
+
if (!fs.existsSync(collectionPath))
|
|
667
|
+
return [];
|
|
668
|
+
const lines = fs.readFileSync(collectionPath, "utf-8").split("\n").filter(Boolean);
|
|
669
|
+
const matches = [];
|
|
670
|
+
const lowerQ = query ? query.toLowerCase() : null;
|
|
671
|
+
for (const line of lines) {
|
|
672
|
+
try {
|
|
673
|
+
const entry = JSON.parse(line);
|
|
674
|
+
let match = true;
|
|
675
|
+
if (lowerQ) {
|
|
676
|
+
const text = schema
|
|
677
|
+
.map((f) => entry[f] ?? "")
|
|
678
|
+
.join(" ")
|
|
679
|
+
.toLowerCase();
|
|
680
|
+
match = text.includes(lowerQ);
|
|
681
|
+
}
|
|
682
|
+
if (match && filters) {
|
|
683
|
+
for (const [k, v] of Object.entries(filters)) {
|
|
684
|
+
if (String(entry[k] ?? "") !== String(v)) {
|
|
685
|
+
match = false;
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (match)
|
|
691
|
+
matches.push(entry);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
/* ignore malformed */
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const visible = filterByVisibility(matches, identity);
|
|
698
|
+
return safeLimit === 0 ? [] : visible.slice(safeOffset, safeOffset + safeLimit);
|
|
699
|
+
};
|
|
700
|
+
export const searchHybrid = async (dataDir, collectionName, query, limit, cfg, identity) => {
|
|
760
701
|
if (isModal(cfg))
|
|
761
|
-
return searchHybridModal(dataDir, collectionName, query, limit, cfg);
|
|
702
|
+
return searchHybridModal(dataDir, collectionName, query, limit, cfg, identity);
|
|
762
703
|
const table = await getCollectionTable(dataDir, collectionName, cfg);
|
|
763
704
|
const embeddingFn = await getEmbeddingFunction(cfg);
|
|
764
705
|
const queryVector = await embeddingFn.generateEmbeddings([query]);
|
|
765
706
|
const results = await table.query().nearestTo(queryVector[0]).limit(limit).toArray();
|
|
766
|
-
return results;
|
|
707
|
+
return filterByVisibility(results, identity);
|
|
767
708
|
};
|
|
768
709
|
/**
|
|
769
710
|
* Modal search path: embed the query immediately (bypasses the coalescer
|
|
770
|
-
* debounce), then nearest-neighbor search the namespaced table. If
|
|
771
|
-
* offline, fall back to a same-space local provider when available;
|
|
772
|
-
* degrade to FTS/keyword search. Never crashes a search.
|
|
711
|
+
* debounce), then nearest-neighbor search the namespaced table. If the remote
|
|
712
|
+
* endpoint is offline, fall back to a same-space local provider when available;
|
|
713
|
+
* otherwise degrade to FTS/keyword search. Never crashes a search.
|
|
773
714
|
*/
|
|
774
|
-
const searchHybridModal = async (dataDir, collectionName, query, limit, cfg) => {
|
|
715
|
+
const searchHybridModal = async (dataDir, collectionName, query, limit, cfg, identity) => {
|
|
775
716
|
const model = resolveModel(cfg, collectionName);
|
|
776
717
|
let queryVector;
|
|
777
718
|
try {
|
|
@@ -781,8 +722,8 @@ const searchHybridModal = async (dataDir, collectionName, query, limit, cfg) =>
|
|
|
781
722
|
// Offline: same-space local fallback, else degrade to FTS.
|
|
782
723
|
const fb = await fallbackQueryEmbed(cfg, collectionName, query);
|
|
783
724
|
if (!fb) {
|
|
784
|
-
console.warn(`[pi-vault-mind]
|
|
785
|
-
return modalFtsFallback(dataDir, collectionName, model, query, limit);
|
|
725
|
+
console.warn(`[pi-vault-mind] Remote search offline, degrading to FTS for "${collectionName}": ${err.message}`);
|
|
726
|
+
return modalFtsFallback(dataDir, collectionName, model, query, limit, identity);
|
|
786
727
|
}
|
|
787
728
|
queryVector = fb;
|
|
788
729
|
}
|
|
@@ -792,17 +733,18 @@ const searchHybridModal = async (dataDir, collectionName, query, limit, cfg) =>
|
|
|
792
733
|
// Guard against space mismatch (shouldn't happen with one canonical model).
|
|
793
734
|
if (found.dim !== queryVector.length) {
|
|
794
735
|
console.warn(`[pi-vault-mind] Query dim ${queryVector.length} ≠ table dim ${found.dim} for "${collectionName}"; degrading to FTS.`);
|
|
795
|
-
return modalFtsFallback(dataDir, collectionName, model, query, limit);
|
|
736
|
+
return modalFtsFallback(dataDir, collectionName, model, query, limit, identity);
|
|
796
737
|
}
|
|
797
738
|
try {
|
|
798
|
-
|
|
739
|
+
const results = await found.table.query().nearestTo(queryVector).limit(limit).toArray();
|
|
740
|
+
return filterByVisibility(results, identity);
|
|
799
741
|
}
|
|
800
742
|
catch (err) {
|
|
801
|
-
console.warn(`[pi-vault-mind]
|
|
802
|
-
return modalFtsFallback(dataDir, collectionName, model, query, limit);
|
|
743
|
+
console.warn(`[pi-vault-mind] Remote vector search failed, degrading to FTS: ${err.message}`);
|
|
744
|
+
return modalFtsFallback(dataDir, collectionName, model, query, limit, identity);
|
|
803
745
|
}
|
|
804
746
|
};
|
|
805
|
-
export const searchFts = async (dataDir, collectionName, query, limit, cfg) => {
|
|
747
|
+
export const searchFts = async (dataDir, collectionName, query, limit, cfg, identity) => {
|
|
806
748
|
// For modal, FTS targets the namespaced table (the same one sync + query use).
|
|
807
749
|
if (isModal(cfg)) {
|
|
808
750
|
const model = resolveModel(cfg, collectionName);
|
|
@@ -810,7 +752,8 @@ export const searchFts = async (dataDir, collectionName, query, limit, cfg) => {
|
|
|
810
752
|
if (!found)
|
|
811
753
|
return [];
|
|
812
754
|
try {
|
|
813
|
-
|
|
755
|
+
const results = await found.table.search(query).limit(limit).toArray();
|
|
756
|
+
return filterByVisibility(results, identity);
|
|
814
757
|
}
|
|
815
758
|
catch {
|
|
816
759
|
return [];
|
|
@@ -818,7 +761,7 @@ export const searchFts = async (dataDir, collectionName, query, limit, cfg) => {
|
|
|
818
761
|
}
|
|
819
762
|
const table = await getCollectionTable(dataDir, collectionName, cfg);
|
|
820
763
|
const results = await table.search(query).limit(limit).toArray();
|
|
821
|
-
return results;
|
|
764
|
+
return filterByVisibility(results, identity);
|
|
822
765
|
};
|
|
823
766
|
export const getStatus = async (dataDir) => {
|
|
824
767
|
const conn = await connect(dataDir);
|