pi-vault-mind 0.16.27 → 0.16.29
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 +24 -0
- package/README.md +1 -0
- package/dist/src/commands.js +81 -0
- package/dist/src/identities-config.js +20 -4
- package/dist/src/identity-injector.d.ts +13 -2
- package/dist/src/identity-injector.js +42 -28
- package/dist/src/scaffold.js +29 -35
- package/dist/src/server.js +21 -12
- package/dist/src/settings-ui.js +70 -29
- package/dist/src/types.d.ts +8 -0
- package/dist/src/utils.js +15 -0
- package/dist/test/commands.test.js +78 -0
- package/dist/test/identity-injector.test.js +86 -148
- package/dist/test/rest-vm.test.js +24 -0
- package/dist/test/settings-ui.test.js +5 -0
- package/dist/test/utils.test.js +13 -0
- package/package.json +1 -1
- package/skills/.agents/broadcaster.agent.md +0 -31
- package/skills/.agents/heavy-lifter.agent.md +0 -31
- package/skills/.agents/manager.agent.md +0 -30
- package/skills/.agents/miner.agent.md +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.29 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Main-terminal identity isolation.** Ambient bundled specialist skills no longer make the interactive Pi session inherit the first matching specialist boundary. Identity injection now defaults to the unrestricted main agent and only activates from an explicit vault or environment selection.
|
|
8
|
+
- **Explicit persona controls.** Added `/vm identity status | set <role> | on | off`, `VAULT_MIND_AGENT_ROLE`, and `VAULT_MIND_IDENTITY_INJECTION`, with canonical persistence under `vaultMind.identities`.
|
|
9
|
+
- **Identity configuration migration and write safety.** Legacy top-level identity settings migrate without losing role allowlists, new scaffolds use the canonical path, and malformed JSON is reported without overwriting the existing vault configuration or leaking an unhandled HTTP rejection.
|
|
10
|
+
|
|
11
|
+
### Verification
|
|
12
|
+
|
|
13
|
+
- Root and Obsidian builds passed; full test suite: 899/899 passing.
|
|
14
|
+
- Real-vault startup probe confirmed ambient Broadcaster resources keep the unrestricted main identity while an explicit Broadcaster override activates the boundary.
|
|
15
|
+
|
|
16
|
+
## 0.16.28 — 2026-08-09
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **CLI setup no longer invents vault folder structure.** `/vm setup` (and `scaffold.ts`'s fallback scaffolding) used to default `vaultMind.folders` to a guessed `Agent/Inbox`/`Agent/Library`/`Agent/Presentations`/`Agent/Journal` convention. In a real vault with its own organization scheme (PARA-style numbered folders, an existing inbox elsewhere, etc.) this silently created a stray top-level `Agent/` folder and misfiled real auto-synced content into it. The interactive wizard now shows a real folder picker — built from the vault's actual existing directories (walked up to 2 levels deep) — with explicit "Type a custom path..." and "Skip — configure later" options, matching the Obsidian plugin's existing `FoldersStep` folder-browser behavior. Headless `/vm setup` (CLI flags) and `scaffoldVaultConfig()` no longer populate a guessed folders block at all; `folders` stays unset until deliberately configured.
|
|
21
|
+
|
|
22
|
+
### Verification
|
|
23
|
+
|
|
24
|
+
- Full build + `node --test dist/test/**/*.test.js`: 894/894 passing.
|
|
25
|
+
- Added a regression test asserting a fresh headless `/vm setup` leaves `vaultMind.folders` unset rather than reinventing the old default.
|
|
26
|
+
- Reproduced the failure mode live: a real vault's auto-sync wrote 5 real notes into a newly-created `Agent/Inbox/` instead of the vault's actual `00-system/00.01-inbox/`.
|
|
3
27
|
|
|
4
28
|
## 0.16.27 — 2026-08-09
|
|
5
29
|
|
package/README.md
CHANGED
|
@@ -356,6 +356,7 @@ Edit `<vault>/.vault-mind/vault-mind.config.json` to match your domain:
|
|
|
356
356
|
| `/vm injector create` | Interactive wizard to create a new injector |
|
|
357
357
|
| `/vm context enable \| disable \| status` | Manage pi-context integration |
|
|
358
358
|
| `/vm embedding status \| use \| model \| models \| pull` | Manage embedding provider |
|
|
359
|
+
| `/vm identity status \| set <role> \| on \| off` | Inspect or explicitly control the interactive agent persona boundary |
|
|
359
360
|
| `/vm remote status \| config \| sync \| jobs \| migrate` | Manage remote embedding + vector sync |
|
|
360
361
|
| `/vm watcher start \| stop \| status` | Manage the passive file watcher |
|
|
361
362
|
| `/vm server status` | Show HTTP server status, port, and uptime |
|
package/dist/src/commands.js
CHANGED
|
@@ -8,6 +8,8 @@ import { revokeToken, rotateToken } from "./auth.js";
|
|
|
8
8
|
import { handleDiscoverSchema } from "./discover-schema.js";
|
|
9
9
|
import { writeEnvEntry } from "./embedding-secrets.js";
|
|
10
10
|
import { queryGraph } from "./graph.js";
|
|
11
|
+
import { writeIdentitiesLayer } from "./identities-config.js";
|
|
12
|
+
import { IDENTITY_BOUNDARY_ROLES, resolveIdentityBoundaryRole } from "./identity-injector.js";
|
|
11
13
|
import { runAskIntake } from "./intake.js";
|
|
12
14
|
import { connect, searchFts, searchHybrid, searchHybridRanked } from "./lance.js";
|
|
13
15
|
import { createModalClient, isModalConfigured, MODAL_TOKEN_ENV, modalTokenEnvPath, resolveModalToken, } from "./modal-config.js";
|
|
@@ -39,6 +41,9 @@ const VM_USAGE = [
|
|
|
39
41
|
" /vm index [--all] [--reembed] [--remote] Index collections (alias: reindex)",
|
|
40
42
|
" /vm discover-schema Infer schema from a JSONL file",
|
|
41
43
|
" /vm injector create Create a new injector (wizard)",
|
|
44
|
+
" /vm identity status Show configured and effective persona",
|
|
45
|
+
" /vm identity set <role> Set main|manager|miner|broadcaster|heavy-lifter",
|
|
46
|
+
" /vm identity on|off Enable or disable persona boundary injection",
|
|
42
47
|
" /vm context enable|disable Enable/disable pi-context integration",
|
|
43
48
|
" /vm embedding status Show embedding config",
|
|
44
49
|
" /vm remote status Show remote provider config + health + collections",
|
|
@@ -1616,6 +1621,74 @@ const handleToken = (args, ctx) => {
|
|
|
1616
1621
|
ctx.ui.notify(`Unknown: ${sub}. Try: show, rotate, revoke`, "error");
|
|
1617
1622
|
}
|
|
1618
1623
|
};
|
|
1624
|
+
const handleIdentity = (args, ctx) => {
|
|
1625
|
+
const parts = args.trim().split(/\s+/g).filter(Boolean);
|
|
1626
|
+
const subcommand = parts[0]?.toLowerCase() || "status";
|
|
1627
|
+
const identities = loadConfig(ctx.cwd).vaultMind.identities;
|
|
1628
|
+
const configuredRole = identities?.activeRole?.trim().toLowerCase() || "main";
|
|
1629
|
+
const updateIdentity = (patch) => {
|
|
1630
|
+
try {
|
|
1631
|
+
writeIdentitiesLayer(ctx.cwd, patch);
|
|
1632
|
+
return true;
|
|
1633
|
+
}
|
|
1634
|
+
catch (error) {
|
|
1635
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1636
|
+
ctx.ui.notify(`Failed to update identity config: ${message}`, "error");
|
|
1637
|
+
return false;
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
switch (subcommand) {
|
|
1641
|
+
case "status": {
|
|
1642
|
+
const effectiveRole = resolveIdentityBoundaryRole(ctx.cwd);
|
|
1643
|
+
const injectionEnabled = identities?.injectBoundaries !== false;
|
|
1644
|
+
const envRole = process.env.VAULT_MIND_AGENT_ROLE?.trim();
|
|
1645
|
+
const envInjection = process.env.VAULT_MIND_IDENTITY_INJECTION?.trim();
|
|
1646
|
+
ctx.ui.notify([
|
|
1647
|
+
"**Identity Boundary**",
|
|
1648
|
+
`Persona: ${configuredRole}`,
|
|
1649
|
+
`Injection: ${injectionEnabled ? "enabled" : "disabled"}`,
|
|
1650
|
+
`Effective boundary: ${effectiveRole ?? "none (main agent)"}`,
|
|
1651
|
+
...(envRole ? [`Environment role override: ${envRole}`] : []),
|
|
1652
|
+
...(envInjection ? [`Environment injection override: ${envInjection}`] : []),
|
|
1653
|
+
].join("\n"), "info");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
case "set": {
|
|
1657
|
+
const role = parts[1]?.toLowerCase();
|
|
1658
|
+
const validRole = role === "main" ||
|
|
1659
|
+
(role !== undefined &&
|
|
1660
|
+
IDENTITY_BOUNDARY_ROLES.includes(role));
|
|
1661
|
+
if (!role || !validRole) {
|
|
1662
|
+
ctx.ui.notify(`Unknown persona: ${role ?? "(missing)"}. Use main, ${IDENTITY_BOUNDARY_ROLES.join(", ")}.`, "error");
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
if (!updateIdentity({
|
|
1666
|
+
activeRole: role,
|
|
1667
|
+
injectBoundaries: true,
|
|
1668
|
+
})) {
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
ctx.ui.notify(`Persona set to ${role}. The boundary applies on the next agent turn.`, "info");
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
case "off":
|
|
1675
|
+
case "disable": {
|
|
1676
|
+
if (!updateIdentity({ injectBoundaries: false }))
|
|
1677
|
+
return;
|
|
1678
|
+
ctx.ui.notify("Identity injection disabled. The main agent remains unrestricted.", "info");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
case "on":
|
|
1682
|
+
case "enable": {
|
|
1683
|
+
if (!updateIdentity({ injectBoundaries: true }))
|
|
1684
|
+
return;
|
|
1685
|
+
ctx.ui.notify(`Identity injection enabled for ${configuredRole}. The setting applies on the next agent turn.`, "info");
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
default:
|
|
1689
|
+
ctx.ui.notify("Usage: /vm identity status | set <role> | on | off", "error");
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1619
1692
|
// ── Main /vm command ───────────────────────────────────────────────────────
|
|
1620
1693
|
export const registerCommands = (pi) => {
|
|
1621
1694
|
pi.registerCommand("vm", {
|
|
@@ -1633,6 +1706,7 @@ export const registerCommands = (pi) => {
|
|
|
1633
1706
|
"discover-schema",
|
|
1634
1707
|
"doctor",
|
|
1635
1708
|
"injector",
|
|
1709
|
+
"identity",
|
|
1636
1710
|
"context",
|
|
1637
1711
|
"embedding",
|
|
1638
1712
|
"remote",
|
|
@@ -1662,6 +1736,11 @@ export const registerCommands = (pi) => {
|
|
|
1662
1736
|
.filter((c) => c.startsWith(prefix))
|
|
1663
1737
|
.map((c) => ({ label: c, value: c, description: `context ${c}` }));
|
|
1664
1738
|
}
|
|
1739
|
+
if (subcommand === "identity") {
|
|
1740
|
+
return ["status", "set", "on", "off", "main", ...IDENTITY_BOUNDARY_ROLES]
|
|
1741
|
+
.filter((c) => c.startsWith(prefix))
|
|
1742
|
+
.map((c) => ({ label: c, value: c, description: `identity ${c}` }));
|
|
1743
|
+
}
|
|
1665
1744
|
if (subcommand === "discover-schema") {
|
|
1666
1745
|
return ["--name", "--sample"]
|
|
1667
1746
|
.filter((c) => c.startsWith(prefix))
|
|
@@ -1736,6 +1815,8 @@ export const registerCommands = (pi) => {
|
|
|
1736
1815
|
return handleCollection(rest, ctx, pi);
|
|
1737
1816
|
case "injector":
|
|
1738
1817
|
return handleInjector(rest, ctx);
|
|
1818
|
+
case "identity":
|
|
1819
|
+
return handleIdentity(rest, ctx);
|
|
1739
1820
|
case "context":
|
|
1740
1821
|
return handleContext(rest, ctx, pi);
|
|
1741
1822
|
case "embedding":
|
|
@@ -26,11 +26,27 @@ function deepMerge(base, overlay) {
|
|
|
26
26
|
*/
|
|
27
27
|
export function writeIdentitiesLayer(cwd, identities) {
|
|
28
28
|
const configPath = getConfigPath(cwd);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
let existing = {};
|
|
30
|
+
if (fs.existsSync(configPath)) {
|
|
31
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
35
|
+
throw new TypeError("the root value must be a JSON object");
|
|
36
|
+
}
|
|
37
|
+
existing = parsed;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`Invalid JSON in ${configPath}; the file was not changed.`, {
|
|
41
|
+
cause: error,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
const vaultMind = existing.vaultMind || {};
|
|
33
|
-
|
|
46
|
+
const legacyIdentities = existing.identities || {};
|
|
47
|
+
const canonicalIdentities = vaultMind.identities || {};
|
|
48
|
+
vaultMind.identities = deepMerge(deepMerge(legacyIdentities, canonicalIdentities), identities);
|
|
49
|
+
delete existing.identities;
|
|
34
50
|
existing.vaultMind = vaultMind;
|
|
35
51
|
const dir = path.dirname(configPath);
|
|
36
52
|
if (!fs.existsSync(dir))
|
|
@@ -6,10 +6,21 @@
|
|
|
6
6
|
* from pi-guideline-loader (kylebrodeur/pi-guideline-loader, MIT).
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
export declare const IDENTITY_BOUNDARY_ROLES: readonly ["heavy-lifter", "miner", "broadcaster", "manager"];
|
|
10
|
+
export type IdentityBoundaryRole = (typeof IDENTITY_BOUNDARY_ROLES)[number];
|
|
11
|
+
/**
|
|
12
|
+
* Resolve an explicitly selected specialist persona.
|
|
13
|
+
*
|
|
14
|
+
* Specialist skills are ambient resources in every pi session, so their
|
|
15
|
+
* presence must never select a persona. The environment can override the
|
|
16
|
+
* vault-local config for launcher/fork use. Unset, "main", "none", and invalid
|
|
17
|
+
* roles all keep the unrestricted interactive main agent.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveIdentityBoundaryRole(cwd: string, env?: NodeJS.ProcessEnv): IdentityBoundaryRole | undefined;
|
|
9
20
|
/**
|
|
10
21
|
* Register the identity injector on a pi ExtensionAPI.
|
|
11
22
|
*
|
|
12
|
-
* On every before_agent_start event,
|
|
13
|
-
*
|
|
23
|
+
* On every before_agent_start event, injects a boundary only when a specialist
|
|
24
|
+
* persona was selected explicitly through environment or vault config.
|
|
14
25
|
*/
|
|
15
26
|
export declare function registerIdentityInjector(pi: ExtensionAPI): void;
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* per role stating capability boundaries as a contract. Pattern adapted
|
|
6
6
|
* from pi-guideline-loader (kylebrodeur/pi-guideline-loader, MIT).
|
|
7
7
|
*/
|
|
8
|
+
import { loadConfig } from "./utils.js";
|
|
8
9
|
// ── Per-role boundary prompts ────────────────────────────────────────────────
|
|
10
|
+
export const IDENTITY_BOUNDARY_ROLES = ["heavy-lifter", "miner", "broadcaster", "manager"];
|
|
9
11
|
const BOUNDARY_PROMPTS = {
|
|
10
12
|
"heavy-lifter": [
|
|
11
13
|
"--- IDENTITY BOUNDARY: Heavy-Lifter ---",
|
|
@@ -50,46 +52,58 @@ const BOUNDARY_PROMPTS = {
|
|
|
50
52
|
"------------------------------------------",
|
|
51
53
|
].join("\n"),
|
|
52
54
|
};
|
|
53
|
-
// ── Role
|
|
55
|
+
// ── Role resolution ──────────────────────────────────────────────────────────
|
|
56
|
+
const DISABLED_SWITCH_VALUES = {
|
|
57
|
+
"0": true,
|
|
58
|
+
false: true,
|
|
59
|
+
no: true,
|
|
60
|
+
off: true,
|
|
61
|
+
disabled: true,
|
|
62
|
+
};
|
|
63
|
+
const ENABLED_SWITCH_VALUES = {
|
|
64
|
+
"1": true,
|
|
65
|
+
true: true,
|
|
66
|
+
yes: true,
|
|
67
|
+
on: true,
|
|
68
|
+
enabled: true,
|
|
69
|
+
};
|
|
54
70
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* PRIMARY signal — event.systemPromptOptions.skills:
|
|
58
|
-
* pi loads `--agent vault-mind-{role}` as a named Skill (Skill.name == "vault-mind-{role}").
|
|
59
|
-
* Exact name match is structural and immune to prose false-positives.
|
|
60
|
-
*
|
|
61
|
-
* FALLBACK signal — system prompt XML:
|
|
62
|
-
* Only when no matching skill is present. Matches only the machine-generated
|
|
63
|
-
* `<name>vault-mind-{role}</name>` element emitted by formatSkillsForPrompt()
|
|
64
|
-
* inside <available_skills> — not any free-text prose mention of the role name.
|
|
71
|
+
* Resolve an explicitly selected specialist persona.
|
|
65
72
|
*
|
|
66
|
-
*
|
|
73
|
+
* Specialist skills are ambient resources in every pi session, so their
|
|
74
|
+
* presence must never select a persona. The environment can override the
|
|
75
|
+
* vault-local config for launcher/fork use. Unset, "main", "none", and invalid
|
|
76
|
+
* roles all keep the unrestricted interactive main agent.
|
|
67
77
|
*/
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
78
|
+
export function resolveIdentityBoundaryRole(cwd, env = process.env) {
|
|
79
|
+
const identities = loadConfig(cwd).vaultMind.identities;
|
|
80
|
+
const injectionOverride = env.VAULT_MIND_IDENTITY_INJECTION?.trim().toLowerCase();
|
|
81
|
+
if (injectionOverride && DISABLED_SWITCH_VALUES[injectionOverride])
|
|
82
|
+
return;
|
|
83
|
+
if ((!injectionOverride || !ENABLED_SWITCH_VALUES[injectionOverride]) &&
|
|
84
|
+
identities?.injectBoundaries === false) {
|
|
85
|
+
return;
|
|
77
86
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
const rawRole = Object.hasOwn(env, "VAULT_MIND_AGENT_ROLE")
|
|
88
|
+
? env.VAULT_MIND_AGENT_ROLE
|
|
89
|
+
: identities?.activeRole;
|
|
90
|
+
const role = rawRole?.trim().toLowerCase();
|
|
91
|
+
if (!role || role === "main" || role === "none")
|
|
92
|
+
return;
|
|
93
|
+
if (!Object.hasOwn(BOUNDARY_PROMPTS, role))
|
|
94
|
+
return;
|
|
95
|
+
return role;
|
|
82
96
|
}
|
|
83
97
|
// ── Injector ──────────────────────────────────────────────────────────────────
|
|
84
98
|
/**
|
|
85
99
|
* Register the identity injector on a pi ExtensionAPI.
|
|
86
100
|
*
|
|
87
|
-
* On every before_agent_start event,
|
|
88
|
-
*
|
|
101
|
+
* On every before_agent_start event, injects a boundary only when a specialist
|
|
102
|
+
* persona was selected explicitly through environment or vault config.
|
|
89
103
|
*/
|
|
90
104
|
export function registerIdentityInjector(pi) {
|
|
91
105
|
pi.on("before_agent_start", async (event) => {
|
|
92
|
-
const role =
|
|
106
|
+
const role = resolveIdentityBoundaryRole(event.systemPromptOptions.cwd);
|
|
93
107
|
if (!role)
|
|
94
108
|
return;
|
|
95
109
|
const boundaryPrompt = BOUNDARY_PROMPTS[role];
|
package/dist/src/scaffold.js
CHANGED
|
@@ -135,46 +135,40 @@ export const scaffoldVaultConfig = (vaultPath, collectionOverride) => {
|
|
|
135
135
|
version: 2,
|
|
136
136
|
collections: defaultCollections,
|
|
137
137
|
injectors: defaultInjectors,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
138
|
+
vaultMind: {
|
|
139
|
+
identities: {
|
|
140
|
+
roles: {
|
|
141
|
+
main: {
|
|
142
|
+
allowedTools: [
|
|
143
|
+
"read",
|
|
144
|
+
"write",
|
|
145
|
+
"edit",
|
|
146
|
+
"bash",
|
|
147
|
+
"grep",
|
|
148
|
+
"find",
|
|
149
|
+
"ls",
|
|
150
|
+
"vm_search",
|
|
151
|
+
"vm_query",
|
|
152
|
+
"vm_append",
|
|
153
|
+
"vm_stats",
|
|
154
|
+
"vm_status",
|
|
155
|
+
"vm_describe",
|
|
156
|
+
"vm_configure",
|
|
157
|
+
"vault_read",
|
|
158
|
+
"vault_search",
|
|
159
|
+
"vault_list",
|
|
160
|
+
"vault_tags",
|
|
161
|
+
"vm_backlinks",
|
|
162
|
+
"vm_related",
|
|
163
|
+
"vm_broken_links",
|
|
164
|
+
"vm_codegraph_impact",
|
|
165
|
+
],
|
|
166
|
+
},
|
|
165
167
|
},
|
|
166
168
|
},
|
|
167
|
-
},
|
|
168
|
-
vaultMind: {
|
|
169
169
|
dataDir: ".vault-mind/.lancedb",
|
|
170
170
|
ftsEnabled: true,
|
|
171
171
|
graph: { enabled: true, canvasSync: false },
|
|
172
|
-
folders: {
|
|
173
|
-
inbox: "Agent/Inbox",
|
|
174
|
-
library: "Agent/Library",
|
|
175
|
-
presentations: "Agent/Presentations",
|
|
176
|
-
journal: "Agent/Journal",
|
|
177
|
-
},
|
|
178
172
|
},
|
|
179
173
|
};
|
|
180
174
|
ensureDir(cfgDest);
|
package/dist/src/server.js
CHANGED
|
@@ -832,19 +832,28 @@ export function startServer(pi, serverState, watcherState, readinessCallback) {
|
|
|
832
832
|
if (parts.length === 5) {
|
|
833
833
|
const role = parts[3];
|
|
834
834
|
withAuth(req, res, async () => {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
835
|
+
try {
|
|
836
|
+
const body = (await readJsonBody(req));
|
|
837
|
+
if (!body.allowedTools || !Array.isArray(body.allowedTools)) {
|
|
838
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
839
|
+
res.end(JSON.stringify({ error: "Missing allowedTools array in request body" }));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// writeIdentitiesLayer deep-merges; pass a partial role config
|
|
843
|
+
writeIdentitiesLayer(process.cwd(), {
|
|
844
|
+
roles: { [role]: { allowedTools: body.allowedTools } },
|
|
845
|
+
});
|
|
846
|
+
refreshAgentIdentities([role]);
|
|
847
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
848
|
+
res.end(JSON.stringify({ ok: true, role, allowedTools: body.allowedTools }));
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
852
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
853
|
+
res.end(JSON.stringify({
|
|
854
|
+
error: `Failed to update identity config: ${message}`,
|
|
855
|
+
}));
|
|
840
856
|
}
|
|
841
|
-
// writeIdentitiesLayer deep-merges; pass a partial role config
|
|
842
|
-
writeIdentitiesLayer(process.cwd(), {
|
|
843
|
-
roles: { [role]: { allowedTools: body.allowedTools } },
|
|
844
|
-
});
|
|
845
|
-
refreshAgentIdentities([role]);
|
|
846
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
847
|
-
res.end(JSON.stringify({ ok: true, role, allowedTools: body.allowedTools }));
|
|
848
857
|
}, { requireWrite: true });
|
|
849
858
|
}
|
|
850
859
|
else {
|
package/dist/src/settings-ui.js
CHANGED
|
@@ -291,27 +291,10 @@ export const setupWizard = async (ctx, cliArgs) => {
|
|
|
291
291
|
if (cliArgs) {
|
|
292
292
|
const config = existingConfigFile
|
|
293
293
|
? JSON.parse(fs.readFileSync(getConfigPath(ctx.cwd), "utf-8"))
|
|
294
|
-
: {
|
|
295
|
-
vaultMind: {
|
|
296
|
-
embedding: {},
|
|
297
|
-
vaults: {},
|
|
298
|
-
folders: {
|
|
299
|
-
inbox: "Agent/Inbox",
|
|
300
|
-
library: "Agent/Library",
|
|
301
|
-
presentations: "Agent/Presentations",
|
|
302
|
-
journal: "Agent/Journal",
|
|
303
|
-
},
|
|
304
|
-
},
|
|
305
|
-
};
|
|
294
|
+
: { vaultMind: { embedding: {}, vaults: {} } };
|
|
306
295
|
config.vaultMind = config.vaultMind || {};
|
|
307
296
|
config.vaultMind.embedding = config.vaultMind.embedding || {};
|
|
308
297
|
config.vaultMind.vaults = config.vaultMind.vaults || {};
|
|
309
|
-
config.vaultMind.folders = config.vaultMind.folders || {
|
|
310
|
-
inbox: "Agent/Inbox",
|
|
311
|
-
library: "Agent/Library",
|
|
312
|
-
presentations: "Agent/Presentations",
|
|
313
|
-
journal: "Agent/Journal",
|
|
314
|
-
};
|
|
315
298
|
const effectiveVaultPath = cliArgs.vault || detectedVaultPath || undefined;
|
|
316
299
|
if (effectiveVaultPath) {
|
|
317
300
|
config.vaultMind.vaults.default = { path: shrinkHome(effectiveVaultPath), autoSync: true };
|
|
@@ -532,13 +515,61 @@ export const setupWizard = async (ctx, cliArgs) => {
|
|
|
532
515
|
dim = 384;
|
|
533
516
|
}
|
|
534
517
|
// ── Step 2.5: Vault folders ──────────────────────────────────────────────
|
|
518
|
+
// Lists real existing folders rather than guessing a convention (e.g.
|
|
519
|
+
// "Agent/Inbox") — a blind default silently creates the wrong structure
|
|
520
|
+
// in vaults that already have their own organization scheme.
|
|
521
|
+
const FOLDER_IGNORE = new Set([
|
|
522
|
+
".git",
|
|
523
|
+
".obsidian",
|
|
524
|
+
".vault-mind",
|
|
525
|
+
".pi",
|
|
526
|
+
"node_modules",
|
|
527
|
+
".trash",
|
|
528
|
+
".DS_Store",
|
|
529
|
+
]);
|
|
530
|
+
const listVaultFolders = (root) => {
|
|
531
|
+
const results = [];
|
|
532
|
+
const walk = (dir, rel, depth) => {
|
|
533
|
+
if (depth > 2)
|
|
534
|
+
return;
|
|
535
|
+
let entries;
|
|
536
|
+
try {
|
|
537
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || FOLDER_IGNORE.has(entry.name))
|
|
544
|
+
continue;
|
|
545
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
546
|
+
results.push(relPath);
|
|
547
|
+
walk(path.join(dir, entry.name), relPath, depth + 1);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
walk(root, "", 0);
|
|
551
|
+
return results.sort();
|
|
552
|
+
};
|
|
553
|
+
const SKIP_FOLDER = "(skip — configure later)";
|
|
554
|
+
const CUSTOM_FOLDER = "→ Type a custom path...";
|
|
555
|
+
const pickFolder = async (label, current) => {
|
|
556
|
+
const options = [SKIP_FOLDER, CUSTOM_FOLDER, ...vaultFolders];
|
|
557
|
+
const prompt = current ? `${label} (current: ${current})` : label;
|
|
558
|
+
const choice = await ctx.ui.select(prompt, options);
|
|
559
|
+
if (!choice || choice === SKIP_FOLDER)
|
|
560
|
+
return "";
|
|
561
|
+
if (choice === CUSTOM_FOLDER)
|
|
562
|
+
return (await ctx.ui.input(`${label} — path:`, current || "")) || "";
|
|
563
|
+
return choice;
|
|
564
|
+
};
|
|
535
565
|
const existingFolders = existingConfigFile
|
|
536
566
|
? (JSON.parse(fs.readFileSync(getConfigPath(ctx.cwd), "utf-8")).vaultMind?.folders ?? {})
|
|
537
567
|
: {};
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
const
|
|
568
|
+
const vaultFolders = fs.existsSync(vaultPath) ? listVaultFolders(vaultPath) : [];
|
|
569
|
+
const inboxFolder = await pickFolder("Inbox folder (agent capture target)", existingFolders.inbox);
|
|
570
|
+
const libraryFolder = await pickFolder("Library folder (durable knowledge notes)", existingFolders.library);
|
|
571
|
+
const presentationsFolder = await pickFolder("Presentations folder", existingFolders.presentations);
|
|
572
|
+
const journalFolder = await pickFolder("Journal folder", existingFolders.journal);
|
|
542
573
|
// ── Step 3: Deterministic runtime settings ──────────────────────────────
|
|
543
574
|
const enableContextAutomation = await ctx.ui.confirm("Context automation", "Enable pi-context integration with auto-/acm trigger and event indexing?");
|
|
544
575
|
const enableAutoStart = await ctx.ui.confirm("Vault auto-start", "Auto-start watcher/server for this vault when pi session starts?");
|
|
@@ -547,7 +578,7 @@ export const setupWizard = async (ctx, cliArgs) => {
|
|
|
547
578
|
`Remote URL: ${remoteUrl || "none"}`,
|
|
548
579
|
`Local URL: ${localUrl || "none"}`,
|
|
549
580
|
`Model: ${model || "(auto/server default)"}`,
|
|
550
|
-
`Folders: inbox=${inboxFolder}, library=${libraryFolder}, presentations=${presentationsFolder}, journal=${journalFolder}`,
|
|
581
|
+
`Folders: inbox=${inboxFolder || "(unset)"}, library=${libraryFolder || "(unset)"}, presentations=${presentationsFolder || "(unset)"}, journal=${journalFolder || "(unset)"}`,
|
|
551
582
|
`Context automation: ${enableContextAutomation ? "enabled" : "disabled"}`,
|
|
552
583
|
`Vault auto-start: ${enableAutoStart ? "enabled" : "disabled"}`,
|
|
553
584
|
].join("\n"));
|
|
@@ -578,12 +609,22 @@ export const setupWizard = async (ctx, cliArgs) => {
|
|
|
578
609
|
autoSync: true,
|
|
579
610
|
autoStart: enableAutoStart,
|
|
580
611
|
};
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
612
|
+
const chosenFolders = {};
|
|
613
|
+
if (inboxFolder)
|
|
614
|
+
chosenFolders.inbox = inboxFolder;
|
|
615
|
+
if (libraryFolder)
|
|
616
|
+
chosenFolders.library = libraryFolder;
|
|
617
|
+
if (presentationsFolder)
|
|
618
|
+
chosenFolders.presentations = presentationsFolder;
|
|
619
|
+
if (journalFolder)
|
|
620
|
+
chosenFolders.journal = journalFolder;
|
|
621
|
+
if (Object.keys(chosenFolders).length > 0) {
|
|
622
|
+
config.vaultMind.folders = chosenFolders;
|
|
623
|
+
}
|
|
624
|
+
else if (Object.keys(existingFolders).length > 0) {
|
|
625
|
+
// All skipped during a reconfigure — preserve what was already set.
|
|
626
|
+
config.vaultMind.folders = existingFolders;
|
|
627
|
+
}
|
|
587
628
|
config.vaultMind.graph = config.vaultMind.graph || { enabled: true, canvasSync: true };
|
|
588
629
|
config.vaultMind.ftsEnabled = config.vaultMind.ftsEnabled !== false;
|
|
589
630
|
// Extension-compatibility (pi-context) now lives in the single consolidated
|
package/dist/src/types.d.ts
CHANGED
|
@@ -203,6 +203,10 @@ export interface VaultMindConfig {
|
|
|
203
203
|
defaultProfile?: IdentityConfig;
|
|
204
204
|
/** Per-role overrides. */
|
|
205
205
|
roles?: Record<string, IdentityConfig>;
|
|
206
|
+
/** Master switch for system-prompt identity-boundary injection. */
|
|
207
|
+
injectBoundaries?: boolean;
|
|
208
|
+
/** Explicit persona to inject; unset, "main", or "none" keeps the main agent. */
|
|
209
|
+
activeRole?: string;
|
|
206
210
|
};
|
|
207
211
|
/**
|
|
208
212
|
* Vault folder layout overrides. Each path is vault-relative and joined to
|
|
@@ -235,6 +239,10 @@ export interface IdentitiesConfig {
|
|
|
235
239
|
defaultProfile?: IdentityConfig;
|
|
236
240
|
/** Per-role overrides. */
|
|
237
241
|
roles?: Record<string, IdentityConfig>;
|
|
242
|
+
/** Master switch for system-prompt identity-boundary injection. */
|
|
243
|
+
injectBoundaries?: boolean;
|
|
244
|
+
/** Explicit persona to inject; unset, "main", or "none" keeps the main agent. */
|
|
245
|
+
activeRole?: string;
|
|
238
246
|
}
|
|
239
247
|
export interface PiContextDef {
|
|
240
248
|
enabled?: boolean;
|
package/dist/src/utils.js
CHANGED
|
@@ -89,6 +89,20 @@ const mergeInjectors = (base, layer) => {
|
|
|
89
89
|
};
|
|
90
90
|
const mergeConfigLayer = (base, layer, cwd) => {
|
|
91
91
|
const rawLayer = layer;
|
|
92
|
+
const legacyIdentities = rawLayer.identities;
|
|
93
|
+
const canonicalIdentities = layer.vaultMind?.identities;
|
|
94
|
+
const mergedIdentities = base.vaultMind.identities || legacyIdentities || canonicalIdentities
|
|
95
|
+
? {
|
|
96
|
+
...base.vaultMind.identities,
|
|
97
|
+
...legacyIdentities,
|
|
98
|
+
...canonicalIdentities,
|
|
99
|
+
roles: {
|
|
100
|
+
...base.vaultMind.identities?.roles,
|
|
101
|
+
...legacyIdentities?.roles,
|
|
102
|
+
...canonicalIdentities?.roles,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
: undefined;
|
|
92
106
|
const merged = {
|
|
93
107
|
version: layer.version ?? base.version,
|
|
94
108
|
collections: { ...base.collections },
|
|
@@ -99,6 +113,7 @@ const mergeConfigLayer = (base, layer, cwd) => {
|
|
|
99
113
|
embedding: { ...base.vaultMind.embedding, ...(layer.vaultMind?.embedding || {}) },
|
|
100
114
|
graph: { ...base.vaultMind.graph, ...(layer.vaultMind?.graph || {}) },
|
|
101
115
|
vaults: { ...base.vaultMind.vaults, ...(layer.vaultMind?.vaults || {}) },
|
|
116
|
+
identities: mergedIdentities,
|
|
102
117
|
},
|
|
103
118
|
extensionCompatibility: {
|
|
104
119
|
...base.extensionCompatibility,
|
|
@@ -157,6 +157,13 @@ describe("CLI command handlers", () => {
|
|
|
157
157
|
process.chdir(origCwd);
|
|
158
158
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
159
159
|
});
|
|
160
|
+
describe("scaffoldVaultConfig", () => {
|
|
161
|
+
it("writes identities only at the canonical vaultMind path", () => {
|
|
162
|
+
const raw = JSON.parse(fs.readFileSync(getConfigPath(dir), "utf-8"));
|
|
163
|
+
assert.equal(raw.identities, undefined);
|
|
164
|
+
assert.ok(raw.vaultMind.identities?.roles?.main);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
160
167
|
describe("handleSearch", () => {
|
|
161
168
|
it("returns results in hybrid mode (default)", async () => {
|
|
162
169
|
const vm = getVmHandler();
|
|
@@ -288,6 +295,77 @@ describe("CLI command handlers", () => {
|
|
|
288
295
|
assert.match(last.message, /Unknown: bogus/);
|
|
289
296
|
});
|
|
290
297
|
});
|
|
298
|
+
describe("handleIdentity", () => {
|
|
299
|
+
it("reports main as the safe default persona", async () => {
|
|
300
|
+
const vm = getVmHandler();
|
|
301
|
+
await vm("identity status", makeCtx(dir, notifies));
|
|
302
|
+
const last = notifies.at(-1);
|
|
303
|
+
assert.ok(last, "notify was called");
|
|
304
|
+
assert.equal(last.level, "info");
|
|
305
|
+
assert.match(last.message, /Persona: main/);
|
|
306
|
+
assert.match(last.message, /Injection: enabled/);
|
|
307
|
+
});
|
|
308
|
+
it("sets an explicit persona and enables injection", async () => {
|
|
309
|
+
const vm = getVmHandler();
|
|
310
|
+
await vm("identity set broadcaster", makeCtx(dir, notifies));
|
|
311
|
+
const raw = JSON.parse(fs.readFileSync(getConfigPath(dir), "utf-8"));
|
|
312
|
+
assert.equal(raw.vaultMind.identities.activeRole, "broadcaster");
|
|
313
|
+
assert.equal(raw.vaultMind.identities.injectBoundaries, true);
|
|
314
|
+
assert.match(notifies.at(-1)?.message ?? "", /Persona set to broadcaster/);
|
|
315
|
+
});
|
|
316
|
+
it("migrates legacy top-level identities when setting a persona", async () => {
|
|
317
|
+
const configPath = getConfigPath(dir);
|
|
318
|
+
const legacy = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
319
|
+
legacy.identities = legacy.vaultMind.identities;
|
|
320
|
+
delete legacy.vaultMind.identities;
|
|
321
|
+
fs.writeFileSync(configPath, `${JSON.stringify(legacy, null, 2)}\n`, "utf-8");
|
|
322
|
+
const vm = getVmHandler();
|
|
323
|
+
await vm("identity set miner", makeCtx(dir, notifies));
|
|
324
|
+
const migrated = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
325
|
+
assert.equal(migrated.identities, undefined);
|
|
326
|
+
assert.ok(migrated.vaultMind.identities.roles.main);
|
|
327
|
+
assert.equal(migrated.vaultMind.identities.activeRole, "miner");
|
|
328
|
+
});
|
|
329
|
+
it("turns injection off without discarding the selected persona", async () => {
|
|
330
|
+
const vm = getVmHandler();
|
|
331
|
+
await vm("identity set miner", makeCtx(dir, notifies));
|
|
332
|
+
await vm("identity off", makeCtx(dir, notifies));
|
|
333
|
+
const raw = JSON.parse(fs.readFileSync(getConfigPath(dir), "utf-8"));
|
|
334
|
+
assert.equal(raw.vaultMind.identities.activeRole, "miner");
|
|
335
|
+
assert.equal(raw.vaultMind.identities.injectBoundaries, false);
|
|
336
|
+
assert.match(notifies.at(-1)?.message ?? "", /Identity injection disabled/);
|
|
337
|
+
});
|
|
338
|
+
it("turns injection back on for the configured persona", async () => {
|
|
339
|
+
const vm = getVmHandler();
|
|
340
|
+
await vm("identity set manager", makeCtx(dir, notifies));
|
|
341
|
+
await vm("identity off", makeCtx(dir, notifies));
|
|
342
|
+
await vm("identity on", makeCtx(dir, notifies));
|
|
343
|
+
const raw = JSON.parse(fs.readFileSync(getConfigPath(dir), "utf-8"));
|
|
344
|
+
assert.equal(raw.vaultMind.identities.activeRole, "manager");
|
|
345
|
+
assert.equal(raw.vaultMind.identities.injectBoundaries, true);
|
|
346
|
+
assert.match(notifies.at(-1)?.message ?? "", /Identity injection enabled/);
|
|
347
|
+
});
|
|
348
|
+
it("rejects an unknown persona without changing identity config", async () => {
|
|
349
|
+
const configPath = getConfigPath(dir);
|
|
350
|
+
const before = JSON.parse(fs.readFileSync(configPath, "utf-8")).vaultMind.identities;
|
|
351
|
+
const vm = getVmHandler();
|
|
352
|
+
await vm("identity set reviewer", makeCtx(dir, notifies));
|
|
353
|
+
const after = JSON.parse(fs.readFileSync(configPath, "utf-8")).vaultMind.identities;
|
|
354
|
+
assert.deepEqual(after, before);
|
|
355
|
+
assert.equal(notifies.at(-1)?.level, "error");
|
|
356
|
+
assert.match(notifies.at(-1)?.message ?? "", /Unknown persona/);
|
|
357
|
+
});
|
|
358
|
+
it("reports malformed config without overwriting it", async () => {
|
|
359
|
+
const configPath = getConfigPath(dir);
|
|
360
|
+
const malformed = "{ invalid json";
|
|
361
|
+
fs.writeFileSync(configPath, malformed, "utf-8");
|
|
362
|
+
const vm = getVmHandler();
|
|
363
|
+
await assert.doesNotReject(() => vm("identity set miner", makeCtx(dir, notifies)));
|
|
364
|
+
assert.equal(fs.readFileSync(configPath, "utf-8"), malformed);
|
|
365
|
+
assert.equal(notifies.at(-1)?.level, "error");
|
|
366
|
+
assert.match(notifies.at(-1)?.message ?? "", /Failed to update identity config/);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
291
369
|
describe("handleWatcher", () => {
|
|
292
370
|
it("status shows watcher stopped by default", async () => {
|
|
293
371
|
const vm = getVmHandler();
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import { strict as assert } from "node:assert";
|
|
2
|
-
import
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
6
|
import { registerIdentityInjector } from "../src/identity-injector.js";
|
|
4
|
-
|
|
7
|
+
const ENV_KEYS = ["VAULT_MIND_AGENT_ROLE", "VAULT_MIND_IDENTITY_INJECTION"];
|
|
8
|
+
const originalEnv = {
|
|
9
|
+
VAULT_MIND_AGENT_ROLE: process.env.VAULT_MIND_AGENT_ROLE,
|
|
10
|
+
VAULT_MIND_IDENTITY_INJECTION: process.env.VAULT_MIND_IDENTITY_INJECTION,
|
|
11
|
+
};
|
|
12
|
+
const temporaryDirectories = [];
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const key of ENV_KEYS) {
|
|
15
|
+
const value = originalEnv[key];
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
delete process.env[key];
|
|
18
|
+
else
|
|
19
|
+
process.env[key] = value;
|
|
20
|
+
}
|
|
21
|
+
while (temporaryDirectories.length > 0) {
|
|
22
|
+
fs.rmSync(temporaryDirectories.pop(), { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
5
25
|
function mockPi() {
|
|
6
26
|
const captured = { current: null };
|
|
7
27
|
const pi = {
|
|
8
28
|
on(event, handler) {
|
|
9
|
-
if (event === "before_agent_start")
|
|
29
|
+
if (event === "before_agent_start")
|
|
10
30
|
captured.current = handler;
|
|
11
|
-
}
|
|
12
31
|
},
|
|
13
32
|
};
|
|
14
33
|
return {
|
|
@@ -18,19 +37,27 @@ function mockPi() {
|
|
|
18
37
|
},
|
|
19
38
|
};
|
|
20
39
|
}
|
|
21
|
-
|
|
40
|
+
function temporaryVault(identities) {
|
|
41
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pvm-identity-"));
|
|
42
|
+
temporaryDirectories.push(cwd);
|
|
43
|
+
if (identities) {
|
|
44
|
+
const configDirectory = path.join(cwd, ".vault-mind");
|
|
45
|
+
fs.mkdirSync(configDirectory, { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(configDirectory, "vault-mind.config.json"), `${JSON.stringify({ version: 2, vaultMind: { identities } }, null, 2)}\n`, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
return cwd;
|
|
49
|
+
}
|
|
22
50
|
function makeEvent(opts) {
|
|
23
51
|
return {
|
|
24
52
|
type: "before_agent_start",
|
|
25
53
|
prompt: "test prompt",
|
|
26
54
|
systemPrompt: opts.systemPrompt ?? "base system prompt",
|
|
27
55
|
systemPromptOptions: {
|
|
28
|
-
cwd:
|
|
56
|
+
cwd: opts.cwd,
|
|
29
57
|
skills: opts.skills,
|
|
30
58
|
},
|
|
31
59
|
};
|
|
32
60
|
}
|
|
33
|
-
/** Build a Skill with a given name. */
|
|
34
61
|
function skill(name) {
|
|
35
62
|
return {
|
|
36
63
|
name,
|
|
@@ -41,157 +68,68 @@ function skill(name) {
|
|
|
41
68
|
disableModelInvocation: false,
|
|
42
69
|
};
|
|
43
70
|
}
|
|
44
|
-
|
|
71
|
+
async function invoke(event) {
|
|
72
|
+
const mock = mockPi();
|
|
73
|
+
registerIdentityInjector(mock.pi);
|
|
74
|
+
assert.ok(mock.handler, "before_agent_start handler should be registered");
|
|
75
|
+
return mock.handler(event);
|
|
76
|
+
}
|
|
45
77
|
describe("registerIdentityInjector", () => {
|
|
46
|
-
it("
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
const result = await mock.handler(event);
|
|
78
|
+
it("does not infer a persona from ambient specialist skills", async () => {
|
|
79
|
+
const cwd = temporaryVault();
|
|
80
|
+
const result = await invoke(makeEvent({
|
|
81
|
+
cwd,
|
|
82
|
+
skills: [
|
|
83
|
+
skill("vault-mind-broadcaster"),
|
|
84
|
+
skill("vault-mind-manager"),
|
|
85
|
+
skill("vault-mind-miner"),
|
|
86
|
+
],
|
|
87
|
+
systemPrompt: "base prompt\n<available_skills><name>vault-mind-broadcaster</name></available_skills>",
|
|
88
|
+
}));
|
|
59
89
|
assert.strictEqual(result, undefined);
|
|
60
90
|
});
|
|
61
|
-
it("the
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
assert.ok(
|
|
65
|
-
|
|
66
|
-
const result = await mock.handler(event);
|
|
67
|
-
assert.strictEqual(result, undefined);
|
|
91
|
+
it("injects the persona selected in vault config", async () => {
|
|
92
|
+
const cwd = temporaryVault({ injectBoundaries: true, activeRole: "broadcaster" });
|
|
93
|
+
const result = await invoke(makeEvent({ cwd, systemPrompt: "base prompt" }));
|
|
94
|
+
assert.ok(result?.systemPrompt?.includes("base prompt"));
|
|
95
|
+
assert.ok(result?.systemPrompt?.includes("IDENTITY BOUNDARY: Broadcaster"));
|
|
68
96
|
});
|
|
69
|
-
it("
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
assert.strictEqual(result, undefined);
|
|
97
|
+
it("treats main and none as the full-tool main persona", async () => {
|
|
98
|
+
for (const activeRole of ["main", "none"]) {
|
|
99
|
+
const cwd = temporaryVault({ injectBoundaries: true, activeRole });
|
|
100
|
+
const result = await invoke(makeEvent({ cwd, skills: [skill("vault-mind-broadcaster")] }));
|
|
101
|
+
assert.strictEqual(result, undefined, activeRole);
|
|
102
|
+
}
|
|
76
103
|
});
|
|
77
|
-
it("
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
assert.
|
|
81
|
-
const event = makeEvent({
|
|
82
|
-
skills: [skill("vault-mind-heavy-lifter")],
|
|
83
|
-
systemPrompt: "base prompt",
|
|
84
|
-
});
|
|
85
|
-
const result = await mock.handler(event);
|
|
86
|
-
assert.ok(result);
|
|
87
|
-
assert.ok("systemPrompt" in result);
|
|
88
|
-
assert.ok(result.systemPrompt.includes("base prompt"));
|
|
89
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Heavy-Lifter"));
|
|
90
|
-
assert.ok(result.systemPrompt.includes("durable knowledge store"));
|
|
91
|
-
});
|
|
92
|
-
it("the hook injects boundary prompt for miner role", async () => {
|
|
93
|
-
const mock = mockPi();
|
|
94
|
-
registerIdentityInjector(mock.pi);
|
|
95
|
-
assert.ok(mock.handler);
|
|
96
|
-
const event = makeEvent({
|
|
97
|
-
skills: [skill("vault-mind-miner")],
|
|
98
|
-
systemPrompt: "base prompt",
|
|
99
|
-
});
|
|
100
|
-
const result = await mock.handler(event);
|
|
101
|
-
assert.ok(result);
|
|
102
|
-
assert.ok("systemPrompt" in result);
|
|
103
|
-
assert.ok(result.systemPrompt.includes("base prompt"));
|
|
104
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Miner"));
|
|
105
|
-
assert.ok(result.systemPrompt.includes("'main' and 'research'"));
|
|
106
|
-
});
|
|
107
|
-
it("the hook injects boundary prompt for broadcaster role", async () => {
|
|
108
|
-
const mock = mockPi();
|
|
109
|
-
registerIdentityInjector(mock.pi);
|
|
110
|
-
assert.ok(mock.handler);
|
|
111
|
-
const event = makeEvent({
|
|
112
|
-
skills: [skill("vault-mind-broadcaster")],
|
|
113
|
-
systemPrompt: "base prompt",
|
|
114
|
-
});
|
|
115
|
-
const result = await mock.handler(event);
|
|
116
|
-
assert.ok(result);
|
|
117
|
-
assert.ok("systemPrompt" in result);
|
|
118
|
-
assert.ok(result.systemPrompt.includes("base prompt"));
|
|
119
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Broadcaster"));
|
|
120
|
-
assert.ok(result.systemPrompt.includes("Agent/Presentations"));
|
|
121
|
-
});
|
|
122
|
-
it("the hook injects boundary prompt for manager role", async () => {
|
|
123
|
-
const mock = mockPi();
|
|
124
|
-
registerIdentityInjector(mock.pi);
|
|
125
|
-
assert.ok(mock.handler);
|
|
126
|
-
const event = makeEvent({
|
|
127
|
-
skills: [skill("vault-mind-manager")],
|
|
128
|
-
systemPrompt: "base prompt",
|
|
129
|
-
});
|
|
130
|
-
const result = await mock.handler(event);
|
|
131
|
-
assert.ok(result);
|
|
132
|
-
assert.ok("systemPrompt" in result);
|
|
133
|
-
assert.ok(result.systemPrompt.includes("base prompt"));
|
|
134
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Manager"));
|
|
135
|
-
assert.ok(result.systemPrompt.includes("publish to the human-facing vault"));
|
|
136
|
-
});
|
|
137
|
-
// ── Fallback: XML-based role detection ────────────────────────────────────
|
|
138
|
-
it("detects role via XML fallback when no skills match", async () => {
|
|
139
|
-
const mock = mockPi();
|
|
140
|
-
registerIdentityInjector(mock.pi);
|
|
141
|
-
assert.ok(mock.handler);
|
|
142
|
-
const event = makeEvent({
|
|
143
|
-
skills: [skill("unrelated-skill")],
|
|
144
|
-
systemPrompt: "base prompt\n<available_skills>\n<name>vault-mind-miner</name>\n</available_skills>",
|
|
145
|
-
});
|
|
146
|
-
const result = await mock.handler(event);
|
|
147
|
-
assert.ok(result);
|
|
148
|
-
assert.ok("systemPrompt" in result);
|
|
149
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Miner"));
|
|
104
|
+
it("config hard-off suppresses a configured specialist persona", async () => {
|
|
105
|
+
const cwd = temporaryVault({ injectBoundaries: false, activeRole: "broadcaster" });
|
|
106
|
+
const result = await invoke(makeEvent({ cwd, skills: [skill("vault-mind-broadcaster")] }));
|
|
107
|
+
assert.strictEqual(result, undefined);
|
|
150
108
|
});
|
|
151
|
-
it("
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
});
|
|
158
|
-
const result = await mock.handler(event);
|
|
159
|
-
assert.ok(result);
|
|
160
|
-
assert.ok("systemPrompt" in result);
|
|
161
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Broadcaster"));
|
|
109
|
+
it("environment role overrides the configured persona", async () => {
|
|
110
|
+
process.env.VAULT_MIND_AGENT_ROLE = "miner";
|
|
111
|
+
const cwd = temporaryVault({ injectBoundaries: true, activeRole: "broadcaster" });
|
|
112
|
+
const result = await invoke(makeEvent({ cwd }));
|
|
113
|
+
assert.ok(result?.systemPrompt?.includes("IDENTITY BOUNDARY: Miner"));
|
|
114
|
+
assert.ok(!result?.systemPrompt?.includes("IDENTITY BOUNDARY: Broadcaster"));
|
|
162
115
|
});
|
|
163
|
-
it("
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
systemPrompt: "base prompt\n<available_skills>\n<name>some-other-skill</name>\n</available_skills>",
|
|
169
|
-
});
|
|
170
|
-
const result = await mock.handler(event);
|
|
116
|
+
it("environment hard-off overrides both environment role and config", async () => {
|
|
117
|
+
process.env.VAULT_MIND_AGENT_ROLE = "miner";
|
|
118
|
+
process.env.VAULT_MIND_IDENTITY_INJECTION = "off";
|
|
119
|
+
const cwd = temporaryVault({ injectBoundaries: true, activeRole: "broadcaster" });
|
|
120
|
+
const result = await invoke(makeEvent({ cwd, skills: [skill("vault-mind-broadcaster")] }));
|
|
171
121
|
assert.strictEqual(result, undefined);
|
|
172
122
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
assert.ok(
|
|
178
|
-
const event = makeEvent({
|
|
179
|
-
skills: [skill("vault-mind-HEAVY-LIFTER")],
|
|
180
|
-
});
|
|
181
|
-
const result = await mock.handler(event);
|
|
182
|
-
assert.ok(result);
|
|
183
|
-
assert.ok("systemPrompt" in result);
|
|
184
|
-
assert.ok(result.systemPrompt.includes("IDENTITY BOUNDARY: Heavy-Lifter"));
|
|
123
|
+
it("normalizes case and whitespace for explicit roles", async () => {
|
|
124
|
+
process.env.VAULT_MIND_AGENT_ROLE = " HEAVY-LIFTER ";
|
|
125
|
+
const cwd = temporaryVault();
|
|
126
|
+
const result = await invoke(makeEvent({ cwd }));
|
|
127
|
+
assert.ok(result?.systemPrompt?.includes("IDENTITY BOUNDARY: Heavy-Lifter"));
|
|
185
128
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
assert.ok(mock.handler);
|
|
191
|
-
const event = makeEvent({
|
|
192
|
-
skills: [skill("vault-mind-unknown-role")],
|
|
193
|
-
});
|
|
194
|
-
const result = await mock.handler(event);
|
|
129
|
+
it("ignores an unknown explicit persona rather than injecting the wrong one", async () => {
|
|
130
|
+
process.env.VAULT_MIND_AGENT_ROLE = "unknown-role";
|
|
131
|
+
const cwd = temporaryVault({ injectBoundaries: true, activeRole: "broadcaster" });
|
|
132
|
+
const result = await invoke(makeEvent({ cwd, skills: [skill("vault-mind-broadcaster")] }));
|
|
195
133
|
assert.strictEqual(result, undefined);
|
|
196
134
|
});
|
|
197
135
|
});
|
|
@@ -461,4 +461,28 @@ describe("REST vm routes", () => {
|
|
|
461
461
|
assert.ok(Array.isArray(returned), "GET must return a config with an injectors array");
|
|
462
462
|
assert.ok(!returned.some((ij) => ij.name === "draft-context"), "GET must not resurrect the default injector after an explicit empty patch");
|
|
463
463
|
});
|
|
464
|
+
it("returns JSON 500 without overwriting a malformed identity config", async () => {
|
|
465
|
+
const configPath = path.join(vaultPath, ".vault-mind", "vault-mind.config.json");
|
|
466
|
+
const validConfig = fs.readFileSync(configPath, "utf-8");
|
|
467
|
+
const malformed = "{ invalid json";
|
|
468
|
+
fs.writeFileSync(configPath, malformed, "utf-8");
|
|
469
|
+
try {
|
|
470
|
+
const response = await fetch(`http://127.0.0.1:${port}/vault-mind/identities/main/allowedTools`, {
|
|
471
|
+
method: "PUT",
|
|
472
|
+
headers: {
|
|
473
|
+
Authorization: `Bearer ${TEST_TOKEN}`,
|
|
474
|
+
"Content-Type": "application/json",
|
|
475
|
+
},
|
|
476
|
+
body: JSON.stringify({ allowedTools: ["read", "write"] }),
|
|
477
|
+
signal: AbortSignal.timeout(1000),
|
|
478
|
+
});
|
|
479
|
+
const body = (await response.json());
|
|
480
|
+
assert.equal(response.status, 500);
|
|
481
|
+
assert.match(String(body.error), /Failed to update identity config/);
|
|
482
|
+
assert.equal(fs.readFileSync(configPath, "utf-8"), malformed);
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
fs.writeFileSync(configPath, validConfig, "utf-8");
|
|
486
|
+
}
|
|
487
|
+
});
|
|
464
488
|
});
|
|
@@ -182,6 +182,11 @@ describe("/vm setup collection scaffolding (regression)", () => {
|
|
|
182
182
|
assert.equal(cfg.vaultMind?.embedding?.remoteUrl, "https://example.test");
|
|
183
183
|
assert.equal(cfg.vaultMind?.embedding?.model, "embeddinggemma");
|
|
184
184
|
assert.ok(cfg.vaultMind?.vaults?.default?.path, "vaults.default.path should be written");
|
|
185
|
+
// Regression: headless setup used to invent an "Agent/Inbox" etc.
|
|
186
|
+
// folders block on a fresh vault. Guessing a folder convention that
|
|
187
|
+
// doesn't match the vault's actual structure silently misfiles
|
|
188
|
+
// content — folders must stay unset until deliberately configured.
|
|
189
|
+
assert.equal(cfg.vaultMind?.folders, undefined, "fresh headless setup should not invent a folders block");
|
|
185
190
|
fs.rmSync(vault, { recursive: true, force: true });
|
|
186
191
|
});
|
|
187
192
|
it("preserves an explicit empty injector array during subsequent setup runs", async () => {
|
package/dist/test/utils.test.js
CHANGED
|
@@ -392,6 +392,19 @@ describe("loadConfig", () => {
|
|
|
392
392
|
// Default embedding.localUrl should still be present from merge
|
|
393
393
|
assert.equal(cfg.vaultMind.embedding.localUrl, "http://127.0.0.1:11434");
|
|
394
394
|
});
|
|
395
|
+
it("loads legacy top-level identities into the canonical vaultMind identities path", () => {
|
|
396
|
+
writeConfig(testDir, {
|
|
397
|
+
identities: {
|
|
398
|
+
roles: {
|
|
399
|
+
main: {
|
|
400
|
+
allowedTools: ["read", "bash"],
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
const cfg = loadConfig(testDir);
|
|
406
|
+
assert.deepEqual(cfg.vaultMind.identities?.roles?.main.allowedTools, ["read", "bash"]);
|
|
407
|
+
});
|
|
395
408
|
it("resolves collection paths relative to cwd", () => {
|
|
396
409
|
writeConfig(testDir, {
|
|
397
410
|
collections: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vault-mind",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.29",
|
|
4
4
|
"description": "Passive Obsidian vault extension for pi. Watches @agent markers, dispatches forked subagents (Miner, Broadcaster, Heavy-Lifter), stores in LanceDB with vector + FTS + graph. Multi-agent 'Drop & Forget' workflow for the pi agent ecosystem.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: BroadcasterAgent
|
|
3
|
-
role: broadcaster
|
|
4
|
-
capabilities: [read, write, edit]
|
|
5
|
-
allowed_tools: [read, write, edit, vm_search, vm_fts_search]
|
|
6
|
-
write_collections: []
|
|
7
|
-
can_publish: false
|
|
8
|
-
llm_provider: none
|
|
9
|
-
llm_model: none
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Broadcaster Agent
|
|
13
|
-
|
|
14
|
-
Content distribution agent for pi-vault-mind. Creates presentations, summaries,
|
|
15
|
-
and external-facing content from vault knowledge.
|
|
16
|
-
|
|
17
|
-
## Capability Boundary
|
|
18
|
-
|
|
19
|
-
- **MAY**: read files, write files, edit files
|
|
20
|
-
- **MAY**: search collections via vm_search, vm_fts_search
|
|
21
|
-
- **MUST NOT**: run bash commands, grep, or find
|
|
22
|
-
- **MUST NOT**: write to the durable knowledge store
|
|
23
|
-
- **MUST NOT**: publish or spawn sub-agents
|
|
24
|
-
- Write output only under Agent/Presentations/
|
|
25
|
-
|
|
26
|
-
## Tool Calling Convention
|
|
27
|
-
|
|
28
|
-
When calling pi-vault-mind tools, always include your role:
|
|
29
|
-
```
|
|
30
|
-
vm_search({ query: "...", role: "broadcaster" })
|
|
31
|
-
```
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: HeavyLifterAgent
|
|
3
|
-
role: heavy-lifter
|
|
4
|
-
capabilities: [read, write, edit, grep, find, ls, bash]
|
|
5
|
-
allowed_tools: [read, write, edit, grep, find, ls, bash, vm_search, vm_fts_search]
|
|
6
|
-
write_collections: []
|
|
7
|
-
can_publish: false
|
|
8
|
-
llm_provider: none
|
|
9
|
-
llm_model: none
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Heavy-Lifter Agent
|
|
13
|
-
|
|
14
|
-
External CLI execution agent for pi-vault-mind. Runs bash commands in an isolated
|
|
15
|
-
worktree. Cannot write to the durable knowledge store — all findings must be
|
|
16
|
-
routed to the Miner for storage.
|
|
17
|
-
|
|
18
|
-
## Capability Boundary
|
|
19
|
-
|
|
20
|
-
- **MAY**: read files, write files, edit files, run bash commands, search with grep/find/ls
|
|
21
|
-
- **MUST NOT**: write to the durable store (vm_append, vm_sync disabled)
|
|
22
|
-
- **MUST NOT**: spawn sub-agents
|
|
23
|
-
- **MUST NOT**: publish to the human-facing vault
|
|
24
|
-
- All bash commands run in an isolated worktree. Do not modify files outside it.
|
|
25
|
-
|
|
26
|
-
## Tool Calling Convention
|
|
27
|
-
|
|
28
|
-
When calling pi-vault-mind tools, always include your role:
|
|
29
|
-
```
|
|
30
|
-
vm_search({ query: "...", role: "heavy-lifter" })
|
|
31
|
-
```
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: ManagerAgent
|
|
3
|
-
role: manager
|
|
4
|
-
capabilities: [read, write, edit, grep, find, ls]
|
|
5
|
-
allowed_tools: [read, write, edit, grep, find, ls, vm_search, vm_fts_search, vm_append, vm_sync, vm_promote, vm_query, vm_configure, vm_describe, vm_stats, vm_export, vm_ingest, vm_status, vm_graph_query]
|
|
6
|
-
write_collections: [main, research, presentations]
|
|
7
|
-
can_publish: true
|
|
8
|
-
llm_provider: none
|
|
9
|
-
llm_model: none
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Manager Agent
|
|
13
|
-
|
|
14
|
-
Final gate and publishing agent for pi-vault-mind. The only role authorized to
|
|
15
|
-
publish to the human-facing vault and dispatch sub-agents.
|
|
16
|
-
|
|
17
|
-
## Capability Boundary
|
|
18
|
-
|
|
19
|
-
- **MAY**: all vm_* tools, read, write, edit, grep, find, ls
|
|
20
|
-
- **MAY**: write to any collection
|
|
21
|
-
- **MAY**: publish to the human-facing vault via vm_sync
|
|
22
|
-
- **MAY**: dispatch sub-agents
|
|
23
|
-
- **MUST NOT**: run bash commands
|
|
24
|
-
|
|
25
|
-
## Tool Calling Convention
|
|
26
|
-
|
|
27
|
-
When calling pi-vault-mind tools, always include your role:
|
|
28
|
-
```
|
|
29
|
-
vm_sync({ collection: "main", role: "manager" })
|
|
30
|
-
```
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: MinerAgent
|
|
3
|
-
role: miner
|
|
4
|
-
capabilities: [read, write, edit, grep, find, ls]
|
|
5
|
-
allowed_tools: [read, write, edit, grep, find, ls, vm_search, vm_fts_search, vm_append]
|
|
6
|
-
write_collections: [main, research]
|
|
7
|
-
can_publish: false
|
|
8
|
-
llm_provider: none
|
|
9
|
-
llm_model: none
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Miner Agent
|
|
13
|
-
|
|
14
|
-
Knowledge extraction and storage agent for pi-vault-mind. Extracts entities and
|
|
15
|
-
relationships from vault content and stores them in the durable knowledge store.
|
|
16
|
-
|
|
17
|
-
## Capability Boundary
|
|
18
|
-
|
|
19
|
-
- **MAY**: read files, write files, edit files, search with grep/find/ls
|
|
20
|
-
- **MAY**: append to collections 'main' and 'research' via vm_append
|
|
21
|
-
- **MUST NOT**: run bash commands
|
|
22
|
-
- **MUST NOT**: publish to the human-facing vault (vm_sync disabled)
|
|
23
|
-
- **MUST NOT**: spawn sub-agents
|
|
24
|
-
|
|
25
|
-
## Tool Calling Convention
|
|
26
|
-
|
|
27
|
-
When calling pi-vault-mind tools, always include your role:
|
|
28
|
-
```
|
|
29
|
-
vm_append({ collection: "main", entry: {...}, role: "miner" })
|
|
30
|
-
```
|