prism-mcp-server 20.0.8 → 20.2.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.
@@ -5,9 +5,14 @@
5
5
  * a prompt is OBVIOUS_RESERVED, OBVIOUS_NOT_RESERVED, or UNCERTAIN.
6
6
  *
7
7
  * Fail-closed contract:
8
- * OBVIOUS_NOT_RESERVED → the ONLY verdict that permits local routing
8
+ * OBVIOUS_NOT_RESERVED → permits local routing
9
9
  * OBVIOUS_RESERVED → escalate to cloud
10
10
  * UNCERTAIN → escalate to cloud (conservative)
11
+ * UNCERTAIN_LENGTH → §5.3: prompt too long to classify in full, but the
12
+ * full-text keyword floor is clean AND a head+tail
13
+ * excerpt classified clean — permits local routing
14
+ * with a distinct audit marker ("too long to classify"
15
+ * ≠ "semantically uncertain")
11
16
  * ERROR → escalate to cloud (never fail-open)
12
17
  *
13
18
  * The prompt below is VERBATIM from §E of prism-infer-boundaries/SKILL.md.
@@ -66,7 +71,7 @@ const LAYER1_RETRY_TIMEOUT_MS = 5_000;
66
71
  // Not sufficient alone (adversaries can paraphrase), but as an ERROR-path
67
72
  // floor they block the obvious cases that padding/injection attacks
68
73
  // would otherwise smuggle through.
69
- const RESERVED_KEYWORDS = /\b(restrain\w*|seclu(?:sion|d\w*)|physical\s*holds?|containment|self[- ]?harm\w*|suicid\w*|overdos\w*|dos(?:age|ing)\s*(?:mg|schedule)|crisis\s*de[- ]?escalation|meltdown\s*management|elopement\s*incident)\b/i;
74
+ const RESERVED_KEYWORDS = /\b(restrain\w*|seclu(?:sion|d\w*)|physical\s*holds?|(?:prone|supine|basket|therapeutic|manual|two[- ]?person)\s+holds?|hold(?:ing)?\s+(?:the\s+)?(?:client|child|student|patient)\s+down|containment|self[- ]?harm\w*|suicid\w*|overdos\w*|dos(?:age|ing)\s*(?:mg|schedule)|crisis\s*de[- ]?escalation|meltdown\s*management|rage\s+episode|elopement\s*incident)\b/i;
70
75
  /**
71
76
  * Deterministic keyword check — the ERROR-path floor.
72
77
  * Returns OBVIOUS_RESERVED if reserved vocabulary is present,
@@ -77,21 +82,54 @@ export function keywordBackstop(prompt) {
77
82
  return RESERVED_KEYWORDS.test(prompt) ? "OBVIOUS_RESERVED" : "OBVIOUS_NOT_RESERVED";
78
83
  }
79
84
  // Over-length prompts are attacker-controlled — don't let length
80
- // select the ERROR branch. Classify as UNCERTAIN (conservative)
81
- // rather than feeding a huge prompt to a 4B classifier that will timeout.
85
+ // select the ERROR branch. §5.3: instead of a blanket UNCERTAIN (which
86
+ // routed every big benign prompt to cloud-or-refuse v1 FATAL #3), run
87
+ // the deterministic keyword floor over the FULL text, then classify a
88
+ // bounded head+tail excerpt. A clean floor + clean excerpt yields the
89
+ // distinct UNCERTAIN_LENGTH verdict so callers can tell "too long to
90
+ // classify" from "semantically uncertain".
82
91
  const MAX_CLASSIFIER_PROMPT_LENGTH = 4_000;
92
+ // Excerpt budget: head + middle + tail must stay under the classifier cap
93
+ // with room for the LAYER1_PROMPT template. The sampled middle window
94
+ // narrows the region an attacker can hide paraphrased reserved content in
95
+ // (adversarial-review finding: keyword-free paraphrases in the unseen
96
+ // middle are the residual gap — the window makes padding placement
97
+ // harder; the full-text keyword floor remains the deterministic net).
98
+ const EXCERPT_HEAD_CHARS = 2_000;
99
+ const EXCERPT_MID_CHARS = 400;
100
+ const EXCERPT_TAIL_CHARS = 1_400;
101
+ /** Bounded head+middle+tail excerpt of an oversize prompt (§5.3). Exported for tests. */
102
+ export function buildOversizeExcerpt(prompt) {
103
+ const midStart = Math.max(EXCERPT_HEAD_CHARS, Math.floor(prompt.length / 2) - EXCERPT_MID_CHARS / 2);
104
+ const middle = prompt.slice(midStart, midStart + EXCERPT_MID_CHARS);
105
+ return (prompt.slice(0, EXCERPT_HEAD_CHARS) +
106
+ "\n[…]\n" +
107
+ middle +
108
+ "\n[…]\n" +
109
+ prompt.slice(-EXCERPT_TAIL_CHARS));
110
+ }
83
111
  /**
84
112
  * Run the Layer 1 classifier with retry on cold-model timeout.
85
113
  * Returns a verdict; never throws.
86
114
  *
87
115
  * Flow: classify → if ERROR, retry once with longer timeout →
88
116
  * if still ERROR, return ERROR (caller uses keywordBackstop).
117
+ *
118
+ * Oversize flow (§5.3): full-text keyword floor → excerpt classification →
119
+ * reserved/uncertain excerpt verdicts keep full reserved handling;
120
+ * a clean excerpt returns UNCERTAIN_LENGTH.
89
121
  */
90
122
  export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch) {
91
123
  if (!userPrompt || !userPrompt.trim())
92
124
  return "ERROR";
93
- if (userPrompt.length > MAX_CLASSIFIER_PROMPT_LENGTH)
94
- return "UNCERTAIN";
125
+ const oversize = userPrompt.length > MAX_CLASSIFIER_PROMPT_LENGTH;
126
+ if (oversize && keywordBackstop(userPrompt) === "OBVIOUS_RESERVED") {
127
+ // The regex floor has no length limit — reserved vocabulary anywhere
128
+ // in the full text (including the middle the excerpt can't see)
129
+ // short-circuits to reserved handling.
130
+ return "OBVIOUS_RESERVED";
131
+ }
132
+ const classifierInput = oversize ? buildOversizeExcerpt(userPrompt) : userPrompt;
95
133
  const classify = async (timeoutMs) => {
96
134
  let res;
97
135
  try {
@@ -101,7 +139,7 @@ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch
101
139
  body: JSON.stringify({
102
140
  model,
103
141
  messages: [
104
- { role: "user", content: LAYER1_PROMPT.replace("{prompt}", userPrompt) },
142
+ { role: "user", content: LAYER1_PROMPT.replace("{prompt}", classifierInput) },
105
143
  ],
106
144
  stream: false,
107
145
  think: false,
@@ -128,7 +166,16 @@ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch
128
166
  return parseLayer1(text);
129
167
  };
130
168
  const first = await classify(LAYER1_TIMEOUT_MS);
131
- if (first !== "ERROR")
132
- return first;
133
- return classify(LAYER1_RETRY_TIMEOUT_MS);
169
+ const verdict = first !== "ERROR" ? first : await classify(LAYER1_RETRY_TIMEOUT_MS);
170
+ if (!oversize)
171
+ return verdict;
172
+ // §5.3 oversize mapping:
173
+ // excerpt OBVIOUS_RESERVED / UNCERTAIN → keep full reserved handling
174
+ // excerpt OBVIOUS_NOT_RESERVED → UNCERTAIN_LENGTH (distinct verdict)
175
+ // excerpt ERROR → UNCERTAIN_LENGTH — the deterministic
176
+ // keyword floor already cleared the FULL text above, which is the same
177
+ // floor the normal-size ERROR path falls back to via the caller.
178
+ if (verdict === "OBVIOUS_RESERVED" || verdict === "UNCERTAIN")
179
+ return verdict;
180
+ return "UNCERTAIN_LENGTH";
134
181
  }
@@ -24,12 +24,21 @@ const GB = 1024 ** 3;
24
24
  /**
25
25
  * Tier table, ordered LARGEST → SMALLEST. Picker walks this and returns
26
26
  * the first row whose minFreeGb fits within freeBytes.
27
+ *
28
+ * ctxTokens (§5.4): aligned with the LIVE Modelfile `num_ctx` values
29
+ * (verified via `ollama show <tag> --modelfile` on 2026-07-20). The values
30
+ * are inverted from intuition — the 27b/9b Modelfiles bake num_ctx 4096
31
+ * while 4b/2b bake 32768. The tier walk skips tiers whose ctx cannot hold
32
+ * prompt + output (reason: ctx_insufficient) — Ollama otherwise silently
33
+ * truncates the prompt and answers from the fragment. If a Modelfile's
34
+ * num_ctx is ever raised, update the row here (a raise is a measured
35
+ * decision with KV-cache RAM costed — plan v2 §5.4).
27
36
  */
28
37
  export const MODEL_TIERS = [
29
- { tag: 'prism-coder:27b', weightsGb: 16, minFreeGb: 20, ctxTokens: 32_768 },
30
- { tag: 'prism-coder:9b', weightsGb: 5.8, minFreeGb: 8, ctxTokens: 32_768 },
38
+ { tag: 'prism-coder:27b', weightsGb: 16, minFreeGb: 20, ctxTokens: 4_096 },
39
+ { tag: 'prism-coder:9b', weightsGb: 5.8, minFreeGb: 8, ctxTokens: 4_096 },
31
40
  { tag: 'prism-coder:4b', weightsGb: 3.4, minFreeGb: 5, ctxTokens: 32_768 },
32
- { tag: 'prism-coder:2b', weightsGb: 2.3, minFreeGb: 3, ctxTokens: 8_192 },
41
+ { tag: 'prism-coder:2b', weightsGb: 2.3, minFreeGb: 3, ctxTokens: 32_768 },
33
42
  ];
34
43
  /**
35
44
  * True when `installed` matches `tierTag` either as a bare tag
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.0.8",
3
+ "version": "20.2.0",
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-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",
@@ -65,6 +65,7 @@
65
65
  "rag",
66
66
  "embeddings",
67
67
  "cursor",
68
+ "codex",
68
69
  "windsurf",
69
70
  "cline",
70
71
  "persistent-memory",
@@ -144,6 +145,7 @@
144
145
  "p-limit": "^7.3.0",
145
146
  "postcss": "8.5.12",
146
147
  "quickjs-emscripten": "^0.32.0",
148
+ "smol-toml": "^1.7.0",
147
149
  "stream-json": "^2.0.0",
148
150
  "turndown": "^7.2.2",
149
151
  "zod": "^4.3.6"
@@ -1,203 +0,0 @@
1
- /**
2
- * groundingVerifier — runtime accountability for prism_infer
3
- * ============================================================
4
- *
5
- * When a caller passes `evidence` + `verify: true` to prism_infer, this
6
- * module checks that every factual claim in the model's draft is
7
- * entailed by one of the evidence snippets. Sibling of synalux-portal's
8
- * chat-verifier — same architecture, lighter footprint (no DB audit,
9
- * stateless MCP), pointed at free-form generation instead of tool-call
10
- * responses.
11
- *
12
- * Cascade role: qwen3.5:4b is the default verifier (fast, 2.5GB).
13
- * 14b drafts; 4b verifies. Different model = Patronus rule satisfied.
14
- * Falls back to 2b on devices with <4GB free RAM.
15
- *
16
- * Failure modes:
17
- * - Verifier model unreachable / timeout → fail-closed refusal
18
- * - Verifier returns malformed JSON → fail-closed refusal
19
- * - NEUTRAL or CONTRADICTED claim → fail-closed refusal that names
20
- * the failed claim
21
- *
22
- * The refusal text always names which claim couldn't be grounded so
23
- * the calling agent can decide whether to retry with more evidence or
24
- * fall back to cloud.
25
- */
26
- import { PRISM_LOCAL_LLM_URL } from "../config.js";
27
- // ─── Pre-checks ─────────────────────────────────────────────────────────
28
- const ASSERTIVE_RX = /\b(?:\d{1,5}|[A-Z]\d{2}\.\d|ICD-?10|CPT|\$\d|\d{4}-\d{2}-\d{2}|[A-Z][a-z]{2,}\s+[A-Z][a-z]{2,})\b/;
29
- /**
30
- * Returns true when the draft makes at least one assertion that could be
31
- * fabricated — numbers, dates, ICD/CPT codes, two-word names, dollar
32
- * amounts. Conversational replies skip the verifier entirely.
33
- */
34
- export function draftHasAssertiveClaims(draft) {
35
- if (!draft)
36
- return false;
37
- return ASSERTIVE_RX.test(draft);
38
- }
39
- // ─── Verifier prompt (grammar-constrained JSON) ─────────────────────────
40
- const VERIFIER_SYSTEM_PROMPT = `You are a strict factual-grounding verifier. Your job is to REJECT ungrounded claims.
41
- Given EVIDENCE (one or more text snippets) and DRAFT_ANSWER, find every
42
- factual claim (counts, names, dates, codes, dollar amounts) and assign:
43
-
44
- ENTAILED — the EXACT value appears verbatim in EVIDENCE text, or is an
45
- arithmetic identity (e.g. "3" and "three"). STRICT: if you
46
- must infer, estimate, or extrapolate, it is NOT ENTAILED.
47
- CONTRADICTED — the claim states a DIFFERENT value than what EVIDENCE says
48
- for the same fact.
49
- NEUTRAL — the claim is not addressed in EVIDENCE at all.
50
-
51
- CRITICAL DEFAULT RULE: when in doubt, use NEUTRAL — never guess ENTAILED.
52
- Prefer false negatives over false positives. If the evidence does not
53
- explicitly state the value, it is NEUTRAL.
54
-
55
- Do NOT report opinions, refusals, or hedges as claims. Conversational
56
- phrasing ("Hello", "I can help") is not a claim.
57
-
58
- Output JSON only — no prose, no apology.`;
59
- const VERIFIER_JSON_SCHEMA = {
60
- type: "object",
61
- properties: {
62
- claims: {
63
- type: "array",
64
- items: {
65
- type: "object",
66
- properties: {
67
- text: { type: "string" },
68
- verdict: { type: "string", enum: ["ENTAILED", "NEUTRAL", "CONTRADICTED"] },
69
- evidence_span: { type: ["string", "null"] },
70
- },
71
- required: ["text", "verdict", "evidence_span"],
72
- additionalProperties: false,
73
- },
74
- },
75
- },
76
- required: ["claims"],
77
- additionalProperties: false,
78
- };
79
- // ─── Refusal text ───────────────────────────────────────────────────────
80
- function refusalText(action, failedClaim) {
81
- switch (action) {
82
- case "refused_fabricated":
83
- return `I can't ground "${failedClaim}" in the evidence provided. ` +
84
- "If this claim is correct, supply the supporting source as evidence and retry.";
85
- case "refused_no_evidence":
86
- return `I can't ground "${failedClaim}" — no evidence was provided this turn. ` +
87
- "Provide evidence snippets via the `evidence` argument and retry.";
88
- case "refused_timeout":
89
- return `I couldn't verify "${failedClaim}" within the allowed time. ` +
90
- "The verifier model may be cold-loading; try again in a moment.";
91
- case "served":
92
- return ""; // unreachable
93
- }
94
- }
95
- export async function verifyGrounding(opts) {
96
- const verifierModel = opts.verifierModel ?? "qwen3.5:4b";
97
- const timeoutMs = opts.timeoutMs ?? 2000;
98
- const ollamaUrl = opts.ollamaUrl ?? PRISM_LOCAL_LLM_URL;
99
- const fetchImpl = opts.fetchImpl ?? fetch;
100
- const verifierChain = [];
101
- // Tier 0 — conversational drafts skip the verifier entirely.
102
- if (!draftHasAssertiveClaims(opts.draft)) {
103
- return {
104
- action: "served",
105
- finalText: opts.draft,
106
- claims: [],
107
- verifierChain,
108
- };
109
- }
110
- // Tier 0a — assertive draft with NO evidence is fail-closed:
111
- // the model is making claims it cannot back up.
112
- if (opts.evidence.length === 0) {
113
- const claim = firstAssertiveSpan(opts.draft);
114
- return {
115
- action: "refused_no_evidence",
116
- finalText: refusalText("refused_no_evidence", claim),
117
- claims: [{ claim, verdict: "NEUTRAL", evidence_span: null }],
118
- verifierChain,
119
- refusalClaim: claim,
120
- };
121
- }
122
- // Tier 2 — NLI verifier call.
123
- const t0 = Date.now();
124
- const evidenceText = opts.evidence
125
- .map((e, i) => `[${i}] ${e.source}\n${e.content}`)
126
- .join("\n\n");
127
- const payload = {
128
- model: verifierModel,
129
- messages: [
130
- { role: "system", content: VERIFIER_SYSTEM_PROMPT },
131
- { role: "user", content: `EVIDENCE:\n${evidenceText}\n\nDRAFT_ANSWER:\n${opts.draft}` },
132
- ],
133
- stream: false,
134
- response_format: {
135
- type: "json_schema",
136
- json_schema: { name: "verifier", schema: VERIFIER_JSON_SCHEMA, strict: true },
137
- },
138
- temperature: 0,
139
- };
140
- let parsedClaims = null;
141
- try {
142
- const res = await fetchImpl(`${ollamaUrl}/v1/chat/completions`, {
143
- method: "POST",
144
- headers: { "Content-Type": "application/json" },
145
- body: JSON.stringify(payload),
146
- signal: AbortSignal.timeout(timeoutMs),
147
- });
148
- if (!res.ok)
149
- throw new Error(`HTTP ${res.status}`);
150
- const data = (await res.json());
151
- const content = data?.choices?.[0]?.message?.content;
152
- if (typeof content !== "string")
153
- throw new Error("no content");
154
- const parsed = JSON.parse(content);
155
- if (!parsed || !Array.isArray(parsed.claims))
156
- throw new Error("malformed");
157
- parsedClaims = parsed.claims.map((c) => ({
158
- claim: String(c.text ?? ""),
159
- verdict: ["ENTAILED", "NEUTRAL", "CONTRADICTED"].includes(c.verdict)
160
- ? c.verdict
161
- : "NEUTRAL",
162
- evidence_span: typeof c.evidence_span === "string" ? c.evidence_span : null,
163
- }));
164
- }
165
- catch {
166
- const latencyMs = Date.now() - t0;
167
- verifierChain.push({ model: verifierModel, verdict: "NEUTRAL", latencyMs });
168
- const claim = firstAssertiveSpan(opts.draft);
169
- return {
170
- action: "refused_timeout",
171
- finalText: refusalText("refused_timeout", claim),
172
- claims: [{ claim, verdict: "NEUTRAL", evidence_span: null }],
173
- verifierChain,
174
- refusalClaim: claim,
175
- };
176
- }
177
- const latencyMs = Date.now() - t0;
178
- const failing = parsedClaims.find(c => c.verdict !== "ENTAILED");
179
- const rollup = failing ? failing.verdict : "ENTAILED";
180
- verifierChain.push({ model: verifierModel, verdict: rollup, latencyMs });
181
- if (failing) {
182
- return {
183
- action: "refused_fabricated",
184
- finalText: refusalText("refused_fabricated", failing.claim),
185
- claims: parsedClaims,
186
- verifierChain,
187
- refusalClaim: failing.claim,
188
- };
189
- }
190
- return {
191
- action: "served",
192
- finalText: opts.draft,
193
- claims: parsedClaims,
194
- verifierChain,
195
- };
196
- }
197
- // ─── helpers ────────────────────────────────────────────────────────────
198
- function firstAssertiveSpan(draft) {
199
- const m = draft.match(ASSERTIVE_RX);
200
- if (m)
201
- return m[0];
202
- return draft.slice(0, 80);
203
- }