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
@@ -0,0 +1,388 @@
1
+ /**
2
+ * P0-F7: Integration tests for dispatch + identity gating.
3
+ *
4
+ * Covers:
5
+ * 1. isToolAllowed — registry allowlist enforcement
6
+ * 2. buildIdentityFromRole — per-role baseline tool + publish scopes
7
+ * 3. handleVmAppend — writeCollections guard (via bridge dispatch)
8
+ * 4. handleVmSync / handleVmPromote — canPublish guard (via bridge dispatch)
9
+ *
10
+ * No module mocks: the real exported functions are called directly (items 1–2)
11
+ * or reached through the real bridge dispatch (items 3–4) so that removing any
12
+ * guard breaks the corresponding assertion.
13
+ */
14
+ import { strict as assert } from "node:assert";
15
+ import { randomUUID } from "node:crypto";
16
+ import * as fs from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import * as path from "node:path";
19
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
20
+ import { MessageBus } from "../src/agent-bus/MessageBus.js";
21
+ import { createBridge, isToolAllowed, registerAgentIdentity, unregisterAgentIdentity, } from "../src/bridge.js";
22
+ import { buildIdentityFromRole } from "../src/engine.js";
23
+ import { registerAllExtensionTools } from "../src/vm-handlers.js";
24
+ // ── Shared bus + bridge (one per file — module-level handlers are singletons) ─
25
+ const fakePi = {
26
+ sendUserMessage: () => { },
27
+ };
28
+ let bus;
29
+ before(() => {
30
+ bus = new MessageBus();
31
+ // Register vm_* handlers into the bridge module's extensionToolHandlers map.
32
+ registerAllExtensionTools(bus);
33
+ // Subscribe the bridge to pi_tool_bridge_requests on this bus.
34
+ createBridge(fakePi, bus);
35
+ });
36
+ // ── Bridge dispatch helper ────────────────────────────────────────────────────
37
+ /**
38
+ * Publish a tool request through the bridge and return the response payload.
39
+ * `response.error` — set when isToolAllowed rejected the request
40
+ * `response.result` — ToolResult from the handler ({ content, details })
41
+ */
42
+ async function callTool(agentId, toolName, args) {
43
+ const requestId = randomUUID();
44
+ const responseTopic = `bridge_resp_${requestId}`;
45
+ let response;
46
+ const responseHandler = (msg) => {
47
+ response = msg.payload;
48
+ };
49
+ bus.subscribe(responseTopic, responseHandler);
50
+ await bus.publish("pi_tool_bridge_requests", "test-client", {
51
+ requestId,
52
+ agentId,
53
+ toolName,
54
+ args,
55
+ responseTopic,
56
+ });
57
+ bus.unsubscribe(responseTopic, responseHandler);
58
+ if (response === undefined)
59
+ throw new Error(`No bridge response for ${toolName}`);
60
+ return response;
61
+ }
62
+ /**
63
+ * Extract the first text item from a ToolResult response.
64
+ * If the bridge rejected at the isToolAllowed level the error is prefixed "Error:".
65
+ */
66
+ function getResultText(resp) {
67
+ if (resp.error)
68
+ return `Error: ${resp.error}`;
69
+ const r = resp.result;
70
+ return r?.content?.[0]?.text ?? "";
71
+ }
72
+ // ── Config fixture ────────────────────────────────────────────────────────────
73
+ /** Write a minimal project config. dataDir must be an absolute path outside dir to avoid
74
+ * cleanup races with the background upsertEntry call in handleVmAppend. */
75
+ function writeProjectConfig(dir, dataDir) {
76
+ fs.writeFileSync(path.join(dir, "pi-vault-mind.config.json"), JSON.stringify({
77
+ version: 2,
78
+ collections: {
79
+ main: {
80
+ path: "collections/main.jsonl",
81
+ schema: ["id", "fact"],
82
+ dedupField: "fact",
83
+ },
84
+ research: {
85
+ path: "collections/research.jsonl",
86
+ schema: ["id", "fact"],
87
+ dedupField: "fact",
88
+ },
89
+ },
90
+ injectors: [],
91
+ vaultMind: {
92
+ dataDir,
93
+ embedding: { provider: "transformers" },
94
+ ftsEnabled: false,
95
+ graph: { enabled: false },
96
+ },
97
+ }), "utf-8");
98
+ }
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // 1. isToolAllowed — registry gating
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ describe("isToolAllowed — registry gating", () => {
103
+ const agentId = `test-allow-${randomUUID()}`;
104
+ before(() => {
105
+ registerAgentIdentity({
106
+ id: agentId,
107
+ role: "test",
108
+ allowedTools: ["read", "vm_search"],
109
+ writeCollections: [],
110
+ canPublish: false,
111
+ });
112
+ });
113
+ after(() => {
114
+ unregisterAgentIdentity(agentId);
115
+ });
116
+ it("returns true for tools in the allowlist", () => {
117
+ assert.equal(isToolAllowed(agentId, "read"), true);
118
+ assert.equal(isToolAllowed(agentId, "vm_search"), true);
119
+ });
120
+ it("returns false for tools not in the allowlist", () => {
121
+ assert.equal(isToolAllowed(agentId, "vm_append"), false);
122
+ assert.equal(isToolAllowed(agentId, "vm_sync"), false);
123
+ assert.equal(isToolAllowed(agentId, "bash"), false);
124
+ });
125
+ it("returns false for an unknown agent id", () => {
126
+ assert.equal(isToolAllowed(`no-such-agent-${randomUUID()}`, "read"), false);
127
+ });
128
+ it("unregisterAgentIdentity removes the entry so isToolAllowed returns false", () => {
129
+ const ephId = `ephemeral-${randomUUID()}`;
130
+ registerAgentIdentity({
131
+ id: ephId,
132
+ role: "test",
133
+ allowedTools: ["read"],
134
+ writeCollections: [],
135
+ canPublish: false,
136
+ });
137
+ assert.equal(isToolAllowed(ephId, "read"), true, "should be allowed before unregister");
138
+ unregisterAgentIdentity(ephId);
139
+ assert.equal(isToolAllowed(ephId, "read"), false, "should be denied after unregister");
140
+ });
141
+ });
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // 2. Role baselines via buildIdentityFromRole
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+ describe("buildIdentityFromRole — per-role gating baselines", () => {
146
+ const ids = {
147
+ heavyLifter: `hl-${randomUUID()}`,
148
+ miner: `miner-${randomUUID()}`,
149
+ manager: `mgr-${randomUUID()}`,
150
+ };
151
+ before(() => {
152
+ registerAgentIdentity(buildIdentityFromRole("heavy-lifter", ids.heavyLifter));
153
+ registerAgentIdentity(buildIdentityFromRole("miner", ids.miner));
154
+ registerAgentIdentity(buildIdentityFromRole("manager", ids.manager));
155
+ });
156
+ after(() => {
157
+ for (const id of Object.values(ids))
158
+ unregisterAgentIdentity(id);
159
+ });
160
+ it("heavy-lifter: vm_append and vm_sync are NOT allowed", () => {
161
+ assert.equal(isToolAllowed(ids.heavyLifter, "vm_append"), false, "heavy-lifter must not get vm_append");
162
+ assert.equal(isToolAllowed(ids.heavyLifter, "vm_sync"), false, "heavy-lifter must not get vm_sync");
163
+ });
164
+ it("miner: vm_append is allowed; vm_sync is NOT", () => {
165
+ assert.equal(isToolAllowed(ids.miner, "vm_append"), true, "miner needs vm_append");
166
+ assert.equal(isToolAllowed(ids.miner, "vm_sync"), false, "miner must not get vm_sync");
167
+ });
168
+ it("manager: vm_sync is allowed AND canPublish===true", () => {
169
+ assert.equal(isToolAllowed(ids.manager, "vm_sync"), true, "manager needs vm_sync");
170
+ const mgr = buildIdentityFromRole("manager", "any");
171
+ assert.equal(mgr.canPublish, true, "manager canPublish must be true");
172
+ });
173
+ it("miner: writeCollections is exactly ['main', 'research']", () => {
174
+ const miner = buildIdentityFromRole("miner", "any");
175
+ assert.deepEqual(miner.writeCollections, ["main", "research"]);
176
+ });
177
+ it("heavy-lifter: writeCollections is empty", () => {
178
+ const hl = buildIdentityFromRole("heavy-lifter", "any");
179
+ assert.deepEqual(hl.writeCollections, []);
180
+ assert.equal(hl.canPublish, false);
181
+ });
182
+ });
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // 3. handleVmAppend — writeCollections guard (via bridge dispatch)
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ describe("handleVmAppend — writeCollections guard", () => {
187
+ let testDir;
188
+ let lanceDir;
189
+ let origCwd;
190
+ let origHome;
191
+ const agentIdDenied = `vm-append-denied-${randomUUID()}`;
192
+ const agentIdAllowed = `vm-append-allowed-${randomUUID()}`;
193
+ beforeEach(() => {
194
+ origCwd = process.cwd();
195
+ origHome = process.env.HOME;
196
+ testDir = path.join(tmpdir(), `pvm-bridge-id-${randomUUID()}`);
197
+ // Keep lancedb data outside testDir so the background async upsert
198
+ // can't race with rmSync(testDir) during afterEach cleanup.
199
+ lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
200
+ fs.mkdirSync(testDir, { recursive: true });
201
+ fs.mkdirSync(lanceDir, { recursive: true });
202
+ writeProjectConfig(testDir, lanceDir);
203
+ // Isolate from the user's global vault config so loadConfig only
204
+ // sees the project config we wrote above.
205
+ process.env.HOME = testDir;
206
+ process.env.USERPROFILE = testDir;
207
+ process.chdir(testDir);
208
+ });
209
+ afterEach(() => {
210
+ process.chdir(origCwd);
211
+ if (origHome !== undefined) {
212
+ process.env.HOME = origHome;
213
+ process.env.USERPROFILE = origHome;
214
+ }
215
+ unregisterAgentIdentity(agentIdDenied);
216
+ unregisterAgentIdentity(agentIdAllowed);
217
+ if (fs.existsSync(testDir))
218
+ fs.rmSync(testDir, { recursive: true, force: true });
219
+ if (fs.existsSync(lanceDir))
220
+ fs.rmSync(lanceDir, { recursive: true, force: true });
221
+ });
222
+ it("rejects when collection is not in writeCollections", async () => {
223
+ registerAgentIdentity({
224
+ id: agentIdDenied,
225
+ role: "heavy-lifter",
226
+ allowedTools: ["vm_append"],
227
+ writeCollections: [], // no write access — "main" excluded
228
+ canPublish: false,
229
+ });
230
+ const resp = await callTool(agentIdDenied, "vm_append", {
231
+ collection: "main",
232
+ entry: { id: "x1", fact: "guarded test" },
233
+ });
234
+ const text = getResultText(resp);
235
+ assert.ok(text.startsWith("Error:"), `Expected an error response, got: "${text}"`);
236
+ assert.ok(text.includes("not allowed"), `Expected "not allowed" in error, got: "${text}"`);
237
+ });
238
+ it("succeeds when collection is in writeCollections", async () => {
239
+ registerAgentIdentity({
240
+ id: agentIdAllowed,
241
+ role: "miner",
242
+ allowedTools: ["vm_append"],
243
+ writeCollections: ["main", "research"], // has write access
244
+ canPublish: false,
245
+ });
246
+ const resp = await callTool(agentIdAllowed, "vm_append", {
247
+ collection: "main",
248
+ entry: { id: "x2", fact: "allowed append" },
249
+ });
250
+ const text = getResultText(resp);
251
+ assert.ok(!text.startsWith("Error:"), `Expected success, got: "${text}"`);
252
+ assert.ok(text.includes("Appended"), `Expected "Appended" in success text, got: "${text}"`);
253
+ });
254
+ });
255
+ // ─────────────────────────────────────────────────────────────────────────────
256
+ // 4a. handleVmSync — canPublish guard (via bridge dispatch)
257
+ // ─────────────────────────────────────────────────────────────────────────────
258
+ describe("handleVmSync — canPublish guard", () => {
259
+ let testDir;
260
+ let lanceDir;
261
+ let origCwd;
262
+ let origHome;
263
+ const agentIdDenied = `vm-sync-denied-${randomUUID()}`;
264
+ const agentIdAllowed = `vm-sync-allowed-${randomUUID()}`;
265
+ beforeEach(() => {
266
+ origCwd = process.cwd();
267
+ origHome = process.env.HOME;
268
+ testDir = path.join(tmpdir(), `pvm-bridge-sync-${randomUUID()}`);
269
+ lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
270
+ fs.mkdirSync(testDir, { recursive: true });
271
+ fs.mkdirSync(lanceDir, { recursive: true });
272
+ writeProjectConfig(testDir, lanceDir);
273
+ // Isolate from the user's global vault config.
274
+ process.env.HOME = testDir;
275
+ process.env.USERPROFILE = testDir;
276
+ process.chdir(testDir);
277
+ });
278
+ afterEach(() => {
279
+ process.chdir(origCwd);
280
+ if (origHome !== undefined) {
281
+ process.env.HOME = origHome;
282
+ process.env.USERPROFILE = origHome;
283
+ }
284
+ unregisterAgentIdentity(agentIdDenied);
285
+ unregisterAgentIdentity(agentIdAllowed);
286
+ if (fs.existsSync(testDir))
287
+ fs.rmSync(testDir, { recursive: true, force: true });
288
+ if (fs.existsSync(lanceDir))
289
+ fs.rmSync(lanceDir, { recursive: true, force: true });
290
+ });
291
+ it("rejects with authorization error when canPublish===false", async () => {
292
+ // Custom identity: vm_sync in allowlist so the bridge lets it through,
293
+ // but canPublish=false so the handler's guard fires.
294
+ registerAgentIdentity({
295
+ id: agentIdDenied,
296
+ role: "miner",
297
+ allowedTools: ["vm_sync"],
298
+ writeCollections: [],
299
+ canPublish: false,
300
+ });
301
+ const resp = await callTool(agentIdDenied, "vm_sync", {
302
+ vault: "default",
303
+ });
304
+ const text = getResultText(resp);
305
+ assert.ok(text.startsWith("Error:"), `Expected an auth error, got: "${text}"`);
306
+ assert.ok(text.includes("not authorized to publish"), `Expected "not authorized to publish", got: "${text}"`);
307
+ });
308
+ it("passes the canPublish guard when canPublish===true (manager role)", async () => {
309
+ const mgr = buildIdentityFromRole("manager", agentIdAllowed);
310
+ registerAgentIdentity(mgr);
311
+ const resp = await callTool(agentIdAllowed, "vm_sync", {
312
+ vault: "default",
313
+ });
314
+ const text = getResultText(resp);
315
+ // The auth guard must NOT fire. Any other error (e.g. no vaults
316
+ // configured) is acceptable; the important thing is the guard let
317
+ // the manager through.
318
+ assert.ok(!text.includes("not authorized to publish"), `Got unexpected auth error: "${text}"`);
319
+ });
320
+ });
321
+ // ─────────────────────────────────────────────────────────────────────────────
322
+ // 4b. handleVmPromote — canPublish guard (via bridge dispatch)
323
+ // ─────────────────────────────────────────────────────────────────────────────
324
+ describe("handleVmPromote — canPublish guard", () => {
325
+ let testDir;
326
+ let lanceDir;
327
+ let origCwd;
328
+ let origHome;
329
+ const agentIdDenied = `vm-promote-denied-${randomUUID()}`;
330
+ const agentIdAllowed = `vm-promote-allowed-${randomUUID()}`;
331
+ beforeEach(() => {
332
+ origCwd = process.cwd();
333
+ origHome = process.env.HOME;
334
+ testDir = path.join(tmpdir(), `pvm-bridge-promo-${randomUUID()}`);
335
+ lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
336
+ fs.mkdirSync(testDir, { recursive: true });
337
+ fs.mkdirSync(lanceDir, { recursive: true });
338
+ writeProjectConfig(testDir, lanceDir);
339
+ process.env.HOME = testDir;
340
+ process.env.USERPROFILE = testDir;
341
+ process.chdir(testDir);
342
+ });
343
+ afterEach(() => {
344
+ process.chdir(origCwd);
345
+ if (origHome !== undefined) {
346
+ process.env.HOME = origHome;
347
+ process.env.USERPROFILE = origHome;
348
+ }
349
+ unregisterAgentIdentity(agentIdDenied);
350
+ unregisterAgentIdentity(agentIdAllowed);
351
+ if (fs.existsSync(testDir))
352
+ fs.rmSync(testDir, { recursive: true, force: true });
353
+ if (fs.existsSync(lanceDir))
354
+ fs.rmSync(lanceDir, { recursive: true, force: true });
355
+ });
356
+ it("rejects with authorization error when canPublish===false", async () => {
357
+ registerAgentIdentity({
358
+ id: agentIdDenied,
359
+ role: "miner",
360
+ allowedTools: ["vm_promote"],
361
+ writeCollections: [],
362
+ canPublish: false,
363
+ });
364
+ const resp = await callTool(agentIdDenied, "vm_promote", {
365
+ sourceCollection: "main",
366
+ targetCollection: "research",
367
+ entryIds: [],
368
+ reason: "test",
369
+ });
370
+ const text = getResultText(resp);
371
+ assert.ok(text.startsWith("Error:"), `Expected an auth error, got: "${text}"`);
372
+ assert.ok(text.includes("not authorized to publish"), `Expected "not authorized to publish", got: "${text}"`);
373
+ });
374
+ it("passes the canPublish guard when canPublish===true (manager role)", async () => {
375
+ const mgr = buildIdentityFromRole("manager", agentIdAllowed);
376
+ registerAgentIdentity(mgr);
377
+ const resp = await callTool(agentIdAllowed, "vm_promote", {
378
+ sourceCollection: "main",
379
+ targetCollection: "research",
380
+ entryIds: [],
381
+ reason: "test",
382
+ });
383
+ const text = getResultText(resp);
384
+ // Guard passed; handler may error for another reason (e.g. unknown
385
+ // collection) but must NOT be the canPublish auth error.
386
+ assert.ok(!text.includes("not authorized to publish"), `Got unexpected auth error: "${text}"`);
387
+ });
388
+ });
@@ -135,7 +135,7 @@ describe("discover-schema", () => {
135
135
  const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
136
136
  fs.writeFileSync(cfgPath, JSON.stringify({
137
137
  version: 2,
138
- wiki: { dataDir: ".lancedb" },
138
+ vaultMind: { dataDir: ".lancedb" },
139
139
  collections: {
140
140
  main: { path: "collections/main.jsonl", schema: ["id", "fact"] },
141
141
  },
@@ -145,7 +145,7 @@ describe("discover-schema", () => {
145
145
  runDiscoverSchema(testDir, filePath, "new");
146
146
  const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
147
147
  assert.equal(cfg.version, 2);
148
- assert.equal(cfg.wiki.dataDir, ".lancedb");
148
+ assert.equal(cfg.vaultMind.dataDir, ".lancedb");
149
149
  assert.ok(cfg.collections.main);
150
150
  assert.ok(cfg.collections.new);
151
151
  });
@@ -0,0 +1,119 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { buildIdentityFromRole, mergeIdentityProfiles } from "../src/engine.js";
4
+ // IdentityConfig requires several fields but at runtime the merge consults
5
+ // only allowedTools / writeCollections / canPublish. Tests deliberately pass
6
+ // partials to mirror what users put in JSON.
7
+ const profile = (over) => ({
8
+ role: "miner",
9
+ readCollections: [],
10
+ allowedVaultPaths: [],
11
+ ...over,
12
+ });
13
+ describe("engine identity profiles", () => {
14
+ describe("buildIdentityFromRole — hard-coded fallback baseline", () => {
15
+ it("miner has vm_append and main/research write scope", () => {
16
+ const id = buildIdentityFromRole("miner", "miner-1");
17
+ assert.equal(id.id, "miner-1");
18
+ assert.equal(id.role, "miner");
19
+ assert.deepEqual(id.writeCollections, ["main", "research"]);
20
+ assert.equal(id.canPublish, false);
21
+ assert.ok(id.allowedTools.includes("vm_append"));
22
+ assert.ok(!id.allowedTools.includes("vm_sync"));
23
+ });
24
+ it("heavy-lifter cannot vm_append or vm_sync (hand to Miner)", () => {
25
+ const id = buildIdentityFromRole("heavy-lifter", "hl-1");
26
+ assert.ok(!id.allowedTools.includes("vm_append"));
27
+ assert.ok(!id.allowedTools.includes("vm_sync"));
28
+ assert.ok(id.allowedTools.includes("bash"));
29
+ assert.deepEqual(id.writeCollections, []);
30
+ assert.equal(id.canPublish, false);
31
+ });
32
+ it("manager is the only role with vm_sync + canPublish", () => {
33
+ const id = buildIdentityFromRole("manager", "mgr-1");
34
+ assert.ok(id.allowedTools.includes("vm_sync"));
35
+ assert.equal(id.canPublish, true);
36
+ });
37
+ it("unknown role falls back to read-only baseline", () => {
38
+ const id = buildIdentityFromRole("nope", "x-1");
39
+ assert.deepEqual(id.allowedTools, ["read", "vm_search", "vm_fts_search"]);
40
+ assert.deepEqual(id.writeCollections, []);
41
+ assert.equal(id.canPublish, false);
42
+ });
43
+ });
44
+ describe("mergeIdentityProfiles — config-driven overrides", () => {
45
+ const baseMiner = () => buildIdentityFromRole("miner", "miner-1");
46
+ it("no config layers → identical to base (byte-equal today's behavior)", () => {
47
+ const base = baseMiner();
48
+ const merged = mergeIdentityProfiles(base, undefined, undefined);
49
+ assert.deepEqual(merged, base);
50
+ // must be a copy, not the same reference (callers may mutate).
51
+ assert.notEqual(merged, base);
52
+ });
53
+ it("defaultProfile overrides hard-coded allowedTools", () => {
54
+ const base = baseMiner();
55
+ const merged = mergeIdentityProfiles(base, profile({ allowedTools: ["read", "vm_search"] }), undefined);
56
+ assert.deepEqual(merged.allowedTools, ["read", "vm_search"]);
57
+ // fields the layer is silent on stay at the base value.
58
+ assert.deepEqual(merged.writeCollections, base.writeCollections);
59
+ assert.equal(merged.canPublish, base.canPublish);
60
+ });
61
+ it("roleProfile beats defaultProfile (last wins, per field)", () => {
62
+ const base = baseMiner();
63
+ const merged = mergeIdentityProfiles(base, profile({
64
+ allowedTools: ["read"],
65
+ writeCollections: ["from-default"],
66
+ canPublish: true,
67
+ }), profile({
68
+ writeCollections: ["from-role"],
69
+ canPublish: false,
70
+ }));
71
+ // defaultProfile won where roleProfile was silent.
72
+ assert.deepEqual(merged.allowedTools, ["read"]);
73
+ // roleProfile won where it was set.
74
+ assert.deepEqual(merged.writeCollections, ["from-role"]);
75
+ assert.equal(merged.canPublish, false);
76
+ });
77
+ it("acceptance: setting roles.miner.writeCollections rescopes Miner with no code change", () => {
78
+ const base = baseMiner();
79
+ const merged = mergeIdentityProfiles(base, undefined, profile({ writeCollections: ["custom-only"] }));
80
+ assert.deepEqual(merged.writeCollections, ["custom-only"]);
81
+ // other fields untouched — allowlist still has vm_append from the hard-coded base.
82
+ assert.ok(merged.allowedTools.includes("vm_append"));
83
+ assert.equal(merged.canPublish, false);
84
+ });
85
+ it("id and role are not overridable by config (always come from base)", () => {
86
+ const base = baseMiner();
87
+ const merged = mergeIdentityProfiles(base, profile({ role: "imposter" }), profile({ role: "still-imposter" }));
88
+ assert.equal(merged.id, "miner-1");
89
+ assert.equal(merged.role, "miner");
90
+ });
91
+ it("merges P2/P3 fields (readCollections, egressAllowlist) alongside P0-F4 fields", () => {
92
+ const base = baseMiner();
93
+ const merged = mergeIdentityProfiles(base, undefined, profile({
94
+ readCollections: ["secret"],
95
+ allowedVaultPaths: ["/etc"],
96
+ tokenRef: "PVM_TOKEN_MINER",
97
+ egressAllowlist: ["example.com"],
98
+ }));
99
+ assert.deepEqual(merged.allowedVaultPaths, ["/etc"]);
100
+ assert.deepEqual(merged.readCollections, ["secret"]);
101
+ assert.deepEqual(merged.egressAllowlist, ["example.com"]);
102
+ assert.deepEqual(Object.keys(merged).sort(), [
103
+ "allowedTools",
104
+ "allowedVaultPaths",
105
+ "canPublish",
106
+ "egressAllowlist",
107
+ "id",
108
+ "readCollections",
109
+ "role",
110
+ "writeCollections",
111
+ ]);
112
+ });
113
+ it("empty arrays in a layer override a non-empty base (intentional rescope, not silent skip)", () => {
114
+ const base = baseMiner();
115
+ const merged = mergeIdentityProfiles(base, undefined, profile({ writeCollections: [] }));
116
+ assert.deepEqual(merged.writeCollections, []);
117
+ });
118
+ });
119
+ });
@@ -69,12 +69,11 @@ describe("pi-vault-mind", () => {
69
69
  },
70
70
  },
71
71
  injectors: [],
72
- wiki: {
72
+ vaultMind: {
73
73
  dataDir: ".lancedb",
74
74
  embedding: {
75
- provider: "transformers",
76
- ollamaModel: "embeddinggemma",
77
- ollamaHost: "http://127.0.0.1:11434",
75
+ model: "embeddinggemma",
76
+ localUrl: "http://127.0.0.1:11434",
78
77
  },
79
78
  graph: { enabled: false, canvasSync: false },
80
79
  ftsEnabled: false,
@@ -85,7 +84,7 @@ describe("pi-vault-mind", () => {
85
84
  assert.ok(cfg, "config should be loaded");
86
85
  assert.equal(cfg.version, 2);
87
86
  assert.ok(cfg.collections.main, "should have main collection");
88
- assert.equal(cfg.wiki.embedding.provider, "transformers");
87
+ assert.equal(cfg.vaultMind.embedding.model, "embeddinggemma");
89
88
  }
90
89
  finally {
91
90
  process.chdir(cwd);
@@ -100,7 +99,7 @@ describe("pi-vault-mind", () => {
100
99
  assert.equal(types.DEFAULT_CONFIG.version, 2);
101
100
  assert.ok(types.DEFAULT_CONFIG.collections, "collections should exist");
102
101
  assert.ok(types.DEFAULT_CONFIG.collections.main, "should have main collection");
103
- assert.equal(types.DEFAULT_CONFIG.wiki.embedding.provider, "transformers");
102
+ assert.equal(types.DEFAULT_CONFIG.vaultMind.embedding.model, "embeddinggemma");
104
103
  }
105
104
  finally {
106
105
  process.chdir(cwd);
@@ -8,10 +8,10 @@ import { connect, resetConnection, searchFts, searchHybrid, upsertPrecomputed, }
8
8
  import { MODAL_TOKEN_ENV } from "../src/modal-config.js";
9
9
  const MODEL = "testmodel";
10
10
  const DIM = 3;
11
- const wiki = (dataDir) => ({
11
+ const makeCfg = (dataDir) => ({
12
12
  dataDir,
13
13
  embedding: {
14
- provider: "modal",
14
+ remoteUrl: "https://pvm.modal.run",
15
15
  modal: { baseUrl: "https://pvm.modal.run", model: MODEL },
16
16
  coalesce: { debounceMs: 5, maxBatchSize: 1, maxConcurrentFlushes: 1 },
17
17
  },
@@ -48,14 +48,14 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
48
48
  resetConnection();
49
49
  });
50
50
  it("upsertPrecomputed imports rows into the namespaced table", async () => {
51
- const cfg = wiki(dir);
51
+ const cfg = makeCfg(dir);
52
52
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0])], cfg);
53
53
  const conn = await connect(dir);
54
54
  const table = await conn.openTable(`col_main__${MODEL}__${DIM}`);
55
55
  assert.equal(await table.countRows(), 2);
56
56
  });
57
57
  it("upsertPrecomputed is idempotent (merge-insert by id)", async () => {
58
- const cfg = wiki(dir);
58
+ const cfg = makeCfg(dir);
59
59
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
60
60
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
61
61
  const conn = await connect(dir);
@@ -63,7 +63,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
63
63
  assert.equal(await table.countRows(), 1);
64
64
  });
65
65
  it("searchHybrid (modal) returns nearest results via the /embed query path", async () => {
66
- const cfg = wiki(dir);
66
+ const cfg = makeCfg(dir);
67
67
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0]), syncRow("3", [0, 0, 1])], cfg);
68
68
  // Mock /embed to return a query vector matching row "1".
69
69
  globalThis.fetch = (async () => ({
@@ -77,7 +77,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
77
77
  assert.equal(results[0].id, "1", "nearest neighbor is row 1");
78
78
  });
79
79
  it("searchHybrid degrades gracefully (no crash) when Modal is offline", async () => {
80
- const cfg = wiki(dir);
80
+ const cfg = makeCfg(dir);
81
81
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "authentication tokens")], cfg);
82
82
  // Offline: every fetch rejects.
83
83
  globalThis.fetch = (async () => {
@@ -87,7 +87,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
87
87
  assert.ok(Array.isArray(results), "offline search returns an array, never throws");
88
88
  });
89
89
  it("searchFts (modal) targets the namespaced table without throwing", async () => {
90
- const cfg = wiki(dir);
90
+ const cfg = makeCfg(dir);
91
91
  await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "rare keyword match here")], cfg);
92
92
  const results = await searchFts(dir, "main", "rare", 5, cfg);
93
93
  assert.ok(Array.isArray(results));