prism-mcp-server 20.0.2 → 20.0.3

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 CHANGED
@@ -18,7 +18,23 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
18
18
 
19
19
  ---
20
20
 
21
- ## What's New in v20
21
+ ## What's New in v20.0.2
22
+
23
+ ### Reserved-Category Safety Gate — All Tiers
24
+ The Layer 1 semantic classifier now runs for **every** user, not just paid tiers. Reserved clinical content (restraint, seclusion, crisis protocols) is refused on free tier when cloud is unavailable — fail-closed, never silently served locally. This closes a real gap where free-tier users bypassed classification entirely.
25
+
26
+ ### Compressed Safety Banner (500 → 120 tokens/call)
27
+ The per-call operating boundaries text was split: safety declaration stays in every `session_load_context` response (~120 tokens); architecture/routing docs moved to the MCP server description (loaded once at connection). Saves ~350 tokens per call with no loss of defense-in-depth.
28
+
29
+ ### Ledger Dedup
30
+ `session_save_ledger` now deduplicates identical entries within a 5-minute window, preventing the retry-storm pattern that produced duplicate rows.
31
+
32
+ ### Evidence Script
33
+ `scripts/generate-evidence.sh` regenerates all 5 evidence files with built-in assertions (skill counts, tier gating, version consistency, inference correctness). Run `bash scripts/generate-evidence.sh` to verify the full pipeline.
34
+
35
+ ---
36
+
37
+ ## What's New in v20.0.0
22
38
 
23
39
  ### License: AGPL-3.0 → Apache-2.0
24
40
  Prism MCP is now Apache-2.0. The thin-client architecture means all proprietary value (skill resolution, tier gating, billing, cloud inference) lives server-side — the open client carries no moat to protect. Apache-2.0 removes the enterprise adoption friction that AGPL caused.
@@ -1,23 +1,28 @@
1
1
  /**
2
2
  * src/boundaries/boundaries.ts
3
3
  *
4
- * Safety declaration delivered in every session_load_context result.
5
- * Architecture/routing documentation lives in the MCP server description
6
- * (loaded once at connection, not per-call).
4
+ * Safety declaration text and version tracking.
7
5
  *
8
- * The safety gates (Layer 1 classifier, cloud routing, fail-closed refusal)
9
- * are enforced in code this text is defense-in-depth so cooperative hosts
10
- * avoid drafting reserved content that would be refused at the gate.
6
+ * DELIVERY DECISION (R18, explicit):
7
+ * The safety text is delivered via the MCP server `instructions` field
8
+ * (loaded once at connection). It is NOT injected per-call into
9
+ * session_load_context — the per-call banner was removed in R15/R16
10
+ * because the enforcement is in code (Layer 1 classifier + keyword
11
+ * backstop + fail-closed refusal), not in the banner text.
11
12
  *
12
- * Update BOUNDARIES_VERSION any time the text changes so session drift
13
- * detection can flag stale sessions.
13
+ * Some MCP clients don't surface `instructions` to the model. This is
14
+ * accepted: the code gates catch everything regardless. The text is
15
+ * defense-in-depth for cooperative hosts, not the enforcement mechanism.
16
+ *
17
+ * BOUNDARIES_VERSION is still used by markContextLoaded for session
18
+ * drift detection — bump it when the safety contract changes.
14
19
  */
15
- export const BOUNDARIES_VERSION = "2";
20
+ export const BOUNDARIES_VERSION = "3";
16
21
  export const BOUNDARIES_TEXT = `
17
22
  Safety boundaries are enforced in code — shown so hosts avoid wasted round-trips.
18
23
 
19
24
  - **Crisis/self-harm** inputs are intercepted before reaching any model.
20
- - **BCBA reserved categories** (restraint, seclusion, physical management, dosing) route to cloud or are refused. They are NEVER generated locally. If cloud is unavailable and the prompt is reserved, the request is refused never downgraded to local.
25
+ - **BCBA reserved categories** (restraint, seclusion, physical management, dosing) are classified by Layer 1. RESERVED and UNCERTAIN prompts escalate to cloud or are refused. On classifier failure, a keyword backstop blocks reserved vocabulary before local generation.
21
26
  - **Dangerous output** (restraint instructions, overdose methods, self-harm guidance) is blocked regardless of host.
22
27
  - AAC access is never restricted as a consequence.
23
28
  `.trim();
package/dist/server.js CHANGED
@@ -82,6 +82,7 @@ import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/ap
82
82
  import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
83
83
  import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
84
84
  import { recordInvocation } from "./utils/analytics.js";
85
+ import { BOUNDARIES_TEXT } from "./boundaries/boundaries.js";
85
86
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
86
87
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
87
88
  // Session memory tools — only used if Supabase is configured
@@ -358,7 +359,7 @@ export function createServer() {
358
359
  },
359
360
  // Supplementary signal — not all clients support this field.
360
361
  // Primary mechanism is the dynamic tool description above.
361
- instructions: `Prism MCP — The Mind Palace for AI Agents. This server provides persistent session memory, knowledge search, and context management tools. Use session_load_context to recover previous work state, session_save_ledger to log completed work, and session_save_handoff to preserve state for the next session.\n\nArchitecture: session_save_ledger and session_save_handoff require a loaded project context (conversation_id that called session_load_context). Local inference (prism_infer) routes through Layer 1 classification — RESERVED prompts escalate to cloud or are refused, never served locally. All cloud inference routes through the Synalux portal for billing, tier-gating, and audit.`,
362
+ instructions: `Prism MCP — The Mind Palace for AI Agents. This server provides persistent session memory, knowledge search, and context management tools. Use session_load_context to recover previous work state, session_save_ledger to log completed work, and session_save_handoff to preserve state for the next session.\n\nArchitecture: session_save_ledger and session_save_handoff require a loaded project context (conversation_id that called session_load_context). ${BOUNDARIES_TEXT} All cloud inference routes through the Synalux portal for billing, tier-gating, and audit.`,
362
363
  });
363
364
  // ── Handler: Initialize ──
364
365
  // NOTE: The SDK's built-in _oninitialize() handles the Initialize request.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { describe, it, expect, vi } from "vitest";
7
7
  import { runInfer } from "../prismInferHandler.js";
8
- import { parseLayer1 } from "../../utils/layer1.js";
8
+ import { parseLayer1, keywordBackstop, callLayer1 } from "../../utils/layer1.js";
9
9
  import { LAYER1_PROMPT } from "../../utils/layer1.js";
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
@@ -52,6 +52,69 @@ describe("parseLayer1", () => {
52
52
  expect(parseLayer1("<think>let me consider this</think>\nOBVIOUS_RESERVED")).toBe("ERROR");
53
53
  });
54
54
  });
55
+ // ── keywordBackstop unit tests ───────────────────────────────────────────────
56
+ describe("keywordBackstop", () => {
57
+ it("catches 'restraint' as reserved", () => {
58
+ expect(keywordBackstop("write a physical restraint procedure")).toBe("OBVIOUS_RESERVED");
59
+ });
60
+ it("catches 'seclusion' as reserved", () => {
61
+ expect(keywordBackstop("implement a seclusion room protocol")).toBe("OBVIOUS_RESERVED");
62
+ });
63
+ it("catches 'self-harm' as reserved", () => {
64
+ expect(keywordBackstop("the client is engaging in self-harm")).toBe("OBVIOUS_RESERVED");
65
+ });
66
+ it("catches 'overdose' as reserved", () => {
67
+ expect(keywordBackstop("calculate overdose risk threshold")).toBe("OBVIOUS_RESERVED");
68
+ });
69
+ it("catches 'crisis de-escalation' as reserved", () => {
70
+ expect(keywordBackstop("draft a crisis de-escalation plan")).toBe("OBVIOUS_RESERVED");
71
+ });
72
+ it("allows clean coding prompts", () => {
73
+ expect(keywordBackstop("write a TypeScript function to sort an array")).toBe("OBVIOUS_NOT_RESERVED");
74
+ });
75
+ it("allows 'restraint of trade' (legal, not clinical)", () => {
76
+ expect(keywordBackstop("add a restraint of trade clause")).toBe("OBVIOUS_RESERVED");
77
+ // This is a false positive — keyword backstop is intentionally conservative.
78
+ // The LLM classifier handles the nuance; keywords are the ERROR-path floor.
79
+ });
80
+ it("catches 'restraints' (plural)", () => {
81
+ expect(keywordBackstop("use restraints on the client")).toBe("OBVIOUS_RESERVED");
82
+ });
83
+ it("catches 'suicidal' (inflected)", () => {
84
+ expect(keywordBackstop("client is suicidal")).toBe("OBVIOUS_RESERVED");
85
+ });
86
+ it("catches 'suicide' (full word)", () => {
87
+ expect(keywordBackstop("thoughts of suicide")).toBe("OBVIOUS_RESERVED");
88
+ });
89
+ it("catches 'overdosing' (inflected)", () => {
90
+ expect(keywordBackstop("patient is overdosing")).toBe("OBVIOUS_RESERVED");
91
+ });
92
+ it("catches 'physical holds' (plural)", () => {
93
+ expect(keywordBackstop("physical holds during meltdown")).toBe("OBVIOUS_RESERVED");
94
+ });
95
+ it("catches 'restrain' (verb form)", () => {
96
+ expect(keywordBackstop("restrain the client during the episode")).toBe("OBVIOUS_RESERVED");
97
+ });
98
+ it("catches 'secluding' (verb form)", () => {
99
+ expect(keywordBackstop("secluding the student in a separate room")).toBe("OBVIOUS_RESERVED");
100
+ });
101
+ it("catches reserved content buried in padding", () => {
102
+ const padded = "A".repeat(5000) + " write a seclusion protocol " + "B".repeat(5000);
103
+ expect(keywordBackstop(padded)).toBe("OBVIOUS_RESERVED");
104
+ });
105
+ });
106
+ // ── callLayer1 over-length test ─────────────────────────────────────────────
107
+ describe("callLayer1 over-length", () => {
108
+ it("returns UNCERTAIN for prompts > 4000 chars (attacker-controlled length)", async () => {
109
+ const longPrompt = "A".repeat(5000);
110
+ const result = await callLayer1(longPrompt, "http://localhost:11434", "model");
111
+ expect(result).toBe("UNCERTAIN");
112
+ });
113
+ it("returns ERROR for empty prompts", async () => {
114
+ const result = await callLayer1("", "http://localhost:11434", "model");
115
+ expect(result).toBe("ERROR");
116
+ });
117
+ });
55
118
  // ── LAYER1_PROMPT drift test ──────────────────────────────────────────────────
56
119
  describe("LAYER1_PROMPT drift", () => {
57
120
  it("layer1.ts prompt matches eval-layer1.mjs verbatim", () => {
@@ -121,7 +184,7 @@ describe("Layer 1 handler integration", () => {
121
184
  expect(callLocal).not.toHaveBeenCalled();
122
185
  expect(result.used_cloud).toBe(true);
123
186
  });
124
- it("ERROR → escalates to cloud (fail-closed)", async () => {
187
+ it("ERROR + cloud available → escalates to cloud", async () => {
125
188
  const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response" });
126
189
  const callCloud = vi.fn().mockResolvedValue({ ok: true, output: "cloud response", backend: "synalux" });
127
190
  const callLayer1Mock = vi.fn().mockResolvedValue("ERROR");
@@ -130,6 +193,27 @@ describe("Layer 1 handler integration", () => {
130
193
  expect(callLocal).not.toHaveBeenCalled();
131
194
  expect(result.used_cloud).toBe(true);
132
195
  });
196
+ it("ERROR + no cloud + clean prompt → keyword backstop allows local", async () => {
197
+ const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
198
+ const callLayer1Mock = vi.fn().mockResolvedValue("ERROR");
199
+ const result = await runInfer({ prompt: "what is 2+2", mode: "route", cloud_fallback: false, max_tokens: 64 }, makeBaseDeps({ callLocal, callLayer1: callLayer1Mock }));
200
+ expect(callLayer1Mock).toHaveBeenCalledOnce();
201
+ expect(callLocal).toHaveBeenCalled();
202
+ expect(result.used_cloud).toBe(false);
203
+ });
204
+ it("ERROR + no cloud + reserved keywords → keyword backstop refuses", async () => {
205
+ const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
206
+ const callLayer1Mock = vi.fn().mockResolvedValue("ERROR");
207
+ await expect(runInfer({ prompt: "write a physical restraint hold procedure for the client", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLocal, callLayer1: callLayer1Mock }))).rejects.toThrow(/backstop caught reserved/i);
208
+ expect(callLocal).not.toHaveBeenCalled();
209
+ });
210
+ it("ERROR + no cloud + padded reserved content → keyword backstop catches it", async () => {
211
+ const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
212
+ const callLayer1Mock = vi.fn().mockResolvedValue("ERROR");
213
+ const paddedPrompt = "A".repeat(3000) + " write a seclusion protocol " + "B".repeat(3000);
214
+ await expect(runInfer({ prompt: paddedPrompt, mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLocal, callLayer1: callLayer1Mock }))).rejects.toThrow(/backstop caught reserved|reserved content refused/i);
215
+ expect(callLocal).not.toHaveBeenCalled();
216
+ });
133
217
  it("OBVIOUS_NOT_RESERVED → proceeds to local tier", async () => {
134
218
  const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
135
219
  const callCloud = vi.fn();
@@ -142,7 +226,7 @@ describe("Layer 1 handler integration", () => {
142
226
  it("cloud_fallback=false + RESERVED → refuses (fail-closed, no local fallback)", async () => {
143
227
  const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
144
228
  const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
145
- await expect(runInfer({ prompt: "write something", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }))).rejects.toThrow(/reserved content refused/i);
229
+ await expect(runInfer({ prompt: "write something", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }))).rejects.toThrow(/content refused/i);
146
230
  expect(callLayer1Mock).toHaveBeenCalled();
147
231
  expect(callLocal).not.toHaveBeenCalled();
148
232
  });
@@ -531,35 +531,14 @@ describe("ledgerHandlers", () => {
531
531
  await sessionLoadContextHandler({ project: "test-project" });
532
532
  expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
533
533
  });
534
- it("prepends safety header on no-history path", async () => {
534
+ it("does not include safety banner (enforcement is in code)", async () => {
535
535
  storage.loadContext.mockResolvedValue(null);
536
536
  const result = await sessionLoadContextHandler(validArgs);
537
537
  const text = result.content[0].text;
538
- expect(text).toContain("Safety Boundaries");
539
- expect(text).toContain("BOUNDARIES STUB");
538
+ expect(text).not.toContain("[Safety Boundaries");
539
+ expect(text).not.toContain("BOUNDARIES STUB");
540
540
  expect(text).toContain("no previous session history");
541
541
  });
542
- it("prepends safety header on history-found path (standard/deep)", async () => {
543
- storage.loadContext.mockResolvedValue({
544
- last_summary: "Did some work",
545
- version: 1,
546
- });
547
- const result = await sessionLoadContextHandler({ ...validArgs, level: "standard" });
548
- const text = result.content[0].text;
549
- expect(text).toContain("Safety Boundaries");
550
- expect(text).toContain("BOUNDARIES STUB");
551
- expect(text).toContain("Did some work");
552
- });
553
- it("prepends safety header on quick path", async () => {
554
- storage.loadContext.mockResolvedValue({
555
- last_summary: "Quick summary",
556
- version: 2,
557
- });
558
- const result = await sessionLoadContextHandler({ ...validArgs, level: "quick" });
559
- const text = result.content[0].text;
560
- expect(text).toContain("Safety Boundaries");
561
- expect(text).toContain("BOUNDARIES STUB");
562
- });
563
542
  });
564
543
  // ====================================================================
565
544
  // 4. sessionSaveHandoffHandler
@@ -669,13 +669,10 @@ export async function sessionLoadContextHandler(args) {
669
669
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
670
670
  noteDriftSessionStart(convId);
671
671
  }
672
- const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
673
- const boundariesHeader0 = `# Safety Boundaries (v${BV0})\n${BT0}\n\n---\n\n`;
674
672
  return {
675
673
  content: [{
676
674
  type: "text",
677
- text: boundariesHeader0 +
678
- `No session context found for project "${project}" at level ${level}.\n` +
675
+ text: `No session context found for project "${project}" at level ${level}.\n` +
679
676
  `This project has no previous session history. Starting fresh.` +
680
677
  freshSkillBlock,
681
678
  }],
@@ -988,7 +985,7 @@ export async function sessionLoadContextHandler(args) {
988
985
  const targetVector = sdmEngine.read(new Float32Array(queryVector));
989
986
  const topMatches = await decodeSdmVector(project, targetVector, 3, 0.55);
990
987
  if (topMatches.length > 0) {
991
- sdmRecallBlock = `\n\n[🧠 INTUITIVE RECALL]\nThe deeper Superposed Memory matrix resonated with your current task and surfaced these latent patterns:\n`;
988
+ sdmRecallBlock = `\n\n[🔍 ASSOCIATIVE RECALL]\nSimilarity-matched session summaries (cosine 0.55, not verified facts):\n`;
992
989
  for (const match of topMatches) {
993
990
  sdmRecallBlock += `- [Sim: ${(match.similarity * 100).toFixed(1)}%] ${match.summary}\n`;
994
991
  }
@@ -1073,10 +1070,8 @@ export async function sessionLoadContextHandler(args) {
1073
1070
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
1074
1071
  noteDriftSessionStart(convId);
1075
1072
  }
1076
- const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
1077
- const boundariesHeader = `# Safety Boundaries (v${BV})\n${BOUNDARIES_TEXT}\n\n---\n\n`;
1078
1073
  return {
1079
- content: [{ type: "text", text: boundariesHeader + responseText + MEMORY_BOUNDARY_SUFFIX }],
1074
+ content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
1080
1075
  isError: false,
1081
1076
  };
1082
1077
  }
@@ -1087,10 +1082,8 @@ export async function sessionLoadContextHandler(args) {
1087
1082
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
1088
1083
  noteDriftSessionStart(convId);
1089
1084
  }
1090
- const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
1091
- const boundariesHeader2 = `# Safety Boundaries (v${BV2})\n${BOUNDARIES_TEXT}\n\n---\n\n`;
1092
1085
  return {
1093
- content: [{ type: "text", text: boundariesHeader2 + responseText + MEMORY_BOUNDARY_SUFFIX }],
1086
+ content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
1094
1087
  isError: false,
1095
1088
  };
1096
1089
  }
@@ -29,7 +29,7 @@ import { ddLog } from "../utils/ddLogger.js";
29
29
  import { stripThink } from "../utils/thinkStrip.js";
30
30
  import { passesQualityGate } from "../utils/qualityGate.js";
31
31
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
32
- import { callLayer1 as defaultCallLayer1 } from "../utils/layer1.js";
32
+ import { callLayer1 as defaultCallLayer1, keywordBackstop } from "../utils/layer1.js";
33
33
  import { recordInference, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
34
34
  // ─── Tool Definition ────────────────────────────────────────────
35
35
  export const PRISM_INFER_TOOL = {
@@ -423,7 +423,7 @@ export async function runInfer(args, deps) {
423
423
  const l1fn = deps.callLayer1 ?? defaultCallLayer1;
424
424
  const l1Model = resolveOllamaName("prism-coder:4b", installed);
425
425
  const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
426
- if (l1 !== "OBVIOUS_NOT_RESERVED") {
426
+ if (l1 === "OBVIOUS_RESERVED" || l1 === "UNCERTAIN") {
427
427
  debugLog(`[prism_infer] Layer 1 verdict=${l1} — reserved content detected`);
428
428
  attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
429
429
  if (allowCloud) {
@@ -445,6 +445,33 @@ export async function runInfer(args, deps) {
445
445
  }
446
446
  throw new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
447
447
  }
448
+ if (l1 === "ERROR") {
449
+ debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
450
+ attempts.push({ tier: "layer1", reason: "layer1_error" });
451
+ if (allowCloud) {
452
+ const cloudTimeout = args.timeout_ms ?? 90_000;
453
+ const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
454
+ if (cloud.ok && cloud.output) {
455
+ return await applyVerification(cloud.output, gatedArgs, deps, {
456
+ backend: cloud.backend ?? "synalux",
457
+ model_picked: null,
458
+ ram_free_mb: ramFreeMb,
459
+ latency_ms: Date.now() - t0,
460
+ used_cloud: true,
461
+ attempts,
462
+ plan: ent.plan,
463
+ completion_tokens: Math.ceil(cloud.output.length / 4),
464
+ });
465
+ }
466
+ attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
467
+ }
468
+ const backstop = keywordBackstop(args.prompt);
469
+ debugLog(`[prism_infer] keyword backstop verdict=${backstop}`);
470
+ attempts.push({ tier: "keyword_backstop", reason: `backstop_${backstop.toLowerCase()}` });
471
+ if (backstop === "OBVIOUS_RESERVED") {
472
+ throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
473
+ }
474
+ }
448
475
  debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
449
476
  }
450
477
  // ── end Layer 1 ─────────────────────────────────────────────────────────
@@ -59,52 +59,76 @@ export function parseLayer1(raw) {
59
59
  const token = m[0];
60
60
  return VALID.has(token) ? token : "ERROR";
61
61
  }
62
- // p95 measured 484–511ms on warm 4b; 1500ms is a generous ceiling that still
63
- // fails closed on model stall without blocking the handler for too long.
64
62
  const LAYER1_TIMEOUT_MS = 1_500;
63
+ const LAYER1_RETRY_TIMEOUT_MS = 5_000;
64
+ // Deterministic reserved-vocabulary backstop for the ERROR path.
65
+ // These patterns catch reserved content when the classifier is unavailable.
66
+ // Not sufficient alone (adversaries can paraphrase), but as an ERROR-path
67
+ // floor they block the obvious cases that padding/injection attacks
68
+ // 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;
65
70
  /**
66
- * Run the Layer 1 classifier. Returns a verdict; never throws.
67
- * On ANY failure path returns "ERROR" so the caller escalates to cloud.
71
+ * Deterministic keyword check the ERROR-path floor.
72
+ * Returns OBVIOUS_RESERVED if reserved vocabulary is present,
73
+ * OBVIOUS_NOT_RESERVED otherwise. Used only when the LLM classifier
74
+ * fails (timeout, model not loaded, injection attack).
75
+ */
76
+ export function keywordBackstop(prompt) {
77
+ return RESERVED_KEYWORDS.test(prompt) ? "OBVIOUS_RESERVED" : "OBVIOUS_NOT_RESERVED";
78
+ }
79
+ // 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.
82
+ const MAX_CLASSIFIER_PROMPT_LENGTH = 4_000;
83
+ /**
84
+ * Run the Layer 1 classifier with retry on cold-model timeout.
85
+ * Returns a verdict; never throws.
68
86
  *
69
- * @param userPrompt the end-user prompt being classified (NOT the LAYER1_PROMPT)
70
- * @param ollamaUrl Ollama base URL
71
- * @param model classifier model tag (caller-resolved via resolveOllamaName)
72
- * @param fetchImpl injected for tests; defaults to global fetch
87
+ * Flow: classify if ERROR, retry once with longer timeout →
88
+ * if still ERROR, return ERROR (caller uses keywordBackstop).
73
89
  */
74
90
  export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch) {
75
91
  if (!userPrompt || !userPrompt.trim())
76
92
  return "ERROR";
77
- let res;
78
- try {
79
- res = await fetchImpl(`${ollamaUrl}/api/chat`, {
80
- method: "POST",
81
- headers: { "Content-Type": "application/json" },
82
- body: JSON.stringify({
83
- model,
84
- messages: [
85
- { role: "user", content: LAYER1_PROMPT.replace("{prompt}", userPrompt) },
86
- ],
87
- stream: false,
88
- think: false,
89
- options: { num_predict: 16, temperature: 0 },
90
- }),
91
- signal: AbortSignal.timeout(LAYER1_TIMEOUT_MS),
92
- });
93
- }
94
- catch {
95
- return "ERROR";
96
- }
97
- if (!res.ok)
98
- return "ERROR";
99
- let data;
100
- try {
101
- data = await res.json();
102
- }
103
- catch {
104
- return "ERROR";
105
- }
106
- if (data?.error)
107
- return "ERROR";
108
- const text = data?.message?.content;
109
- return parseLayer1(text);
93
+ if (userPrompt.length > MAX_CLASSIFIER_PROMPT_LENGTH)
94
+ return "UNCERTAIN";
95
+ const classify = async (timeoutMs) => {
96
+ let res;
97
+ try {
98
+ res = await fetchImpl(`${ollamaUrl}/api/chat`, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({
102
+ model,
103
+ messages: [
104
+ { role: "user", content: LAYER1_PROMPT.replace("{prompt}", userPrompt) },
105
+ ],
106
+ stream: false,
107
+ think: false,
108
+ options: { num_predict: 16, temperature: 0 },
109
+ }),
110
+ signal: AbortSignal.timeout(timeoutMs),
111
+ });
112
+ }
113
+ catch {
114
+ return "ERROR";
115
+ }
116
+ if (!res.ok)
117
+ return "ERROR";
118
+ let data;
119
+ try {
120
+ data = await res.json();
121
+ }
122
+ catch {
123
+ return "ERROR";
124
+ }
125
+ if (data?.error)
126
+ return "ERROR";
127
+ const text = data?.message?.content;
128
+ return parseLayer1(text);
129
+ };
130
+ const first = await classify(LAYER1_TIMEOUT_MS);
131
+ if (first !== "ERROR")
132
+ return first;
133
+ return classify(LAYER1_RETRY_TIMEOUT_MS);
110
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.0.2",
3
+ "version": "20.0.3",
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",