prism-mcp-server 20.6.0 → 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.
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -300,7 +302,8 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
300
302
|
`> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
|
|
301
303
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
302
304
|
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
|
|
303
|
-
conflictWarning
|
|
305
|
+
conflictWarning +
|
|
306
|
+
freeTierUpgradeLine(snapshot.tier);
|
|
304
307
|
}
|
|
305
308
|
if (snapshot.source === "tier-fallback") {
|
|
306
309
|
return `> **Prism System Ready**\n>\n` +
|
|
@@ -308,7 +311,8 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
308
311
|
`> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
|
|
309
312
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
310
313
|
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
|
|
311
|
-
conflictWarning
|
|
314
|
+
conflictWarning +
|
|
315
|
+
freeTierUpgradeLine(snapshot.tier);
|
|
312
316
|
}
|
|
313
317
|
return `> **Prism System Ready**\n>\n` +
|
|
314
318
|
`> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
|
|
@@ -318,7 +322,62 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
|
|
|
318
322
|
`> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
|
|
319
323
|
`> - 🧠 **Context depth:** ${depth}\n` +
|
|
320
324
|
`> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
|
|
321
|
-
conflictWarning
|
|
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;
|
|
322
381
|
}
|
|
323
382
|
function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
|
|
324
383
|
const maxChars = effectiveNativeBudget(level, requestedMaxChars);
|
|
@@ -1668,15 +1727,57 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1668
1727
|
const role = sanitizeNativeIdentity(defaultRole) || "global";
|
|
1669
1728
|
const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
|
|
1670
1729
|
const systemReadyBlock = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
|
|
1671
|
-
|
|
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.`;
|
|
1672
1752
|
const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
|
|
1673
|
-
const startupHeader = `${greeting}\n\n${identityBlock}`;
|
|
1753
|
+
const startupHeader = isFirstRun ? greeting : `${greeting}\n\n${identityBlock}`;
|
|
1674
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
|
+
}
|
|
1675
1776
|
const unconfiguredState = (depth === "quick" ? "" : `\n- 📝 **Last Session Summary:** Not loaded`) +
|
|
1676
1777
|
`\n- ✅ **Open TODOs:** Not loaded` +
|
|
1677
1778
|
`\n- 🔄 **Session Version:** Not loaded`;
|
|
1678
1779
|
const noProjectsText = `${startupHeader}${unconfiguredState}\n\n` +
|
|
1679
|
-
`⚠️ 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}`;
|
|
1680
1781
|
return {
|
|
1681
1782
|
content: [{
|
|
1682
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;
|
|
@@ -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,6 +1,6 @@
|
|
|
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
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",
|