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.
Files changed (119) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +44 -44
  3. package/agents/broadcaster.agent.md +30 -0
  4. package/agents/heavy-lifter.agent.md +30 -0
  5. package/agents/manager.agent.md +29 -0
  6. package/agents/miner.agent.md +29 -0
  7. package/dist/src/agent-bus/Agent.d.ts +49 -0
  8. package/dist/src/agent-bus/Agent.js +97 -0
  9. package/dist/src/agent-bus/AgentLoader.d.ts +33 -0
  10. package/dist/src/agent-bus/AgentLoader.js +102 -0
  11. package/dist/src/agent-bus/AgentRegistry.d.ts +17 -0
  12. package/dist/src/agent-bus/AgentRegistry.js +20 -0
  13. package/dist/src/agent-bus/LLMProvider.d.ts +36 -0
  14. package/dist/src/agent-bus/LLMProvider.js +125 -0
  15. package/dist/src/agent-bus/MessageBus.d.ts +47 -0
  16. package/dist/src/agent-bus/MessageBus.js +81 -0
  17. package/dist/src/agent-bus/TaskQueue.d.ts +27 -0
  18. package/dist/src/agent-bus/TaskQueue.js +57 -0
  19. package/dist/src/agent-bus/index.d.ts +6 -0
  20. package/dist/src/agent-bus/index.js +6 -0
  21. package/dist/src/agents/BroadcasterAgent.d.ts +19 -0
  22. package/dist/src/agents/BroadcasterAgent.js +70 -0
  23. package/dist/src/agents/HeavyLifterAgent.d.ts +26 -0
  24. package/dist/src/agents/HeavyLifterAgent.js +73 -0
  25. package/dist/src/agents/ManagerAgent.d.ts +20 -0
  26. package/dist/src/agents/ManagerAgent.js +71 -0
  27. package/dist/src/agents/MinerAgent.d.ts +19 -0
  28. package/dist/src/agents/MinerAgent.js +70 -0
  29. package/dist/src/agents/index.d.ts +4 -0
  30. package/dist/src/agents/index.js +4 -0
  31. package/dist/src/auth.d.ts +49 -2
  32. package/dist/src/auth.js +149 -7
  33. package/dist/src/autosync.d.ts +1 -1
  34. package/dist/src/autosync.js +3 -3
  35. package/dist/src/bridge.d.ts +45 -0
  36. package/dist/src/bridge.js +189 -0
  37. package/dist/src/commands.d.ts +1 -1
  38. package/dist/src/commands.js +419 -295
  39. package/dist/src/context-capture.d.ts +22 -0
  40. package/dist/src/context-capture.js +41 -0
  41. package/dist/src/discover-schema.d.ts +1 -1
  42. package/dist/src/discover-schema.js +4 -4
  43. package/dist/src/embed-queue.d.ts +1 -1
  44. package/dist/src/embed-queue.js +1 -1
  45. package/dist/src/engine.d.ts +65 -0
  46. package/dist/src/engine.js +244 -0
  47. package/dist/src/events.js +1 -1
  48. package/dist/src/graph.d.ts +3 -3
  49. package/dist/src/identity-injector.d.ts +15 -0
  50. package/dist/src/identity-injector.js +102 -0
  51. package/dist/src/index.js +88 -49
  52. package/dist/src/intake.js +4 -4
  53. package/dist/src/lance.d.ts +11 -37
  54. package/dist/src/lance.js +183 -240
  55. package/dist/src/modal-client.d.ts +14 -4
  56. package/dist/src/modal-client.js +50 -34
  57. package/dist/src/modal-config.d.ts +26 -10
  58. package/dist/src/modal-config.js +36 -5
  59. package/dist/src/personalize.d.ts +47 -0
  60. package/dist/src/personalize.js +352 -0
  61. package/dist/src/server.d.ts +30 -4
  62. package/dist/src/server.js +146 -64
  63. package/dist/src/settings-ui.d.ts +3 -1
  64. package/dist/src/settings-ui.js +84 -77
  65. package/dist/src/status.d.ts +25 -0
  66. package/dist/src/status.js +49 -0
  67. package/dist/src/sync.d.ts +1 -1
  68. package/dist/src/sync.js +19 -19
  69. package/dist/src/tombstone.js +2 -2
  70. package/dist/src/tools.js +112 -130
  71. package/dist/src/types.d.ts +86 -18
  72. package/dist/src/types.js +7 -4
  73. package/dist/src/utils.d.ts +5 -1
  74. package/dist/src/utils.js +24 -8
  75. package/dist/src/vault-tools.d.ts +29 -0
  76. package/dist/src/vault-tools.js +314 -0
  77. package/dist/src/vault-writer.d.ts +4 -5
  78. package/dist/src/vault-writer.js +13 -13
  79. package/dist/src/vm-handlers.d.ts +15 -0
  80. package/dist/src/vm-handlers.js +469 -0
  81. package/dist/src/watcher.d.ts +18 -4
  82. package/dist/src/watcher.js +109 -98
  83. package/dist/test/auth.test.js +57 -7
  84. package/dist/test/bridge-identity.test.js +388 -0
  85. package/dist/test/discover-schema.test.js +2 -2
  86. package/dist/test/engine-identity.test.js +119 -0
  87. package/dist/test/index.test.js +5 -6
  88. package/dist/test/lance-modal.test.js +7 -7
  89. package/dist/test/modal-config.test.js +51 -29
  90. package/dist/test/personalize.test.js +212 -0
  91. package/dist/test/rest-queue.test.js +1 -1
  92. package/dist/test/rest-setup.test.js +21 -21
  93. package/dist/test/{rest-wiki.test.js → rest-vm.test.js} +27 -23
  94. package/dist/test/server-ws.test.js +4 -4
  95. package/dist/test/sync.test.js +2 -2
  96. package/dist/test/tombstone.test.js +1 -1
  97. package/dist/test/vault-tools.test.js +224 -0
  98. package/package.json +2 -1
  99. package/scripts/check-deps.sh +207 -0
  100. package/scripts/e2e-ws.mjs +4 -4
  101. package/scripts/modal-e2e-smoke.mjs +1 -1
  102. package/scripts/setup-test-config.sh +1 -5
  103. package/scripts/test-slash-commands.mjs +197 -0
  104. package/skills/.agents/broadcaster.agent.md +31 -0
  105. package/skills/.agents/heavy-lifter.agent.md +31 -0
  106. package/skills/.agents/manager.agent.md +30 -0
  107. package/skills/.agents/miner.agent.md +30 -0
  108. package/skills/vault-mind/SKILL.md +50 -50
  109. package/skills/vault-mind/references/tool-reference.md +25 -25
  110. package/skills/vault-mind-broadcaster/SKILL.md +1 -1
  111. package/skills/vault-mind-heavy-lifter/SKILL.md +3 -3
  112. package/skills/vault-mind-manager/SKILL.md +7 -7
  113. package/skills/vault-mind-miner/SKILL.md +11 -11
  114. package/skills/vault-mind-setup/SKILL.md +65 -265
  115. package/skills/vault-mind-setup/references/obsidian-vault-structure.md +6 -6
  116. package/skills/vault-mind-setup/references/pi-extension-wiring.md +24 -33
  117. package/skills/vault-mind-setup/references/troubleshooting-tree.md +25 -26
  118. package/dist/src/auto-index.d.ts +0 -47
  119. 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
- * Ollama embedding function that talks to a local Ollama instance.
31
- * Uses the /api/embed endpoint with configurable model.
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 OllamaEmbeddingFunction extends TextEmbeddingFunction {
32
+ class OpenAICompatibleEmbeddingFunction extends TextEmbeddingFunction {
33
+ url;
34
34
  model;
35
- host;
35
+ apiKey;
36
36
  ndimsValue;
37
- constructor(options = {}) {
37
+ constructor(options) {
38
38
  super();
39
- this.model = options.model || "embeddinggemma";
40
- this.host = options.host || "http://127.0.0.1:11434";
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
- // Verify Ollama is reachable and the model is available
51
+ // Best-effort connectivity check
51
52
  try {
52
- const resp = await fetch(`${this.host}/api/tags`);
53
- if (!resp.ok)
54
- throw new Error(`Ollama returned ${resp.status}`);
55
- const data = (await resp.json());
56
- const modelNames = data.models?.map((m) => m.name) || [];
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 Ollama at ${this.host}: ${err}`);
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 embeddings = [];
67
- for (const text of texts) {
68
- const resp = await fetch(`${this.host}/api/embed`, {
69
- method: "POST",
70
- headers: { "Content-Type": "application/json" },
71
- body: JSON.stringify({ model: this.model, input: text }),
72
- });
73
- if (!resp.ok) {
74
- const body = await resp.text().catch(() => "");
75
- throw new Error(`Ollama embed failed: ${resp.status} ${body}`);
76
- }
77
- const data = (await resp.json());
78
- if (!data.embeddings?.[0]) {
79
- throw new Error("Ollama returned no embedding for input");
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
- return embeddings;
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
- return await found.table.search(query).limit(limit).toArray();
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 Modal is unreachable and a
334
- * same-space local provider is configured (same canonical model → same vector
335
- * space → can query the same namespaced table). Returns null when no same-space
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 !== "ollama")
343
- return null; // transformers is 384-dim ≠ canonical space
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
- // Same-space only: the local Ollama model must match the canonical model.
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 OllamaEmbeddingFunction({
350
- model: cfg.embedding.ollamaModel || "embeddinggemma",
351
- host: cfg.embedding.ollamaHost || "http://127.0.0.1:11434",
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] Modal offline and Ollama fallback failed: ${err.message}`);
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.provider === "modal") {
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.provider === "ollama") {
529
- const fn = new OllamaEmbeddingFunction({
530
- model: cfg.embedding.ollamaModel || "embeddinggemma",
531
- host: cfg.embedding.ollamaHost || "http://127.0.0.1:11434",
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
- const fn = new TransformersEmbeddingFunction();
537
- await fn.init();
538
- return fn;
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 modal provider the dim may be unresolved until
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.provider === "modal" && effectiveDim(cfg) == null) {
614
- throw new Error(`Graph ${kind} table needs a known embedding dimension under the modal provider. Set wiki.embedding.modal.dim, or run an embed/sync first so the native dim is cached.`);
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.provider === "modal";
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 Modal is offline and
670
- * no same-space local fallback is configured, it warns and skips the vector
671
- * index (the JSONL append is the source of truth; reindex recovers) — it does
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] Modal embed failed for "${collectionName}" and no same-space fallback — skipping vector index: ${err.message}`);
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 ollama only. */
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 !== "ollama")
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.ollamaModel || "embeddinggemma") !== canonical)
601
+ if ((cfg.embedding.model || "embeddinggemma") !== canonical)
716
602
  return null;
717
603
  try {
718
- const fn = new OllamaEmbeddingFunction({
719
- model: cfg.embedding.ollamaModel || "embeddinggemma",
720
- host: cfg.embedding.ollamaHost || "http://127.0.0.1:11434",
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] Modal offline and Ollama document fallback failed: ${err.message}`);
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
- export const searchHybrid = async (dataDir, collectionName, query, limit, cfg) => {
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 Modal is
771
- * offline, fall back to a same-space local provider when available; otherwise
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] Modal search offline, degrading to FTS for "${collectionName}": ${err.message}`);
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
- return await found.table.query().nearestTo(queryVector).limit(limit).toArray();
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] Modal vector search failed, degrading to FTS: ${err.message}`);
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
- return await found.table.search(query).limit(limit).toArray();
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);