prism-mcp-server 20.2.7 → 20.2.9

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.
@@ -1,12 +1,4 @@
1
- /**
2
- * Quality Gate — deterministic check for obvious inference failures.
3
- *
4
- * NARROW by design: only high-precision signals that rarely false-positive.
5
- * Does NOT judge correctness — that's the grounding verifier's job.
6
- * Does NOT use refusal regex (too many false positives on legitimate output).
7
- *
8
- * Returns: { pass: boolean, reason?: string }
9
- */
1
+ import { parseRouteOutput } from "./routeContract.js";
10
2
  /**
11
3
  * Signal 5 — Tool-call bleed: pipe-delimited format leaking into non-tool turns.
12
4
  * Matches <|tool_call|> and <|tool_call_end|> only — NOT angle-bracket <tool_call> variants
@@ -37,11 +29,22 @@ export function passesQualityGate(stripped, thinkOnly, finishReason, mode) {
37
29
  if (finishReason === "length") {
38
30
  return { pass: false, reason: "hard_truncation" };
39
31
  }
40
- // Signal 5: Tool-call bleed — fine-tuned 4b emits <|tool_call|> format in non-tool turns.
41
- // Pipe-delimited format only; angle-bracket variants are handled by normalizeToolCallFormat.
42
- // False-positive guard: requires the literal pipe tokens, not the words "tool call".
32
+ // Signal 5: Tool-call bleed. The pipe envelope is invalid in chat/code,
33
+ // but it is the canonical trained output in route mode. Route mode parses
34
+ // the whole envelope and fails only when the contract is malformed.
43
35
  if (TOOL_CALL_BLEED_RE.test(stripped)) {
44
- return { pass: false, reason: "tool_call_bleed" };
36
+ if (mode === "route") {
37
+ const parsedRoute = parseRouteOutput(stripped);
38
+ if (parsedRoute.kind === "tool_call") {
39
+ // Continue through the remaining generic loop checks.
40
+ }
41
+ else {
42
+ return { pass: false, reason: "route_tool_call_malformed" };
43
+ }
44
+ }
45
+ else {
46
+ return { pass: false, reason: "tool_call_bleed" };
47
+ }
45
48
  }
46
49
  // Signal 4: Exact-loop detection (two passes).
47
50
  //
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Generic route-output contract for prism_infer.
3
+ *
4
+ * This module deliberately contains no intent taxonomy or benchmark-derived
5
+ * routing heuristics. It only parses the public Prism tool-call envelope and
6
+ * enforces the caller-advertised registry before a host can act on a route.
7
+ */
8
+ export const DEFAULT_PRISM_ROUTE_TOOLS = new Set([
9
+ "session_load_context",
10
+ "session_save_ledger",
11
+ "session_save_handoff",
12
+ "session_compact_ledger",
13
+ "session_search_memory",
14
+ "knowledge_search",
15
+ "brave_web_search",
16
+ ]);
17
+ const PIPE_START = "<|tool_call|>";
18
+ const PIPE_END = "<|tool_call_end|>";
19
+ const ANGLE_START = "<tool_call>";
20
+ const ANGLE_END = "</tool_call>";
21
+ const TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
22
+ const MAX_ROUTE_OUTPUT_CHARS = 32_000;
23
+ const MAX_ROUTE_REASON_CHARS = 256;
24
+ const MAX_ARGUMENT_DEPTH = 32;
25
+ const MAX_ARGUMENT_NODES = 2_048;
26
+ const TOOL_CALL_KEYS = new Set(["name", "arguments", "args"]);
27
+ export function isRouteToolName(value) {
28
+ return typeof value === "string" && TOOL_NAME_RE.test(value);
29
+ }
30
+ function isBoundedJsonValue(root) {
31
+ const stack = [{ value: root, depth: 0 }];
32
+ let nodes = 0;
33
+ while (stack.length > 0) {
34
+ const current = stack.pop();
35
+ nodes += 1;
36
+ if (nodes > MAX_ARGUMENT_NODES || current.depth > MAX_ARGUMENT_DEPTH) {
37
+ return false;
38
+ }
39
+ if (current.value === null)
40
+ continue;
41
+ if (typeof current.value === "number") {
42
+ if (!Number.isFinite(current.value))
43
+ return false;
44
+ continue;
45
+ }
46
+ if (typeof current.value === "string" ||
47
+ typeof current.value === "boolean") {
48
+ continue;
49
+ }
50
+ if (typeof current.value !== "object")
51
+ return false;
52
+ const values = Array.isArray(current.value)
53
+ ? current.value
54
+ : Object.values(current.value);
55
+ for (const value of values) {
56
+ stack.push({ value, depth: current.depth + 1 });
57
+ }
58
+ }
59
+ return true;
60
+ }
61
+ function primitiveLeaves(value) {
62
+ const leaves = [];
63
+ const stack = [value];
64
+ while (stack.length > 0) {
65
+ const current = stack.pop();
66
+ if (current !== null && typeof current === "object") {
67
+ stack.push(...(Array.isArray(current)
68
+ ? current
69
+ : Object.values(current)));
70
+ }
71
+ else {
72
+ leaves.push(current);
73
+ }
74
+ }
75
+ return leaves;
76
+ }
77
+ function promptContainsPrimitive(prompt, value) {
78
+ const rendered = String(value).toLowerCase()
79
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ return new RegExp(`(^|[^A-Za-z0-9_.-])${rendered}($|[^A-Za-z0-9_.-])`)
81
+ .test(prompt.toLowerCase());
82
+ }
83
+ function promptContainsString(prompt, value) {
84
+ const normalizedValue = value.trim().toLowerCase();
85
+ if (!normalizedValue)
86
+ return false;
87
+ if (/^[A-Za-z0-9_.:-]+$/.test(normalizedValue)) {
88
+ const escaped = normalizedValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
+ return new RegExp(`(^|[^A-Za-z0-9_.:-])${escaped}($|[^A-Za-z0-9_.:-])`)
90
+ .test(prompt.toLowerCase());
91
+ }
92
+ return prompt.toLowerCase().includes(normalizedValue);
93
+ }
94
+ function sameJsonValue(left, right) {
95
+ if (Object.is(left, right))
96
+ return true;
97
+ if (Array.isArray(left) || Array.isArray(right)) {
98
+ return Array.isArray(left) &&
99
+ Array.isArray(right) &&
100
+ left.length === right.length &&
101
+ left.every((entry, index) => sameJsonValue(entry, right[index]));
102
+ }
103
+ if (left === null ||
104
+ right === null ||
105
+ typeof left !== "object" ||
106
+ typeof right !== "object") {
107
+ return false;
108
+ }
109
+ const leftRecord = left;
110
+ const rightRecord = right;
111
+ const leftKeys = Object.keys(leftRecord).sort();
112
+ const rightKeys = Object.keys(rightRecord).sort();
113
+ return leftKeys.length === rightKeys.length &&
114
+ leftKeys.every((key, index) => key === rightKeys[index] &&
115
+ sameJsonValue(leftRecord[key], rightRecord[key]));
116
+ }
117
+ function hasDerivedArguments(correctedArgs, originalArgs, originalPrompt) {
118
+ const originalLeaves = primitiveLeaves(originalArgs);
119
+ return primitiveLeaves(correctedArgs).every((leaf) => {
120
+ if (originalLeaves.some((candidate) => Object.is(candidate, leaf))) {
121
+ return true;
122
+ }
123
+ if (typeof leaf === "string") {
124
+ return promptContainsString(originalPrompt, leaf);
125
+ }
126
+ if (typeof leaf === "number" || typeof leaf === "boolean") {
127
+ return promptContainsPrimitive(originalPrompt, leaf);
128
+ }
129
+ return false;
130
+ });
131
+ }
132
+ function parseToolJson(raw) {
133
+ try {
134
+ const value = JSON.parse(raw);
135
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
136
+ return { kind: "malformed" };
137
+ }
138
+ const record = value;
139
+ if (!isRouteToolName(record.name)) {
140
+ return { kind: "malformed" };
141
+ }
142
+ if (Object.keys(record).some((key) => !TOOL_CALL_KEYS.has(key))) {
143
+ return { kind: "malformed" };
144
+ }
145
+ const hasArguments = Object.prototype.hasOwnProperty.call(record, "arguments");
146
+ const hasArgs = Object.prototype.hasOwnProperty.call(record, "args");
147
+ if (hasArguments && hasArgs)
148
+ return { kind: "malformed" };
149
+ const rawArgs = hasArguments
150
+ ? record.arguments
151
+ : hasArgs
152
+ ? record.args
153
+ : {};
154
+ if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
155
+ return { kind: "malformed" };
156
+ }
157
+ if (!isBoundedJsonValue(rawArgs))
158
+ return { kind: "malformed" };
159
+ return {
160
+ kind: "tool_call",
161
+ name: record.name,
162
+ args: rawArgs,
163
+ };
164
+ }
165
+ catch {
166
+ return { kind: "malformed" };
167
+ }
168
+ }
169
+ function parseEnvelope(output, startToken, endToken) {
170
+ const start = output.indexOf(startToken);
171
+ const end = output.indexOf(endToken, start + startToken.length);
172
+ if (start < 0 ||
173
+ end < 0 ||
174
+ output.indexOf(startToken, start + startToken.length) >= 0 ||
175
+ output.indexOf(endToken, end + endToken.length) >= 0) {
176
+ return { kind: "malformed" };
177
+ }
178
+ const before = output.slice(0, start).trim();
179
+ const after = output.slice(end + endToken.length).trim();
180
+ if (before || after)
181
+ return { kind: "malformed" };
182
+ return parseToolJson(output.slice(start + startToken.length, end).trim());
183
+ }
184
+ export function parseRouteOutput(output) {
185
+ if (output.length > MAX_ROUTE_OUTPUT_CHARS)
186
+ return { kind: "malformed" };
187
+ const trimmed = output.trim();
188
+ const hasPipeMarker = trimmed.includes(PIPE_START) || trimmed.includes(PIPE_END);
189
+ if (hasPipeMarker)
190
+ return parseEnvelope(trimmed, PIPE_START, PIPE_END);
191
+ const hasAngleMarker = trimmed.includes(ANGLE_START) || trimmed.includes(ANGLE_END);
192
+ if (hasAngleMarker)
193
+ return parseEnvelope(trimmed, ANGLE_START, ANGLE_END);
194
+ // A route answer that starts like raw tool JSON is an attempted contract
195
+ // response. Treat invalid JSON/shape as malformed rather than returning it
196
+ // as ordinary prose that a host might accidentally interpret.
197
+ if (trimmed.startsWith("{"))
198
+ return parseToolJson(trimmed);
199
+ return { kind: "plain_text" };
200
+ }
201
+ export function applyLocalRouteContract(draft, allowedTools = DEFAULT_PRISM_ROUTE_TOOLS) {
202
+ const parsed = parseRouteOutput(draft);
203
+ if (parsed.kind === "plain_text") {
204
+ return { output: draft, action: "plain_text", source: "local" };
205
+ }
206
+ if (parsed.kind === "malformed") {
207
+ return {
208
+ output: "NO_TOOL",
209
+ action: "suppressed",
210
+ source: "local",
211
+ reason: "malformed_tool_call",
212
+ };
213
+ }
214
+ if (parsed.name === "NO_TOOL") {
215
+ return { output: "NO_TOOL", action: "plain_text", source: "local" };
216
+ }
217
+ if (!allowedTools.has(parsed.name)) {
218
+ return {
219
+ output: "NO_TOOL",
220
+ action: "suppressed",
221
+ source: "local",
222
+ original_tool: parsed.name,
223
+ reason: "unadvertised_tool",
224
+ };
225
+ }
226
+ return {
227
+ output: draft,
228
+ action: "preserved",
229
+ source: "local",
230
+ original_tool: parsed.name,
231
+ final_tool: parsed.name,
232
+ };
233
+ }
234
+ /**
235
+ * Validate a private portal correction before trusting it.
236
+ *
237
+ * The portal is an authenticated service, but the public client still treats
238
+ * its response as untrusted network input. The declared action must agree with
239
+ * the output shape and with the original draft.
240
+ */
241
+ export function validatePortalRouteGuardOutcome(value, originalDraft, allowedTools, originalPrompt = "") {
242
+ if (!value || typeof value !== "object" || Array.isArray(value))
243
+ return null;
244
+ const record = value;
245
+ if (typeof record.output !== "string" ||
246
+ record.output.length === 0 ||
247
+ record.output.length > MAX_ROUTE_OUTPUT_CHARS ||
248
+ record.source !== "portal" ||
249
+ !["plain_text", "preserved", "remapped", "suppressed"].includes(typeof record.action === "string" ? record.action : "")) {
250
+ return null;
251
+ }
252
+ if (record.original_tool !== undefined &&
253
+ !isRouteToolName(record.original_tool)) {
254
+ return null;
255
+ }
256
+ if (record.final_tool !== undefined &&
257
+ !isRouteToolName(record.final_tool)) {
258
+ return null;
259
+ }
260
+ if (record.reason !== undefined &&
261
+ (typeof record.reason !== "string" ||
262
+ record.reason.length > MAX_ROUTE_REASON_CHARS)) {
263
+ return null;
264
+ }
265
+ const action = record.action;
266
+ const original = parseRouteOutput(originalDraft);
267
+ const corrected = parseRouteOutput(record.output);
268
+ if (action === "plain_text") {
269
+ if (original.kind !== "plain_text" || corrected.kind !== "plain_text") {
270
+ return null;
271
+ }
272
+ }
273
+ else if (action === "suppressed") {
274
+ if (original.kind !== "tool_call" ||
275
+ record.output.trim() !== "NO_TOOL" ||
276
+ (record.original_tool !== undefined &&
277
+ record.original_tool !== original.name) ||
278
+ record.final_tool !== undefined) {
279
+ return null;
280
+ }
281
+ }
282
+ else {
283
+ if (original.kind !== "tool_call" ||
284
+ corrected.kind !== "tool_call" ||
285
+ !allowedTools.has(corrected.name) ||
286
+ record.original_tool !== original.name ||
287
+ record.final_tool !== corrected.name) {
288
+ return null;
289
+ }
290
+ if (action === "preserved" && corrected.name !== original.name) {
291
+ return null;
292
+ }
293
+ if (action === "remapped" && corrected.name === original.name) {
294
+ return null;
295
+ }
296
+ if (action === "preserved" &&
297
+ !sameJsonValue(corrected.args, original.args)) {
298
+ return null;
299
+ }
300
+ if (action === "remapped" &&
301
+ !hasDerivedArguments(corrected.args, original.args, originalPrompt)) {
302
+ return null;
303
+ }
304
+ }
305
+ return {
306
+ output: record.output,
307
+ action,
308
+ source: "portal",
309
+ ...(record.original_tool !== undefined
310
+ ? { original_tool: record.original_tool }
311
+ : {}),
312
+ ...(record.final_tool !== undefined
313
+ ? { final_tool: record.final_tool }
314
+ : {}),
315
+ ...(record.reason !== undefined
316
+ ? { reason: record.reason }
317
+ : {}),
318
+ };
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.2.7",
3
+ "version": "20.2.9",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — 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–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",
@@ -22,7 +22,7 @@
22
22
  "prebuild": "npm run clean",
23
23
  "build": "tsc && npm run chmod-bins",
24
24
  "chmod-bins": "node -e \"['dist/cli.js','dist/server.js','dist/utils/universalImporter.js'].forEach(f => { try { require('fs').chmodSync(f, 0o755); } catch (e) { console.warn('chmod skipped', f, e.message); } })\"",
25
- "prepublishOnly": "npm run build",
25
+ "prepublishOnly": "node scripts/check-publish-clean.mjs && npm run build",
26
26
  "lint:dashboard": "node scripts/lint-dashboard-es5.cjs",
27
27
  "start": "node dist/server.js",
28
28
  "test": "vitest run",