prism-mcp-server 20.5.3 → 20.7.0
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/README.md +109 -0
- package/dist/agentManifestSync.js +417 -0
- package/dist/cli.js +15 -3
- package/dist/connect.js +91 -8
- package/dist/skillManifestSync.js +58 -0
- package/dist/tools/ledgerHandlers.js +124 -8
- package/dist/tools/sessionMemoryDefinitions.js +17 -7
- package/dist/tools/skillRouting.js +7 -0
- package/dist/tools/v12Handlers.js +20 -5
- package/dist/utils/analytics.js +10 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,11 +56,120 @@ Prism works locally without an account, API key, or cloud subscription. Add a
|
|
|
56
56
|
Synalux subscription when you want cloud memory, paid-tier skills, or team
|
|
57
57
|
features.
|
|
58
58
|
|
|
59
|
+
### What `prism connect` changes about host subagents
|
|
60
|
+
|
|
61
|
+
`connect` steers bounded work to `prism_infer` on your machine rather than to
|
|
62
|
+
host-spawned agents. What it writes differs per host, and **it does not disable
|
|
63
|
+
subagents everywhere** — Claude Code keeps them and is pointed at an economy
|
|
64
|
+
model instead. Prism's local workers stay available over MCP in every case.
|
|
65
|
+
|
|
66
|
+
| Host | Setting written | Effect |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Claude Code | `env.CLAUDE_CODE_SUBAGENT_MODEL = "sonnet"` in `~/.claude/settings.json` | Subagents stay **enabled**, pinned to an economy model. Fan-out is discouraged by policy text, not by config |
|
|
69
|
+
| Gemini CLI | `experimental.enableAgents = false` in `~/.gemini/settings.json` | Subagents **off**. Gemini exposes one boolean, so that is all there is to set |
|
|
70
|
+
| Codex | `features.multi_agent = false` in `$CODEX_HOME/config.toml` (default `~/.codex`), plus a bounded fallback: 2 threads, depth 1, cheap subagent model, 900s cap | Subagents **off**, with a bounded profile underneath so a deliberate re-enable lands somewhere sane |
|
|
71
|
+
|
|
72
|
+
Two things worth knowing:
|
|
73
|
+
|
|
74
|
+
- **`experimental` is Gemini's namespace, not ours.** Prism is not enabling
|
|
75
|
+
anything experimental — it writes `false` to a flag Gemini already defines at
|
|
76
|
+
that path. Writing anywhere else would have no effect.
|
|
77
|
+
- **That namespace is by definition temporary.** If Gemini promotes
|
|
78
|
+
`enableAgents` out of `experimental`, Prism keeps writing the old path, Gemini
|
|
79
|
+
reads the new one, and host subagents quietly turn back on. Nothing errors and
|
|
80
|
+
the settings file still looks correct. If you see host subagents running while
|
|
81
|
+
`enableAgents` reads `false`, check whether the key has moved before assuming
|
|
82
|
+
`connect` failed to write it.
|
|
83
|
+
|
|
84
|
+
Both writes are idempotent in the sense that a host already configured this way
|
|
85
|
+
is left untouched — but they are **re-applied on every `prism connect` run**,
|
|
86
|
+
not only on `--refresh`. If you deliberately re-enable host subagents, the next
|
|
87
|
+
`connect` will turn them off again. Keep them on by not re-running `connect`,
|
|
88
|
+
or by re-enabling after each run.
|
|
89
|
+
|
|
59
90
|
---
|
|
60
91
|
|
|
61
92
|
<details>
|
|
62
93
|
<summary>Release history (optional)</summary>
|
|
63
94
|
|
|
95
|
+
## What's New in v20.6.0
|
|
96
|
+
|
|
97
|
+
### Delivery Is Not a Suggestion
|
|
98
|
+
|
|
99
|
+
An audit of a real incident (an agent wiped demo data after *announcing* the
|
|
100
|
+
wipe — with the ask-first rule committed, bundled, and absent from what any
|
|
101
|
+
agent actually received) found the protected floor had outgrown every delivery
|
|
102
|
+
budget: "unprotected" had quietly come to mean "never delivered".
|
|
103
|
+
|
|
104
|
+
- **`ask-first` and `feature-preservation` join the protected floor** (14 → 16).
|
|
105
|
+
Protected skills are always inlined; these two now reach every session.
|
|
106
|
+
- **Sync conflicts are loud and named.** Startup used to say "· 2 local
|
|
107
|
+
conflicts preserved" while safety skills sat months stale; it now names each
|
|
108
|
+
frozen skill and states how to resume updates.
|
|
109
|
+
- **`--storage` accepts `auto` and `synalux`** — the CLI rejected its own
|
|
110
|
+
documented default and the production backend.
|
|
111
|
+
- **Disclosure:** skill delivery informs; it does not gate. A live probe showed
|
|
112
|
+
a host agent still edit unverified source with the rule loaded. If your
|
|
113
|
+
threat model includes an agent acting against a loaded rule under task
|
|
114
|
+
pressure, pair this package with mechanical gates (hooks, permissions,
|
|
115
|
+
least-privilege roles). True of every prior release; stated from this one.
|
|
116
|
+
|
|
117
|
+
## What's New in v20.5.3
|
|
118
|
+
|
|
119
|
+
### Grounding Evidence Carries Its Age
|
|
120
|
+
|
|
121
|
+
Memory-grounded answers labelled their sources but never dated them, so a
|
|
122
|
+
two-year-old note and yesterday's reached the model identically. Nothing in the
|
|
123
|
+
evidence let it discount the stale one. Prompted by an external review naming
|
|
124
|
+
the right risk for local-first memory: *the data stays local, but bad grounding
|
|
125
|
+
becomes permanent* — storing everything on your machine removes the outside
|
|
126
|
+
pressure that would otherwise surface a stale note.
|
|
127
|
+
|
|
128
|
+
Evidence now reads:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
[SOURCE 1: ledger:8286581d (recorded 2025-05-29, 431 days ago)]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The date already existed in storage and was being dropped at the snippet layer,
|
|
135
|
+
so this is plumbing rather than new data collection. Zone-less SQLite
|
|
136
|
+
timestamps are normalised to UTC — read as local, a ten-minute-old record
|
|
137
|
+
parsed hours into the future and its age was suppressed entirely, meaning the
|
|
138
|
+
feature silently did nothing on the freshest memories. An absent or unparseable
|
|
139
|
+
date renders as nothing rather than defaulting to now; defaulting would make
|
|
140
|
+
the oldest memories, the ones most likely to be stale, appear freshest.
|
|
141
|
+
|
|
142
|
+
`tests/integration/grounding-staleness.test.ts` runs the reviewer's own probe —
|
|
143
|
+
seed a deliberately outdated note beside a contradicting fresh one and assert
|
|
144
|
+
the model receives both, visibly dated. Anyone can run it.
|
|
145
|
+
|
|
146
|
+
**Not solved, and not claimed:** retrieval does not weight recency. A stale note
|
|
147
|
+
shown *beside* a fresh one is the easy case — the model sees both dates and can
|
|
148
|
+
weigh them. The hard case is a stale note retrieved *alone*, because ranking is
|
|
149
|
+
by keyword match and an old store returns old results; then the age label is the
|
|
150
|
+
only defence and there is no fresher record to compare against. Tracked as
|
|
151
|
+
`TECH_DEBT.md` #4.
|
|
152
|
+
|
|
153
|
+
## What's New in v20.5.0 – v20.5.2
|
|
154
|
+
|
|
155
|
+
### The First Message Never Leaves Your Machine
|
|
156
|
+
|
|
157
|
+
Symptom-triggered skills — the rules that fire on "can't see X", "no rows",
|
|
158
|
+
"the list is empty" — are meant to load on the turn an incident report arrives.
|
|
159
|
+
They never did: every host template called `session_bootstrap` with `{}`, so
|
|
160
|
+
there was no prompt to match against.
|
|
161
|
+
|
|
162
|
+
Fixing that raised the question of where matching happens. It now happens
|
|
163
|
+
locally. The 28 keyword rules are already public, so there was nothing a local
|
|
164
|
+
match could not compute, and `callPortal()` has no `prompt` parameter at all —
|
|
165
|
+
the guarantee is structural, not a promise. The portal request carries the
|
|
166
|
+
project and role only.
|
|
167
|
+
|
|
168
|
+
A matched rule now arrives as **content**, not as a name. Native hosts outside
|
|
169
|
+
the skill-file mirror had no way to read a rule they were only told about, so
|
|
170
|
+
the rule body is inlined into the startup display, bounded and sized against
|
|
171
|
+
the real per-project budget.
|
|
172
|
+
|
|
64
173
|
## What's New in v20.4.0
|
|
65
174
|
|
|
66
175
|
### An Explicitly Named Cloud Backend Fails Loud
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Definition Sync — materializes portal-served agent definitions
|
|
3
|
+
* (subagent routing policy: model tier, effort cap, tool surface) into
|
|
4
|
+
* Claude Code's agent root (~/.claude/agents/<name>.md).
|
|
5
|
+
*
|
|
6
|
+
* Deliberately separate from skillManifestSync's directory-package engine:
|
|
7
|
+
* agent definitions are SINGLE FILES, so this module implements file-level
|
|
8
|
+
* semantics with the same two protective properties the skill engine pins:
|
|
9
|
+
*
|
|
10
|
+
* 1. A hand-edited or foreign file is NEVER overwritten or deleted — it is
|
|
11
|
+
* reported as a conflict and left byte-identical on disk.
|
|
12
|
+
* 2. Ownership is provable, not assumed: a file is "ours" only when its
|
|
13
|
+
* current content digest matches the digest recorded in the sidecar
|
|
14
|
+
* index at the time we last wrote it.
|
|
15
|
+
*
|
|
16
|
+
* The server contract (portal /api/v1/prism/skill-manifest) ships agents
|
|
17
|
+
* under separate `agents` + `agents_generation` fields precisely so that
|
|
18
|
+
* clients which predate this module ignore them; `generation` remains a
|
|
19
|
+
* skills-only hash. Do not fold agents into the skills generation on either
|
|
20
|
+
* side.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
const OWNER = "prism-mcp";
|
|
27
|
+
const INDEX = ".prism-agents.json";
|
|
28
|
+
const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
|
|
29
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
30
|
+
const MAX_AGENTS = 64;
|
|
31
|
+
const MAX_AGENT_BYTES = 256 * 1024;
|
|
32
|
+
export function parseAgentDefinition(content) {
|
|
33
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
34
|
+
if (!match)
|
|
35
|
+
return { fields: {}, body: content };
|
|
36
|
+
const fields = {};
|
|
37
|
+
for (const line of match[1].split("\n")) {
|
|
38
|
+
const entry = line.match(/^([A-Za-z_-]+):\s*(.*)$/);
|
|
39
|
+
if (entry)
|
|
40
|
+
fields[entry[1]] = entry[2].trim();
|
|
41
|
+
}
|
|
42
|
+
return { fields, body: content.slice(match[0].length) };
|
|
43
|
+
}
|
|
44
|
+
// ─── Host renderers ──────────────────────────────────────────
|
|
45
|
+
//
|
|
46
|
+
// The canonical format is Claude Code's agent markdown; other hosts receive a
|
|
47
|
+
// translation of the universally-expressible subset. Model tier is
|
|
48
|
+
// DELIBERATELY not translated: model namespaces are host-specific and rotate
|
|
49
|
+
// (gpt-5.6-* today), so foreign hosts keep their own configured defaults
|
|
50
|
+
// (codex: default_subagent_model) and only the proven effort/tool-surface
|
|
51
|
+
// levers travel.
|
|
52
|
+
/** Claude Code: the canonical content verbatim. */
|
|
53
|
+
export const renderClaudeAgent = (agent) => ({
|
|
54
|
+
file: `${agent.name}.md`,
|
|
55
|
+
content: agent.content,
|
|
56
|
+
});
|
|
57
|
+
const CODEX_EFFORT = {
|
|
58
|
+
low: "low", medium: "medium", high: "high", xhigh: "high", max: "high",
|
|
59
|
+
};
|
|
60
|
+
function tomlString(value) {
|
|
61
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
62
|
+
}
|
|
63
|
+
function tomlMultiline(value) {
|
|
64
|
+
// Escaping every backslash and quote keeps any body (including """ runs)
|
|
65
|
+
// valid inside a multiline basic string while preserving literal newlines.
|
|
66
|
+
return `"""\n${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"""`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Codex: ~/.codex/agents/<name>.toml. Keys verified against codex 0.146.0:
|
|
70
|
+
* name, description, developer_instructions, model_reasoning_effort,
|
|
71
|
+
* sandbox_mode. A Bash-bearing tool surface implies the agent must execute
|
|
72
|
+
* reproductions, so it gets workspace-write; otherwise read-only.
|
|
73
|
+
*/
|
|
74
|
+
export const renderCodexAgent = (agent) => {
|
|
75
|
+
const { fields, body } = parseAgentDefinition(agent.content);
|
|
76
|
+
const effort = CODEX_EFFORT[fields.effort ?? ""];
|
|
77
|
+
const tools = (fields.tools ?? "").split(",").map((tool) => tool.trim());
|
|
78
|
+
const sandbox = tools.includes("Bash") ? "workspace-write" : "read-only";
|
|
79
|
+
const lines = [
|
|
80
|
+
`name = ${tomlString(agent.name)}`,
|
|
81
|
+
`description = ${tomlString(fields.description ?? agent.name)}`,
|
|
82
|
+
...(effort ? [`model_reasoning_effort = ${tomlString(effort)}`] : []),
|
|
83
|
+
`sandbox_mode = ${tomlString(sandbox)}`,
|
|
84
|
+
`developer_instructions = ${tomlMultiline(body)}`,
|
|
85
|
+
];
|
|
86
|
+
return { file: `${agent.name}.toml`, content: `${lines.join("\n")}\n` };
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Gemini CLI: ~/.gemini/agents/<name>.md (verified loadAgentsFromDirectory:
|
|
90
|
+
* .md files, "_"-prefixed skipped). Frontmatter is reduced to the fields
|
|
91
|
+
* gemini understands; Claude-specific keys (tools/model/effort) are dropped
|
|
92
|
+
* rather than risked against a stricter parser. Coexists with the
|
|
93
|
+
* prism-connect agent policy that owns ~/.gemini/settings.json overrides.
|
|
94
|
+
*/
|
|
95
|
+
export const renderGeminiAgent = (agent) => {
|
|
96
|
+
const { fields, body } = parseAgentDefinition(agent.content);
|
|
97
|
+
const frontmatter = [
|
|
98
|
+
"---",
|
|
99
|
+
`name: ${agent.name}`,
|
|
100
|
+
`description: ${fields.description ?? agent.name}`,
|
|
101
|
+
"---",
|
|
102
|
+
].join("\n");
|
|
103
|
+
return { file: `${agent.name}.md`, content: `${frontmatter}\n\n${body}` };
|
|
104
|
+
};
|
|
105
|
+
function sha256(value) {
|
|
106
|
+
return createHash("sha256").update(value).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the optional agents section of a skill-manifest payload.
|
|
110
|
+
* Returns null when the server predates agents (fields absent).
|
|
111
|
+
* Throws on a malformed section — the caller decides whether that is fatal;
|
|
112
|
+
* it must never be silently treated as "no agents", because that would turn
|
|
113
|
+
* contract drift into an invisible prune signal.
|
|
114
|
+
*/
|
|
115
|
+
export function validateAgentSection(payload) {
|
|
116
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
117
|
+
return null;
|
|
118
|
+
const value = payload;
|
|
119
|
+
if (value.agents === undefined && value.agents_generation === undefined)
|
|
120
|
+
return null;
|
|
121
|
+
if (!Array.isArray(value.agents) || typeof value.agents_generation !== "string" ||
|
|
122
|
+
!SHA256.test(value.agents_generation)) {
|
|
123
|
+
throw new Error("invalid agent section");
|
|
124
|
+
}
|
|
125
|
+
if (value.agents.length > MAX_AGENTS)
|
|
126
|
+
throw new Error("agent section exceeds bounds");
|
|
127
|
+
const names = new Set();
|
|
128
|
+
const agents = value.agents.map((raw, index) => {
|
|
129
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
130
|
+
throw new Error(`invalid agent at index ${index}`);
|
|
131
|
+
const agent = raw;
|
|
132
|
+
if (typeof agent.name !== "string" || !SAFE_NAME.test(agent.name))
|
|
133
|
+
throw new Error(`invalid agent name at index ${index}`);
|
|
134
|
+
const folded = agent.name.toLocaleLowerCase("en-US");
|
|
135
|
+
if (names.has(folded))
|
|
136
|
+
throw new Error(`duplicate agent name: ${agent.name}`);
|
|
137
|
+
names.add(folded);
|
|
138
|
+
if (typeof agent.content !== "string" || !agent.content.trim())
|
|
139
|
+
throw new Error(`empty agent content: ${agent.name}`);
|
|
140
|
+
if (Buffer.byteLength(agent.content, "utf8") > MAX_AGENT_BYTES)
|
|
141
|
+
throw new Error(`agent definition too large: ${agent.name}`);
|
|
142
|
+
if (typeof agent.digest !== "string" || !SHA256.test(agent.digest) ||
|
|
143
|
+
sha256(Buffer.from(agent.content, "utf8")) !== agent.digest.toLowerCase()) {
|
|
144
|
+
throw new Error(`agent digest mismatch: ${agent.name}`);
|
|
145
|
+
}
|
|
146
|
+
return { name: agent.name, content: agent.content, digest: agent.digest.toLowerCase() };
|
|
147
|
+
});
|
|
148
|
+
return { agents, generation: value.agents_generation.toLowerCase() };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Claude Code's agent-definition root, mirroring the skill sync's detection
|
|
152
|
+
* guard: auto-detect only in the production default configuration so tests
|
|
153
|
+
* and custom-root callers stay isolated.
|
|
154
|
+
*/
|
|
155
|
+
export async function resolveClaudeAgentsDir(options = {}) {
|
|
156
|
+
if (options.claudeCodeAgentsDir === false)
|
|
157
|
+
return null;
|
|
158
|
+
if (typeof options.claudeCodeAgentsDir === "string")
|
|
159
|
+
return options.claudeCodeAgentsDir;
|
|
160
|
+
if (options.agentsSkillsDir !== undefined)
|
|
161
|
+
return null;
|
|
162
|
+
const claudeHome = join(options.homeDir ?? homedir(), ".claude");
|
|
163
|
+
try {
|
|
164
|
+
const stat = await lstat(claudeHome);
|
|
165
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return join(claudeHome, "agents");
|
|
172
|
+
}
|
|
173
|
+
async function detectHostAgentsDir(hostHome) {
|
|
174
|
+
try {
|
|
175
|
+
const stat = await lstat(hostHome);
|
|
176
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return join(hostHome, "agents");
|
|
183
|
+
}
|
|
184
|
+
/** Codex agent root (~/.codex/agents or $CODEX_HOME/agents), same guards. */
|
|
185
|
+
export async function resolveCodexAgentsDir(options = {}) {
|
|
186
|
+
if (options.codexAgentsDir === false)
|
|
187
|
+
return null;
|
|
188
|
+
if (typeof options.codexAgentsDir === "string")
|
|
189
|
+
return options.codexAgentsDir;
|
|
190
|
+
if (options.agentsSkillsDir !== undefined)
|
|
191
|
+
return null;
|
|
192
|
+
const configured = (options.env ?? process.env).CODEX_HOME?.trim();
|
|
193
|
+
const codexHome = configured || join(options.homeDir ?? homedir(), ".codex");
|
|
194
|
+
return detectHostAgentsDir(codexHome);
|
|
195
|
+
}
|
|
196
|
+
/** Gemini CLI agent root (~/.gemini/agents), same guards. */
|
|
197
|
+
export async function resolveGeminiAgentsDir(options = {}) {
|
|
198
|
+
if (options.geminiAgentsDir === false)
|
|
199
|
+
return null;
|
|
200
|
+
if (typeof options.geminiAgentsDir === "string")
|
|
201
|
+
return options.geminiAgentsDir;
|
|
202
|
+
if (options.agentsSkillsDir !== undefined)
|
|
203
|
+
return null;
|
|
204
|
+
return detectHostAgentsDir(join(options.homeDir ?? homedir(), ".gemini"));
|
|
205
|
+
}
|
|
206
|
+
const SAFE_FILE = /^[A-Za-z0-9_-]+\.(md|toml)$/;
|
|
207
|
+
async function readIndex(path) {
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
210
|
+
if (parsed?.owner !== OWNER || typeof parsed.files !== "object" || !parsed.files)
|
|
211
|
+
return null;
|
|
212
|
+
const files = {};
|
|
213
|
+
for (const [name, entry] of Object.entries(parsed.files)) {
|
|
214
|
+
if (!SAFE_NAME.test(name) || !entry || typeof entry !== "object")
|
|
215
|
+
continue;
|
|
216
|
+
const { digest, file } = entry;
|
|
217
|
+
if (typeof digest === "string" && SHA256.test(digest) && typeof file === "string" && SAFE_FILE.test(file)) {
|
|
218
|
+
files[name] = { digest: digest.toLowerCase(), file };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { owner: OWNER, generation: typeof parsed.generation === "string" ? parsed.generation : "", files };
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function currentDigest(path) {
|
|
228
|
+
try {
|
|
229
|
+
const stat = await lstat(path);
|
|
230
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
231
|
+
return null;
|
|
232
|
+
return sha256(await readFile(path));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Exclusive install: hard-link the temp file into place. Unlike rename(2),
|
|
240
|
+
* link(2) fails with EEXIST instead of overwriting, so a file that appears
|
|
241
|
+
* between our existence check and the install cannot be clobbered (TOCTOU).
|
|
242
|
+
* Returns false when the target already exists.
|
|
243
|
+
*/
|
|
244
|
+
async function installExclusive(dir, target, content) {
|
|
245
|
+
const temp = join(dir, `.prism-agent-${randomUUID()}.tmp`);
|
|
246
|
+
await writeFile(temp, content, { mode: 0o600 });
|
|
247
|
+
try {
|
|
248
|
+
await link(temp, target);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (isErrno(error, "EEXIST"))
|
|
253
|
+
return false;
|
|
254
|
+
// Filesystems without hard-link support (exFAT/FAT32, several SMB and
|
|
255
|
+
// container-volume mounts) reject link(2) outright. Without this fallback
|
|
256
|
+
// those users receive ZERO agent definitions and only a stderr line —
|
|
257
|
+
// the exclusivity hardening would have silently disabled the feature.
|
|
258
|
+
// rename(2) is marginally weaker against a concurrent writer inside the
|
|
259
|
+
// check→act window; delivering nothing is strictly worse.
|
|
260
|
+
if (isErrno(error, "EPERM") || isErrno(error, "ENOTSUP") ||
|
|
261
|
+
isErrno(error, "EOPNOTSUPP") || isErrno(error, "EXDEV")) {
|
|
262
|
+
if (await currentDigest(target) !== null)
|
|
263
|
+
return false;
|
|
264
|
+
await rename(temp, target);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await rm(temp, { force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Claim-then-verify mutation: atomically rename the target out of the root,
|
|
275
|
+
* re-hash the CLAIMED bytes, and only act when they still match the recorded
|
|
276
|
+
* digest. A mismatch (someone edited the file inside the check→act window)
|
|
277
|
+
* restores the file byte-identical and reports a conflict. This closes the
|
|
278
|
+
* check-then-act race CodeQL flags (js/file-system-race): after the rename we
|
|
279
|
+
* operate on a path no other writer targets, and the decision digest is
|
|
280
|
+
* computed from those exact bytes.
|
|
281
|
+
*/
|
|
282
|
+
async function claimVerified(dir, target, expectedDigest) {
|
|
283
|
+
const claimPath = join(dir, `.prism-agent-claim-${randomUUID()}.tmp`);
|
|
284
|
+
try {
|
|
285
|
+
await rename(target, claimPath);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (isErrno(error, "ENOENT"))
|
|
289
|
+
return "gone";
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
const bytes = await readFile(claimPath);
|
|
293
|
+
if (sha256(bytes) !== expectedDigest) {
|
|
294
|
+
await rename(claimPath, target); // restore byte-identical
|
|
295
|
+
return "mismatch";
|
|
296
|
+
}
|
|
297
|
+
return { claimed: claimPath };
|
|
298
|
+
}
|
|
299
|
+
function isErrno(error, code) {
|
|
300
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
301
|
+
&& error.code === code;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Apply the agent section to a host agent root. Every write is an atomic
|
|
305
|
+
* tmp+rename of a single small file; the index commits last, so a crash
|
|
306
|
+
* mid-run leaves files that the next run re-proves ownership of by digest.
|
|
307
|
+
*/
|
|
308
|
+
export async function materializeAgentDefinitions(section, targetDir, render = renderClaudeAgent) {
|
|
309
|
+
await mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
310
|
+
const rootStat = await lstat(targetDir);
|
|
311
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
312
|
+
throw new Error("agent root must be a real directory");
|
|
313
|
+
}
|
|
314
|
+
const indexPath = join(targetDir, INDEX);
|
|
315
|
+
const index = await readIndex(indexPath);
|
|
316
|
+
const owned = index?.files ?? {};
|
|
317
|
+
const incoming = new Map();
|
|
318
|
+
for (const agent of section.agents) {
|
|
319
|
+
const rendered = render(agent);
|
|
320
|
+
if (rendered === null)
|
|
321
|
+
continue;
|
|
322
|
+
if (!SAFE_FILE.test(rendered.file))
|
|
323
|
+
throw new Error(`unsafe rendered agent file: ${rendered.file}`);
|
|
324
|
+
incoming.set(agent.name, rendered);
|
|
325
|
+
}
|
|
326
|
+
const installed = [];
|
|
327
|
+
const updated = [];
|
|
328
|
+
const pruned = [];
|
|
329
|
+
const conflicts = [];
|
|
330
|
+
const finalFiles = {};
|
|
331
|
+
// Downgrades first, mirroring the skill engine's ordering: entitlement
|
|
332
|
+
// removal must not depend on the success of later installs. Deletion is
|
|
333
|
+
// claim-then-verify: the digest that authorizes the rm is computed from the
|
|
334
|
+
// bytes AFTER they leave the discovery root, so an edit racing the check
|
|
335
|
+
// can never be destroyed.
|
|
336
|
+
for (const [name, entry] of Object.entries(owned)) {
|
|
337
|
+
if (incoming.has(name))
|
|
338
|
+
continue;
|
|
339
|
+
const target = join(targetDir, entry.file);
|
|
340
|
+
const claim = await claimVerified(targetDir, target, entry.digest);
|
|
341
|
+
if (claim === "gone")
|
|
342
|
+
continue; // already gone
|
|
343
|
+
if (claim === "mismatch") {
|
|
344
|
+
// Hand-edited content survives; we merely stop claiming it.
|
|
345
|
+
conflicts.push(name);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
await rm(claim.claimed, { force: true });
|
|
349
|
+
pruned.push(name);
|
|
350
|
+
}
|
|
351
|
+
for (const [name, rendered] of incoming) {
|
|
352
|
+
const target = join(targetDir, rendered.file);
|
|
353
|
+
const renderedDigest = sha256(Buffer.from(rendered.content, "utf8"));
|
|
354
|
+
const digestOnDisk = await currentDigest(target);
|
|
355
|
+
if (digestOnDisk === null) {
|
|
356
|
+
// Exclusive install: a file appearing inside the window makes link()
|
|
357
|
+
// fail instead of being overwritten; re-judge it as foreign content.
|
|
358
|
+
if (await installExclusive(targetDir, target, rendered.content)) {
|
|
359
|
+
installed.push(name);
|
|
360
|
+
finalFiles[name] = { digest: renderedDigest, file: rendered.file };
|
|
361
|
+
}
|
|
362
|
+
else if (await currentDigest(target) === renderedDigest) {
|
|
363
|
+
finalFiles[name] = { digest: renderedDigest, file: rendered.file };
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
conflicts.push(name);
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const record = owned[name];
|
|
371
|
+
const pristine = record !== undefined && record.file === rendered.file && digestOnDisk === record.digest;
|
|
372
|
+
if (!pristine) {
|
|
373
|
+
if (digestOnDisk === renderedDigest) {
|
|
374
|
+
// Byte-identical foreign file: adopt without writing. Content equality
|
|
375
|
+
// is the strongest ownership evidence available for a single file.
|
|
376
|
+
finalFiles[name] = { digest: renderedDigest, file: rendered.file };
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
conflicts.push(name);
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (digestOnDisk === renderedDigest) {
|
|
384
|
+
finalFiles[name] = { digest: renderedDigest, file: rendered.file };
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// Update = claim the old version out (verifying the recorded digest on
|
|
388
|
+
// the claimed bytes), then exclusively install the new render. A racer
|
|
389
|
+
// in either window wins the file and we report a conflict instead of
|
|
390
|
+
// clobbering; the claimed old version is ours and safe to discard.
|
|
391
|
+
const claim = await claimVerified(targetDir, target, record.digest);
|
|
392
|
+
if (claim === "mismatch") {
|
|
393
|
+
conflicts.push(name);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const installedNow = await installExclusive(targetDir, target, rendered.content);
|
|
397
|
+
if (claim !== "gone")
|
|
398
|
+
await rm(claim.claimed, { force: true });
|
|
399
|
+
if (installedNow) {
|
|
400
|
+
updated.push(name);
|
|
401
|
+
finalFiles[name] = { digest: renderedDigest, file: rendered.file };
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
conflicts.push(name);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const nextIndex = { owner: OWNER, generation: section.generation, files: finalFiles };
|
|
408
|
+
const tempIndex = join(targetDir, `${INDEX}.${randomUUID()}.tmp`);
|
|
409
|
+
await writeFile(tempIndex, `${JSON.stringify(nextIndex, null, 2)}\n`, { mode: 0o600 });
|
|
410
|
+
await rename(tempIndex, indexPath);
|
|
411
|
+
return {
|
|
412
|
+
installed: installed.sort(),
|
|
413
|
+
updated: updated.sort(),
|
|
414
|
+
pruned: pruned.sort(),
|
|
415
|
+
conflicts: [...new Set(conflicts)].sort(),
|
|
416
|
+
};
|
|
417
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -393,7 +393,11 @@ program
|
|
|
393
393
|
// v9.2.2: --storage flag overrides PRISM_STORAGE env var to prevent
|
|
394
394
|
// split-brain when CLI environment differs from MCP server config.
|
|
395
395
|
if (storage) {
|
|
396
|
-
|
|
396
|
+
// Must mirror the PRISM_STORAGE union in config.ts. This list shipped
|
|
397
|
+
// as ['local','supabase'] — rejecting 'auto' (the documented default in
|
|
398
|
+
// this very command's --storage help text) and 'synalux' (the actual
|
|
399
|
+
// production backend). Found 2026-08-03; the config type is the truth.
|
|
400
|
+
const validStorages = ['auto', 'local', 'supabase', 'synalux'];
|
|
397
401
|
if (!validStorages.includes(storage)) {
|
|
398
402
|
console.error(`Error: Invalid storage "${storage}". Must be one of: ${validStorages.join(', ')}`);
|
|
399
403
|
process.exit(1);
|
|
@@ -512,7 +516,11 @@ saveCmd
|
|
|
512
516
|
try {
|
|
513
517
|
// Storage override
|
|
514
518
|
if (options.storage) {
|
|
515
|
-
|
|
519
|
+
// Must mirror the PRISM_STORAGE union in config.ts. This list shipped
|
|
520
|
+
// as ['local','supabase'] — rejecting 'auto' (the documented default in
|
|
521
|
+
// this very command's --storage help text) and 'synalux' (the actual
|
|
522
|
+
// production backend). Found 2026-08-03; the config type is the truth.
|
|
523
|
+
const validStorages = ['auto', 'local', 'supabase', 'synalux'];
|
|
516
524
|
if (!validStorages.includes(options.storage)) {
|
|
517
525
|
console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
|
|
518
526
|
process.exit(1);
|
|
@@ -565,7 +573,11 @@ saveCmd
|
|
|
565
573
|
try {
|
|
566
574
|
// Storage override
|
|
567
575
|
if (options.storage) {
|
|
568
|
-
|
|
576
|
+
// Must mirror the PRISM_STORAGE union in config.ts. This list shipped
|
|
577
|
+
// as ['local','supabase'] — rejecting 'auto' (the documented default in
|
|
578
|
+
// this very command's --storage help text) and 'synalux' (the actual
|
|
579
|
+
// production backend). Found 2026-08-03; the config type is the truth.
|
|
580
|
+
const validStorages = ['auto', 'local', 'supabase', 'synalux'];
|
|
569
581
|
if (!validStorages.includes(options.storage)) {
|
|
570
582
|
console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
|
|
571
583
|
process.exit(1);
|
package/dist/connect.js
CHANGED
|
@@ -60,15 +60,48 @@ const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
|
|
|
60
60
|
};
|
|
61
61
|
const INSTALLATION_RECEIPT_SCHEMA_VERSION = 1;
|
|
62
62
|
const INSTALLATION_RECEIPT_OWNER = "prism-connect";
|
|
63
|
+
/**
|
|
64
|
+
* Economy subagent model per host.
|
|
65
|
+
*
|
|
66
|
+
* The local-first policy already permits exactly this — "a host-native subagent
|
|
67
|
+
* is a last resort: at most one, no nesting, using the configured economy
|
|
68
|
+
* model" — but only Claude was configured that way. Gemini and Codex banned
|
|
69
|
+
* subagents outright, which is stricter than the policy Prism publishes.
|
|
70
|
+
*
|
|
71
|
+
* The stated reason for Gemini was "native/remote subagents". That does not
|
|
72
|
+
* survive checking: Gemini CLI subagents execute locally, in-process, with
|
|
73
|
+
* optional local container sandboxes. The model is remote, which is equally
|
|
74
|
+
* true of Claude and Codex. There was no egress difference to justify the
|
|
75
|
+
* asymmetry.
|
|
76
|
+
*
|
|
77
|
+
* Cursor is deliberately absent. Its Background Agents really do run in remote
|
|
78
|
+
* cloud sandboxes with repository access — a different risk class — and no
|
|
79
|
+
* subagent-model setting was found for it.
|
|
80
|
+
*/
|
|
63
81
|
const CLAUDE_FALLBACK_SUBAGENT_MODEL = "sonnet";
|
|
82
|
+
/** Fastest tier of the GPT-5.6 line (Sol > Terra > Luna) for simple work. */
|
|
83
|
+
const CODEX_ECONOMY_SUBAGENT_MODEL = "gpt-5.6-luna";
|
|
84
|
+
/**
|
|
85
|
+
* Gemini's speed tier for agentic work.
|
|
86
|
+
*
|
|
87
|
+
* Confirmed working in practice. An earlier revision changed this to
|
|
88
|
+
* `gemini-3-flash-preview` because `gemini-3.6-flash` appears in none of the
|
|
89
|
+
* 116 files of gemini-cli 0.49.0 — but the CLI does not validate Gemini model
|
|
90
|
+
* names at all. Its only ALLOWED_MODELS list gates Whisper speech binaries.
|
|
91
|
+
* Model names are passed through to the API, so absence from the bundle says
|
|
92
|
+
* nothing about resolvability. That reversion was a regression to an older ID.
|
|
93
|
+
*/
|
|
94
|
+
const GEMINI_ECONOMY_SUBAGENT_MODEL = "gemini-3.6-flash";
|
|
64
95
|
const CODEX_LOCAL_FIRST_POLICY = {
|
|
65
96
|
features: {
|
|
66
|
-
|
|
97
|
+
// Subagents permitted, but bounded: the ceiling below is what keeps this
|
|
98
|
+
// from becoming fan-out. Previously false, which contradicted the policy.
|
|
99
|
+
multi_agent: true,
|
|
67
100
|
},
|
|
68
101
|
agents: {
|
|
69
|
-
max_threads: 2
|
|
70
|
-
max_depth: 1,
|
|
71
|
-
default_subagent_model:
|
|
102
|
+
max_threads: 1, // "at most one" — was 2
|
|
103
|
+
max_depth: 1, // "no nesting"
|
|
104
|
+
default_subagent_model: CODEX_ECONOMY_SUBAGENT_MODEL,
|
|
72
105
|
default_subagent_reasoning_effort: "low",
|
|
73
106
|
job_max_runtime_seconds: 900,
|
|
74
107
|
},
|
|
@@ -827,7 +860,21 @@ export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false,
|
|
|
827
860
|
return true;
|
|
828
861
|
}, dryRun, beforeCommit);
|
|
829
862
|
}
|
|
830
|
-
/**
|
|
863
|
+
/**
|
|
864
|
+
* Pin Gemini's subagents to the economy model rather than banning them.
|
|
865
|
+
*
|
|
866
|
+
* Previously this wrote `enableAgents = false`, which is stricter than the
|
|
867
|
+
* local-first policy Prism publishes — that policy allows "at most one, no
|
|
868
|
+
* nesting, using the configured economy model", and Claude was configured
|
|
869
|
+
* exactly that way. The stated reason was "native/remote subagents", which does
|
|
870
|
+
* not hold: Gemini CLI subagents execute locally, in-process, with optional
|
|
871
|
+
* local container sandboxes. Only the model is remote, as it is for every host.
|
|
872
|
+
*
|
|
873
|
+
* `agents.overrides.<name>.modelConfig.model` is the documented shape. Only
|
|
874
|
+
* `codebase_investigator` is pinned: it is the built-in whose name is published,
|
|
875
|
+
* and inventing names for the others would write dead config. Unpinned
|
|
876
|
+
* subagents inherit the session model, which is the pre-existing behaviour.
|
|
877
|
+
*/
|
|
831
878
|
export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
832
879
|
const configPath = join(homeDir, ".gemini", "settings.json");
|
|
833
880
|
return configureJsonAgentPolicy(configPath, "Gemini agent settings", (config) => {
|
|
@@ -835,11 +882,42 @@ export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false,
|
|
|
835
882
|
if (currentExperimental !== undefined && !isJsonObject(currentExperimental)) {
|
|
836
883
|
throw new Error('"experimental" must be a JSON object');
|
|
837
884
|
}
|
|
885
|
+
const currentAgents = config.agents;
|
|
886
|
+
if (currentAgents !== undefined && !isJsonObject(currentAgents)) {
|
|
887
|
+
throw new Error('"agents" must be a JSON object');
|
|
888
|
+
}
|
|
838
889
|
const experimental = (currentExperimental ?? {});
|
|
839
|
-
|
|
890
|
+
const agents = (currentAgents ?? {});
|
|
891
|
+
const currentOverrides = agents.overrides;
|
|
892
|
+
if (currentOverrides !== undefined && !isJsonObject(currentOverrides)) {
|
|
893
|
+
throw new Error('"agents.overrides" must be a JSON object');
|
|
894
|
+
}
|
|
895
|
+
const overrides = (currentOverrides ?? {});
|
|
896
|
+
const investigator = (isJsonObject(overrides.codebase_investigator)
|
|
897
|
+
? overrides.codebase_investigator
|
|
898
|
+
: {});
|
|
899
|
+
const modelConfig = (isJsonObject(investigator.modelConfig)
|
|
900
|
+
? investigator.modelConfig
|
|
901
|
+
: {});
|
|
902
|
+
// Respect a deliberate opt-out. Prism previously forced enableAgents=false
|
|
903
|
+
// on every run; flipping that to an unconditional `true` would repeat the
|
|
904
|
+
// same mistake pointing the other way, and switching something ON that a
|
|
905
|
+
// user turned OFF is the more intrusive direction. Only enable when the
|
|
906
|
+
// key is absent — pin the model either way, so a user who keeps subagents
|
|
907
|
+
// off still gets the economy default if they later turn them on.
|
|
908
|
+
const userDisabledDeliberately = experimental.enableAgents === false;
|
|
909
|
+
const alreadyApplied = (userDisabledDeliberately || experimental.enableAgents === true)
|
|
910
|
+
&& modelConfig.model === GEMINI_ECONOMY_SUBAGENT_MODEL;
|
|
911
|
+
if (alreadyApplied)
|
|
840
912
|
return false;
|
|
841
|
-
|
|
913
|
+
if (!userDisabledDeliberately)
|
|
914
|
+
experimental.enableAgents = true;
|
|
915
|
+
modelConfig.model = GEMINI_ECONOMY_SUBAGENT_MODEL;
|
|
916
|
+
investigator.modelConfig = modelConfig;
|
|
917
|
+
overrides.codebase_investigator = investigator;
|
|
918
|
+
agents.overrides = overrides;
|
|
842
919
|
config.experimental = experimental;
|
|
920
|
+
config.agents = agents;
|
|
843
921
|
return true;
|
|
844
922
|
}, dryRun, beforeCommit);
|
|
845
923
|
}
|
|
@@ -933,7 +1011,12 @@ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit,
|
|
|
933
1011
|
: undefined;
|
|
934
1012
|
if (!parsedFeatures
|
|
935
1013
|
|| !parsedAgents
|
|
936
|
-
|
|
1014
|
+
// Validate against the declared policy, not a literal. Hardcoding `false`
|
|
1015
|
+
// here meant the guard rejected any change to CODEX_LOCAL_FIRST_POLICY
|
|
1016
|
+
// rather than verifying the write matched it — a guard pinned to a value
|
|
1017
|
+
// instead of to the invariant it exists to protect.
|
|
1018
|
+
|| !Object.entries(CODEX_LOCAL_FIRST_POLICY.features)
|
|
1019
|
+
.every(([key, value]) => isDeepStrictEqual(parsedFeatures[key], value))
|
|
937
1020
|
|| !Object.entries(CODEX_LOCAL_FIRST_POLICY.agents)
|
|
938
1021
|
.every(([key, value]) => isDeepStrictEqual(parsedAgents[key], value))) {
|
|
939
1022
|
throw new Error("Generated Codex local-first policy failed validation");
|
|
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realp
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
|
|
7
|
+
import { materializeAgentDefinitions, renderClaudeAgent, renderCodexAgent, renderGeminiAgent, resolveClaudeAgentsDir, resolveCodexAgentsDir, resolveGeminiAgentsDir, validateAgentSection, } from "./agentManifestSync.js";
|
|
7
8
|
import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
|
|
8
9
|
import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
|
|
9
10
|
const OWNER = "prism-skill-sync-v1";
|
|
@@ -763,6 +764,10 @@ async function fetchManifest(options) {
|
|
|
763
764
|
if (!headers.Authorization && manifest.tier !== "free") {
|
|
764
765
|
throw new Error("unauthenticated skill manifest must be free tier");
|
|
765
766
|
}
|
|
767
|
+
// Agents ride the same response under separate fields. A malformed section
|
|
768
|
+
// is contract drift and must fail the fetch loudly — treating it as "no
|
|
769
|
+
// agents" would convert server corruption into a silent prune signal.
|
|
770
|
+
manifest.agentSection = validateAgentSection(payload);
|
|
766
771
|
return manifest;
|
|
767
772
|
}
|
|
768
773
|
async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
|
|
@@ -851,6 +856,35 @@ async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
|
|
|
851
856
|
}
|
|
852
857
|
};
|
|
853
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Converge every detected host's agent root onto the portal-validated agent
|
|
861
|
+
* section. Prefixed outcomes per host; a failure on one host degrades to a
|
|
862
|
+
* conflict rather than aborting the others or the skill result.
|
|
863
|
+
*/
|
|
864
|
+
async function materializeAgentsAcrossHosts(section, options) {
|
|
865
|
+
const result = { installed: [], updated: [], pruned: [], conflicts: [] };
|
|
866
|
+
const hostTargets = [
|
|
867
|
+
{ prefix: "agent", dir: await resolveClaudeAgentsDir(options), render: renderClaudeAgent },
|
|
868
|
+
{ prefix: "agent-codex", dir: await resolveCodexAgentsDir(options), render: renderCodexAgent },
|
|
869
|
+
{ prefix: "agent-gemini", dir: await resolveGeminiAgentsDir(options), render: renderGeminiAgent },
|
|
870
|
+
];
|
|
871
|
+
for (const host of hostTargets) {
|
|
872
|
+
if (!host.dir)
|
|
873
|
+
continue;
|
|
874
|
+
try {
|
|
875
|
+
const outcome = await materializeAgentDefinitions(section, host.dir, host.render);
|
|
876
|
+
result.installed.push(...outcome.installed.map((name) => `${host.prefix}:${name}`));
|
|
877
|
+
result.updated.push(...outcome.updated.map((name) => `${host.prefix}:${name}`));
|
|
878
|
+
result.pruned.push(...outcome.pruned.map((name) => `${host.prefix}:${name}`));
|
|
879
|
+
result.conflicts.push(...outcome.conflicts.map((name) => `${host.prefix}:${name}`));
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
console.error(`[Prism Skill Sync] ${host.prefix} materialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
883
|
+
result.conflicts.push(`${host.prefix}:sync-failed`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
854
888
|
export async function synchronizeSkillManifest(options = {}) {
|
|
855
889
|
const empty = { installed: [], updated: [], pruned: [], conflicts: [] };
|
|
856
890
|
let nativeSkillsDirs = [];
|
|
@@ -890,6 +924,17 @@ export async function synchronizeSkillManifest(options = {}) {
|
|
|
890
924
|
nativeResults.push(await materializeNative(manifest, nativeSkillsDir, options));
|
|
891
925
|
}
|
|
892
926
|
const native = mergeNativeResults(nativeResults);
|
|
927
|
+
// Agent definitions are additive: they piggyback on the manifest with
|
|
928
|
+
// their own generation, and each host's outcome reports under its own
|
|
929
|
+
// prefix. A failure on one host never rolls back skill state or the
|
|
930
|
+
// other hosts — it surfaces as a conflict instead.
|
|
931
|
+
if (manifest.agentSection) {
|
|
932
|
+
const outcome = await materializeAgentsAcrossHosts(manifest.agentSection, options);
|
|
933
|
+
native.installed.push(...outcome.installed);
|
|
934
|
+
native.updated.push(...outcome.updated);
|
|
935
|
+
native.pruned.push(...outcome.pruned);
|
|
936
|
+
native.conflicts.push(...outcome.conflicts);
|
|
937
|
+
}
|
|
893
938
|
const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
|
|
894
939
|
return {
|
|
895
940
|
status,
|
|
@@ -915,6 +960,19 @@ export async function synchronizeSkillManifest(options = {}) {
|
|
|
915
960
|
catch (enforcement) {
|
|
916
961
|
enforcementErrors.push(`${nativeSkillsDir}: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
|
|
917
962
|
}
|
|
963
|
+
// Agent definitions need the SAME downgrade guarantee as skills. Without
|
|
964
|
+
// this, a tier downgrade whose DB apply throws prunes paid skills but
|
|
965
|
+
// leaves paid agent definitions in every host root until some later
|
|
966
|
+
// successful sync — exactly the local-fault-becomes-entitlement-bypass
|
|
967
|
+
// the skill path above exists to prevent.
|
|
968
|
+
if (manifest.agentSection) {
|
|
969
|
+
try {
|
|
970
|
+
await materializeAgentsAcrossHosts(manifest.agentSection, options);
|
|
971
|
+
}
|
|
972
|
+
catch (enforcement) {
|
|
973
|
+
enforcementErrors.push(`agents: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
918
976
|
enforcementError = enforcementErrors.length > 0
|
|
919
977
|
? `; entitlement cleanup failed: ${enforcementErrors.join(", ")}`
|
|
920
978
|
: "";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
|
+
import * as http from "node:http";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { redactSettings, toMarkdown } from "./commonHelpers.js";
|
|
6
7
|
import { scanAndRedactPHI } from "../utils/phiGuard.js";
|
|
@@ -24,11 +25,12 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
|
|
|
24
25
|
* ═══════════════════════════════════════════════════════════════════
|
|
25
26
|
*/
|
|
26
27
|
import { debugLog } from "../utils/logger.js";
|
|
28
|
+
import { FREE_ENTITLEMENTS } from "../utils/entitlements.js";
|
|
27
29
|
import { getStorage, activeStorageBackend } from "../storage/index.js";
|
|
28
30
|
import { toKeywordArray } from "../utils/keywordExtractor.js";
|
|
29
31
|
import { getLLMProvider } from "../utils/llm/factory.js";
|
|
30
32
|
import { getCurrentGitState, getGitDrift } from "../utils/git.js";
|
|
31
|
-
import { getSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
|
|
33
|
+
import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
|
|
32
34
|
import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
|
|
33
35
|
import { resolveProject } from "../utils/projectResolver.js";
|
|
34
36
|
import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
|
|
@@ -276,8 +278,20 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
276
278
|
const superSkills = snapshot.names.filter((name) => name.endsWith("-super-skill"));
|
|
277
279
|
const superSkillAliases = superSkills.map((name) => `${name.slice(0, -"-super-skill".length)} (${name})`);
|
|
278
280
|
const otherTierSkills = snapshot.names.filter((name) => !coreSkillSet.has(name) && !name.endsWith("-super-skill"));
|
|
281
|
+
// Conflicts must be LOUD, named, and actionable. This used to render as
|
|
282
|
+
// "· 2 local conflicts preserved" — a count with benign phrasing, so two
|
|
283
|
+
// safety skills (ask-first among them) sat 4 months stale on disk while
|
|
284
|
+
// every sync silently skipped them and nobody was ever told which ones.
|
|
279
285
|
const conflictSuffix = snapshot.conflicts.length > 0
|
|
280
|
-
? ` · ${snapshot.conflicts.length}
|
|
286
|
+
? ` · ${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} — see warning`
|
|
287
|
+
: "";
|
|
288
|
+
const conflictWarning = snapshot.conflicts.length > 0
|
|
289
|
+
? `\n> - ⚠️ **SKILLS NOT UPDATING (local copy has no Prism ownership marker):** ` +
|
|
290
|
+
`${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
|
|
291
|
+
`Each named skill is frozen at whatever version is on disk — updates are ` +
|
|
292
|
+
`withheld to protect local edits. To resume updates: move the skill's ` +
|
|
293
|
+
`directory out of the native skills folder and rerun \`prism connect\` ` +
|
|
294
|
+
`(or a session bootstrap) to reinstall the managed copy.`
|
|
281
295
|
: "";
|
|
282
296
|
if (snapshot.source === "validated-partial") {
|
|
283
297
|
return `> **Prism System Ready**\n>\n` +
|
|
@@ -287,14 +301,18 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
287
301
|
`> - 🧩 **Super-skill entitlements:** ${formatBoundedSkillNames(superSkillAliases, "entitled")}\n` +
|
|
288
302
|
`> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
|
|
289
303
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
290
|
-
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}
|
|
304
|
+
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
|
|
305
|
+
conflictWarning +
|
|
306
|
+
freeTierUpgradeLine(snapshot.tier);
|
|
291
307
|
}
|
|
292
308
|
if (snapshot.source === "tier-fallback") {
|
|
293
309
|
return `> **Prism System Ready**\n>\n` +
|
|
294
310
|
`> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
|
|
295
311
|
`> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
|
|
296
312
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
297
|
-
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}
|
|
313
|
+
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
|
|
314
|
+
conflictWarning +
|
|
315
|
+
freeTierUpgradeLine(snapshot.tier);
|
|
298
316
|
}
|
|
299
317
|
return `> **Prism System Ready**\n>\n` +
|
|
300
318
|
`> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
|
|
@@ -303,7 +321,63 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
303
321
|
`> - 🧩 **Super-skills provisioned:** ${formatBoundedSkillNames(superSkillAliases, "provisioned")}\n` +
|
|
304
322
|
`> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
|
|
305
323
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
306
|
-
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}
|
|
324
|
+
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
|
|
325
|
+
conflictWarning +
|
|
326
|
+
freeTierUpgradeLine(snapshot.tier);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The paid funnel's one startup line. Before 2026-08-05 the upgrade_url was
|
|
330
|
+
* surfaced only AFTER a user hit an entitlement gate (sessionDriftHandler,
|
|
331
|
+
* queryMemoryNaturalHandler); the startup path — the only guaranteed
|
|
332
|
+
* impression — referenced it zero times.
|
|
333
|
+
*/
|
|
334
|
+
function freeTierUpgradeLine(tier) {
|
|
335
|
+
if (tier !== "free")
|
|
336
|
+
return "";
|
|
337
|
+
return `\n> - 💎 **Free tier:** paid plans unlock the full skill library, ` +
|
|
338
|
+
`super-skills, and agent routing → ${FREE_ENTITLEMENTS.upgrade_url}`;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Dashboard URL for startup output. The bound port is announced on stderr
|
|
342
|
+
* only (MCP stdio owns stdout), so users never saw it; the dashboard also
|
|
343
|
+
* writes the port to ~/.prism-mcp/dashboard.port — read that, then the env
|
|
344
|
+
* override, then the default.
|
|
345
|
+
*/
|
|
346
|
+
async function readDashboardUrl() {
|
|
347
|
+
// Precedence: explicit env override > recorded port file > default. The
|
|
348
|
+
// file is written by whatever dashboard ran last and persists across boots,
|
|
349
|
+
// so it must never outrank configuration the operator set for THIS process.
|
|
350
|
+
let port = (process.env.PRISM_DASHBOARD_PORT || "").trim();
|
|
351
|
+
if (!port) {
|
|
352
|
+
try {
|
|
353
|
+
const recorded = fs.readFileSync(nodePath.join(os.homedir(), ".prism-mcp", "dashboard.port"), "utf8").trim();
|
|
354
|
+
if (/^\d{2,5}$/.test(recorded))
|
|
355
|
+
port = recorded;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// port file absent — dashboard not started yet this boot; default holds
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (!port)
|
|
362
|
+
port = "3000";
|
|
363
|
+
// The port file persists across boots and is never cleaned up, so it is
|
|
364
|
+
// evidence of a PREVIOUS dashboard, not a running one. Advertising a dead
|
|
365
|
+
// URL as the first-run headline action is worse than omitting it.
|
|
366
|
+
//
|
|
367
|
+
// A TCP connect is NOT sufficient: it proves something is listening, not
|
|
368
|
+
// that it is Prism. The default is 3000 — the single most commonly occupied
|
|
369
|
+
// port on a developer machine — so a bare liveness check would confidently
|
|
370
|
+
// point a first-run user at their own dev server. Hit the dashboard's
|
|
371
|
+
// /api/health instead, so identity is verified rather than assumed.
|
|
372
|
+
const healthy = await new Promise((resolveProbe) => {
|
|
373
|
+
const request = http.get({ host: "127.0.0.1", port: Number(port), path: "/api/health", timeout: 300 }, (response) => {
|
|
374
|
+
response.resume(); // drain so the socket can close
|
|
375
|
+
resolveProbe(response.statusCode === 200);
|
|
376
|
+
});
|
|
377
|
+
request.once("timeout", () => { request.destroy(); resolveProbe(false); });
|
|
378
|
+
request.once("error", () => resolveProbe(false));
|
|
379
|
+
});
|
|
380
|
+
return healthy ? `http://localhost:${port}` : null;
|
|
307
381
|
}
|
|
308
382
|
function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
|
|
309
383
|
const maxChars = effectiveNativeBudget(level, requestedMaxChars);
|
|
@@ -1653,15 +1727,57 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1653
1727
|
const role = sanitizeNativeIdentity(defaultRole) || "global";
|
|
1654
1728
|
const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
|
|
1655
1729
|
const systemReadyBlock = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
|
|
1656
|
-
|
|
1730
|
+
// First run = the dashboard has never been touched: no agent identity AND no
|
|
1731
|
+
// projects. Measured 2026-08-05: a brand-new free-tier install was greeted
|
|
1732
|
+
// with "Welcome back", three "Not loaded" rows, a warning, and three
|
|
1733
|
+
// statements of what it doesn't have — an all-absence payload with no next
|
|
1734
|
+
// step, no dashboard URL (stderr-only), and no path to the paid tier.
|
|
1735
|
+
// First run must mean NEW, not merely unconfigured: a user with saved
|
|
1736
|
+
// sessions who never set a name or projects would otherwise be told "first
|
|
1737
|
+
// run detected" every single session. A durable marker is written after the
|
|
1738
|
+
// first bootstrap, so this is decisive rather than heuristic.
|
|
1739
|
+
const bootstrapSeen = (await getSetting("first_bootstrap_at", "")).trim();
|
|
1740
|
+
const isFirstRun = projects.length === 0 && !configuredGreetingName && !bootstrapSeen;
|
|
1741
|
+
if (!bootstrapSeen) {
|
|
1742
|
+
try {
|
|
1743
|
+
await setSetting("first_bootstrap_at", new Date().toISOString());
|
|
1744
|
+
}
|
|
1745
|
+
catch {
|
|
1746
|
+
// Marker is an optimization; a write failure must never block startup.
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
const greeting = isFirstRun
|
|
1750
|
+
? `👋 Welcome to Prism — first run detected. Let's get you productive in a few minutes.`
|
|
1751
|
+
: `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
|
|
1657
1752
|
const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
|
|
1658
|
-
const startupHeader = `${greeting}\n\n${identityBlock}`;
|
|
1753
|
+
const startupHeader = isFirstRun ? greeting : `${greeting}\n\n${identityBlock}`;
|
|
1659
1754
|
if (projects.length === 0) {
|
|
1755
|
+
const dashboardUrl = await readDashboardUrl();
|
|
1756
|
+
const dashboardLine = dashboardUrl
|
|
1757
|
+
? `- 🎛️ **Dashboard:** ${dashboardUrl} — configure projects, identity, and context depth`
|
|
1758
|
+
: `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
|
|
1759
|
+
if (isFirstRun) {
|
|
1760
|
+
// Action-first instead of absence-first: every line is a capability or
|
|
1761
|
+
// a next step. The wizard exists and is well-built; route to it.
|
|
1762
|
+
const firstRunText = `${greeting}\n\n` +
|
|
1763
|
+
`- 🚀 **Get started:** run the \`onboarding_wizard\` tool (guided setup, ~3 minutes)\n` +
|
|
1764
|
+
`${dashboardLine}\n` +
|
|
1765
|
+
`- 💾 **Already working?** \`session_save_ledger\` records this session; the next one resumes with full context\n\n` +
|
|
1766
|
+
`${systemReadyBlock}`;
|
|
1767
|
+
return {
|
|
1768
|
+
content: [{
|
|
1769
|
+
type: "text",
|
|
1770
|
+
text: capNativeStartupText(firstRunText, depth),
|
|
1771
|
+
}],
|
|
1772
|
+
isError: false,
|
|
1773
|
+
structuredContent: { conversation_id: conversationId, projects: [], depth, first_run: true },
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1660
1776
|
const unconfiguredState = (depth === "quick" ? "" : `\n- 📝 **Last Session Summary:** Not loaded`) +
|
|
1661
1777
|
`\n- ✅ **Open TODOs:** Not loaded` +
|
|
1662
1778
|
`\n- 🔄 **Session Version:** Not loaded`;
|
|
1663
1779
|
const noProjectsText = `${startupHeader}${unconfiguredState}\n\n` +
|
|
1664
|
-
`⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n\n${systemReadyBlock}`;
|
|
1780
|
+
`⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n${dashboardLine}\n\n${systemReadyBlock}`;
|
|
1665
1781
|
return {
|
|
1666
1782
|
content: [{
|
|
1667
1783
|
type: "text",
|
|
@@ -1462,8 +1462,8 @@ export const ONBOARDING_WIZARD_TOOL = {
|
|
|
1462
1462
|
description: "Interactive setup wizard for new Prism users. Provides a step-by-step " +
|
|
1463
1463
|
"guided experience to get productive in under 3 minutes.\n\n" +
|
|
1464
1464
|
"**Actions:**\n" +
|
|
1465
|
-
"- `start` — Begin the wizard from step 1\n" +
|
|
1466
|
-
"- `next` — Advance
|
|
1465
|
+
"- `start` (default when omitted) — Begin the wizard from step 1\n" +
|
|
1466
|
+
"- `next` — Advance past `step` (pass the step number you are on)\n" +
|
|
1467
1467
|
"- `status` — Check current wizard progress\n" +
|
|
1468
1468
|
"- `skip` — Skip to completion\n\n" +
|
|
1469
1469
|
"Each step returns instructions, code snippets, and progress percentage.",
|
|
@@ -1473,7 +1473,12 @@ export const ONBOARDING_WIZARD_TOOL = {
|
|
|
1473
1473
|
action: {
|
|
1474
1474
|
type: "string",
|
|
1475
1475
|
enum: ["start", "next", "status", "skip"],
|
|
1476
|
-
description: "Wizard action to perform.",
|
|
1476
|
+
description: "Wizard action to perform. Omitted = start.",
|
|
1477
|
+
},
|
|
1478
|
+
step: {
|
|
1479
|
+
type: "integer",
|
|
1480
|
+
minimum: 0,
|
|
1481
|
+
description: "The step_index from the previous response; used by `next` and `status`.",
|
|
1477
1482
|
},
|
|
1478
1483
|
project_name: {
|
|
1479
1484
|
type: "string",
|
|
@@ -1485,16 +1490,21 @@ export const ONBOARDING_WIZARD_TOOL = {
|
|
|
1485
1490
|
description: "IDE client for config generation.",
|
|
1486
1491
|
},
|
|
1487
1492
|
},
|
|
1488
|
-
required: ["action"],
|
|
1489
1493
|
},
|
|
1490
1494
|
};
|
|
1491
1495
|
export function isOnboardingWizardArgs(args) {
|
|
1492
1496
|
if (typeof args !== "object" || args === null)
|
|
1493
1497
|
return false;
|
|
1494
1498
|
const a = args;
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1499
|
+
// A bare call is the front door for brand-new users (the startup greeting
|
|
1500
|
+
// routes here) — it must work, defaulting to `start`. Measured 2026-08-05:
|
|
1501
|
+
// requiring `action` made the tool the first thing a new user touches AND
|
|
1502
|
+
// the first error they see.
|
|
1503
|
+
if (a.action !== undefined &&
|
|
1504
|
+
(typeof a.action !== "string" || !["start", "next", "status", "skip"].includes(a.action))) {
|
|
1505
|
+
return false;
|
|
1506
|
+
}
|
|
1507
|
+
if (a.step !== undefined && (!Number.isInteger(a.step) || a.step < 0))
|
|
1498
1508
|
return false;
|
|
1499
1509
|
if (a.project_name !== undefined && typeof a.project_name !== "string")
|
|
1500
1510
|
return false;
|
|
@@ -46,6 +46,13 @@ export const REQUIRED_PROTECTED_SKILL_NAMES = [
|
|
|
46
46
|
// so the two sources disagreed. A portal test now asserts they match.
|
|
47
47
|
'data-before-code',
|
|
48
48
|
'critical-thinking-debug',
|
|
49
|
+
// Added 2026-08-03. The floor had grown past every budget tranche, so an
|
|
50
|
+
// unprotected universal skill could NEVER inline — including the destruction
|
|
51
|
+
// gate, during the very audit of a demo-venue wipe it should have prevented.
|
|
52
|
+
// If these two are absent, nothing tells the agent that announcing a wipe is
|
|
53
|
+
// not the same as being allowed to run it.
|
|
54
|
+
'ask-first',
|
|
55
|
+
'feature-preservation',
|
|
49
56
|
];
|
|
50
57
|
/**
|
|
51
58
|
* Native skills that paid subscription tiers receive through `prism connect`.
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { debugLog } from "../utils/logger.js";
|
|
13
13
|
import { isOnboardingWizardArgs, isExtractEntitiesArgs, isBackupDatabaseArgs, isConfigureNotificationsArgs, } from "./sessionMemoryDefinitions.js";
|
|
14
|
-
// ─── Onboarding Wizard Handler ───────────────────────────────
|
|
15
14
|
export async function onboardingWizardHandler(args) {
|
|
16
15
|
if (!isOnboardingWizardArgs(args)) {
|
|
17
16
|
return {
|
|
@@ -19,7 +18,18 @@ export async function onboardingWizardHandler(args) {
|
|
|
19
18
|
isError: true,
|
|
20
19
|
};
|
|
21
20
|
}
|
|
22
|
-
|
|
21
|
+
// The schema advertises action-based navigation while this handler
|
|
22
|
+
// historically read `{step, responses}` — fields the validator never
|
|
23
|
+
// passed, so `next`/`status`/`skip` all silently rendered step 1 and a
|
|
24
|
+
// bare call was rejected outright (measured 2026-08-05). Map the
|
|
25
|
+
// advertised contract onto the stateless wizard: the client carries the
|
|
26
|
+
// step number between calls.
|
|
27
|
+
const action = args.action ?? "start";
|
|
28
|
+
const currentStep = typeof args.step === "number" ? args.step : 0;
|
|
29
|
+
const step = action === "start" ? undefined
|
|
30
|
+
: action === "next" ? currentStep + 1
|
|
31
|
+
: action === "status" ? currentStep
|
|
32
|
+
: Number.MAX_SAFE_INTEGER; // skip → past the final step = completion
|
|
23
33
|
try {
|
|
24
34
|
const wizard = await import("../onboarding/wizard.js");
|
|
25
35
|
if (step === undefined || step === null) {
|
|
@@ -32,6 +42,7 @@ export async function onboardingWizardHandler(args) {
|
|
|
32
42
|
text: JSON.stringify({
|
|
33
43
|
status: "in_progress",
|
|
34
44
|
current_step: state.currentStep,
|
|
45
|
+
step_index: 0,
|
|
35
46
|
total_steps: 8,
|
|
36
47
|
step: content,
|
|
37
48
|
summary: wizard.getWizardSummary(state),
|
|
@@ -39,12 +50,15 @@ export async function onboardingWizardHandler(args) {
|
|
|
39
50
|
}],
|
|
40
51
|
};
|
|
41
52
|
}
|
|
42
|
-
// Advance to
|
|
53
|
+
// Advance to the requested step position. Wizard steps are NAMED
|
|
54
|
+
// ("welcome", "storage", …); the numeric step_index in every response
|
|
55
|
+
// is what clients echo back for `next`/`status`.
|
|
43
56
|
const state = wizard.createWizardState();
|
|
44
|
-
// Advance to the requested step position
|
|
45
57
|
let currentState = state;
|
|
46
|
-
|
|
58
|
+
let stepIndex = 0;
|
|
59
|
+
for (let i = 0; i < step && !wizard.isWizardComplete(currentState); i++) {
|
|
47
60
|
currentState = wizard.advanceWizard(currentState);
|
|
61
|
+
stepIndex += 1;
|
|
48
62
|
}
|
|
49
63
|
if (wizard.isWizardComplete(currentState)) {
|
|
50
64
|
return {
|
|
@@ -66,6 +80,7 @@ export async function onboardingWizardHandler(args) {
|
|
|
66
80
|
text: JSON.stringify({
|
|
67
81
|
status: "in_progress",
|
|
68
82
|
current_step: currentState.currentStep,
|
|
83
|
+
step_index: stepIndex,
|
|
69
84
|
step: content,
|
|
70
85
|
summary: wizard.getWizardSummary(currentState),
|
|
71
86
|
}, null, 2),
|
package/dist/utils/analytics.js
CHANGED
|
@@ -50,6 +50,16 @@ async function ensureTable() {
|
|
|
50
50
|
}
|
|
51
51
|
/** Reset DB connection and in-memory buffer (for tests). */
|
|
52
52
|
export function _resetDb() {
|
|
53
|
+
// Close before dropping the reference. Nulling alone leaks the underlying
|
|
54
|
+
// sqlite handle: harmless on POSIX (unlink works on open files), but on
|
|
55
|
+
// Windows the file stays locked, so a suite that resets between cases
|
|
56
|
+
// accumulates locks on the analytics DB and cannot clean up its temp dir.
|
|
57
|
+
try {
|
|
58
|
+
_db?.close();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A close failure must never break a test reset.
|
|
62
|
+
}
|
|
53
63
|
_db = null;
|
|
54
64
|
_tableReady = false;
|
|
55
65
|
BUFFER.length = 0;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.7.0",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
|
-
"description": "Prism Coder
|
|
5
|
+
"description": "Prism Coder \u2014 Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B\u201332B open-weights LLM fleet.",
|
|
6
6
|
"module": "index.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "dist/server.js",
|