prism-mcp-server 20.0.2 → 20.0.4
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 +33 -1
- package/dist/boundaries/boundaries.js +15 -10
- package/dist/server.js +2 -1
- package/dist/tools/__tests__/layer1Integration.test.js +102 -3
- package/dist/tools/__tests__/ledgerHandlers.test.js +5 -27
- package/dist/tools/ledgerHandlers.js +8 -23
- package/dist/tools/prismInferHandler.js +44 -5
- package/dist/utils/layer1.js +65 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,39 @@ 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.4
|
|
22
|
+
|
|
23
|
+
### Think-Only Retry
|
|
24
|
+
Qwen 3.5 models (9B/27B) with thinking enabled could burn all tokens on `<think>` blocks and return empty content, causing a cascade to 4B. Now detects think-only responses and retries the same tier with thinking disabled — preserving model quality instead of falling to a smaller model.
|
|
25
|
+
|
|
26
|
+
### Local-First Skill Rewrite
|
|
27
|
+
The `local-inference-first` skill now uses a hard falsifiable trigger list (format conversion, regex, i18n, boilerplate CRUD, deterministic tests) with no self-assessment escape. Clinical content removed from delegation — it conflicts with the Layer 1 reserved-category safety floor. Honest target: 15-25% of prompts delegated at equal quality.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## What's New in v20.0.3
|
|
32
|
+
|
|
33
|
+
### Layer 1 Cold-Model Resilience
|
|
34
|
+
The reserved-category classifier now retries once with a longer timeout on cold-model failure, then falls back to a deterministic keyword backstop before refusing. Over-length prompts (>4K chars) are classified as UNCERTAIN before reaching the classifier — prompt padding can no longer force the ERROR branch. This eliminates the cold-start refusal problem without weakening the safety gate.
|
|
35
|
+
|
|
36
|
+
### Keyword Backstop for Reserved Content
|
|
37
|
+
When the LLM classifier fails (timeout, injection, resource pressure), a deterministic regex floor catches reserved vocabulary (restraint, seclusion, self-harm, suicide, overdose, crisis de-escalation, etc.) including inflected and verb forms. Blocks prompt-padding and classifier-injection attacks on the ERROR path.
|
|
38
|
+
|
|
39
|
+
### Single-Source Safety Text
|
|
40
|
+
The safety statement in the MCP server `instructions` field now imports from `boundaries.ts` — one source of truth instead of two hand-maintained copies. Boundaries version bumped to v3 with an explicit delivery decision documented in code.
|
|
41
|
+
|
|
42
|
+
### Reserved-Category Safety Gate — All Tiers (v20.0.2)
|
|
43
|
+
The Layer 1 semantic classifier now runs for **every** user, not just paid tiers. Reserved clinical content is refused on free tier when cloud is unavailable — fail-closed.
|
|
44
|
+
|
|
45
|
+
### Ledger Dedup (v20.0.2)
|
|
46
|
+
`session_save_ledger` deduplicates identical entries within a 5-minute window.
|
|
47
|
+
|
|
48
|
+
### Evidence Script (v20.0.2)
|
|
49
|
+
`scripts/generate-evidence.sh` regenerates all 5 evidence files with built-in assertions. Run `bash scripts/generate-evidence.sh` to verify the full pipeline.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## What's New in v20.0.0
|
|
22
54
|
|
|
23
55
|
### License: AGPL-3.0 → Apache-2.0
|
|
24
56
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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 = "
|
|
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)
|
|
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).
|
|
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
|
|
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(/
|
|
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
|
});
|
|
@@ -166,6 +250,21 @@ describe("Layer 1 handler integration", () => {
|
|
|
166
250
|
await expect(runInfer({ prompt: "de-escalation plan for violent behavior", mode: "code", cloud_fallback: true, max_tokens: 512 }, makeBaseDeps({ callLocal, callCloud, callLayer1: callLayer1Mock }))).rejects.toThrow("Layer 1 verdict=OBVIOUS_RESERVED");
|
|
167
251
|
expect(callLocal).not.toHaveBeenCalled();
|
|
168
252
|
});
|
|
253
|
+
it("think_only → retries same tier with think=false, succeeds", async () => {
|
|
254
|
+
let callCount = 0;
|
|
255
|
+
const callLocal = vi.fn().mockImplementation(async (_url, _model, _prompt, _sys, _max, _temp, _timeout, think) => {
|
|
256
|
+
callCount++;
|
|
257
|
+
if (think)
|
|
258
|
+
return { ok: false, reason: "think_only" };
|
|
259
|
+
return { ok: true, text: "answer without thinking", doneReason: "stop" };
|
|
260
|
+
});
|
|
261
|
+
const result = await runInfer({ prompt: "write a regex for emails", mode: "code", cloud_fallback: false, max_tokens: 256 }, makeBaseDeps({ callLocal }));
|
|
262
|
+
expect(callCount).toBe(2);
|
|
263
|
+
expect(callLocal).toHaveBeenNthCalledWith(1, expect.anything(), expect.anything(), expect.anything(), undefined, expect.anything(), expect.anything(), expect.anything(), true);
|
|
264
|
+
expect(callLocal).toHaveBeenNthCalledWith(2, expect.anything(), expect.anything(), expect.anything(), undefined, expect.anything(), expect.anything(), expect.anything(), false);
|
|
265
|
+
expect(result.output).toBe("answer without thinking");
|
|
266
|
+
expect(result.used_cloud).toBe(false);
|
|
267
|
+
});
|
|
169
268
|
});
|
|
170
269
|
// ── Auto-evict unit tests ─────────────────────────────────────────────────────
|
|
171
270
|
describe("Auto-evict warm smaller models (F1/F2/F3/F4 regression suite)", () => {
|
|
@@ -449,15 +449,14 @@ describe("ledgerHandlers", () => {
|
|
|
449
449
|
expect(text).toContain('<prism_memory context="historical">');
|
|
450
450
|
expect(text).toContain("</prism_memory>");
|
|
451
451
|
});
|
|
452
|
-
it("
|
|
452
|
+
it("does not contain inline ABA protocol (delivered via skill routing)", async () => {
|
|
453
453
|
storage.loadContext.mockResolvedValue({
|
|
454
454
|
last_summary: "Summary",
|
|
455
455
|
version: 1,
|
|
456
456
|
});
|
|
457
457
|
const result = await sessionLoadContextHandler(validArgs);
|
|
458
458
|
const text = result.content[0].text;
|
|
459
|
-
expect(text).toContain("ABA PRECISION PROTOCOL");
|
|
460
|
-
expect(text).toContain("Observable Goals");
|
|
459
|
+
expect(text).not.toContain("ABA PRECISION PROTOCOL");
|
|
461
460
|
});
|
|
462
461
|
it("passes role to loadContext when provided", async () => {
|
|
463
462
|
storage.loadContext.mockResolvedValue(null);
|
|
@@ -531,35 +530,14 @@ describe("ledgerHandlers", () => {
|
|
|
531
530
|
await sessionLoadContextHandler({ project: "test-project" });
|
|
532
531
|
expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
|
|
533
532
|
});
|
|
534
|
-
it("
|
|
533
|
+
it("does not include safety banner (enforcement is in code)", async () => {
|
|
535
534
|
storage.loadContext.mockResolvedValue(null);
|
|
536
535
|
const result = await sessionLoadContextHandler(validArgs);
|
|
537
536
|
const text = result.content[0].text;
|
|
538
|
-
expect(text).toContain("Safety Boundaries");
|
|
539
|
-
expect(text).toContain("BOUNDARIES STUB");
|
|
537
|
+
expect(text).not.toContain("[Safety Boundaries");
|
|
538
|
+
expect(text).not.toContain("BOUNDARIES STUB");
|
|
540
539
|
expect(text).toContain("no previous session history");
|
|
541
540
|
});
|
|
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
541
|
});
|
|
564
542
|
// ====================================================================
|
|
565
543
|
// 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:
|
|
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[
|
|
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
|
}
|
|
@@ -1013,21 +1010,13 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1013
1010
|
behavWarnings.map(w => `- ${w.summary} (importance: ${w.importance})`).join("\n");
|
|
1014
1011
|
behavBlock = [...rawBlock].slice(0, 2000).join('');
|
|
1015
1012
|
}
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
`Rule 2 — Precise Execution: One step at a time. Verify each step. If it fails → STOP → fix → verify → then continue.\n` +
|
|
1020
|
-
`Rule 3 — No Reinforcement of Errors: Never repeat the same mistake twice. When the user says something is wrong, read the actual code/data FIRST before forming an opinion.\n` +
|
|
1021
|
-
`Rule 4 — No Negation/Filler/Hedging Lead: NEVER start with "I can't", "I'm unable to", "Unfortunately", "I cannot", "I apologize", "While I cannot", "Sorry", "Regrettably", "I'm afraid", "As an AI", "Sure,", "Certainly,", "I can certainly", "Let me be transparent", "I should note", "Just to clarify", "It's worth noting". You may use "Yes" or "Absolutely" ONLY as a 1-word direct answer to a binary Yes/No question. UNCERTAINTY ESCAPE: Use ONLY for required database fields/API params (e.g., "Missing: patient_id"). Do NOT use as generic refusal.\n` +
|
|
1022
|
-
`Rule 5 — Fix Without Asking: When you see a bug, fix it immediately. Do NOT ask "would you like me to fix that?" — just fix it.\n` +
|
|
1023
|
-
`Rule 6 — Action Intent: When user says "fix/run/open/deploy", they want ACTION not a tutorial. Ask for specific info needed in 1-2 sentences, or act directly.\n` +
|
|
1024
|
-
`Rule 7 — Tool Redirect: When user asks to "open browser"/"run terminal"/"git push" — output ONLY the URL or command. No follow-up. No explanations. Example: "open browser" → "https://synalux.ai/dashboard"`;
|
|
1013
|
+
// ABA Precision Protocol is now delivered via skill routing
|
|
1014
|
+
// (aba-precision-protocol, protected universal at priority -1).
|
|
1015
|
+
// The inline literal was deleted in R25 to eliminate the mirror pair.
|
|
1025
1016
|
// T5 structural truncation: assemble sections in priority order so budget truncation
|
|
1026
1017
|
// drops the LEAST critical sections first (session history), never skills.
|
|
1027
|
-
// Priority high→low:
|
|
1028
|
-
// The header + critical rules are always at the top; history is appended last.
|
|
1018
|
+
// Priority high→low: skills | behavioral warnings | version | drift | briefing | SDM | history
|
|
1029
1019
|
const criticalPrefix = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n` +
|
|
1030
|
-
abaProtocol +
|
|
1031
1020
|
behavBlock +
|
|
1032
1021
|
skillBlock +
|
|
1033
1022
|
versionNote +
|
|
@@ -1073,10 +1062,8 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1073
1062
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
1074
1063
|
noteDriftSessionStart(convId);
|
|
1075
1064
|
}
|
|
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
1065
|
return {
|
|
1079
|
-
content: [{ type: "text", text:
|
|
1066
|
+
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1080
1067
|
isError: false,
|
|
1081
1068
|
};
|
|
1082
1069
|
}
|
|
@@ -1087,10 +1074,8 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1087
1074
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
1088
1075
|
noteDriftSessionStart(convId);
|
|
1089
1076
|
}
|
|
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
1077
|
return {
|
|
1093
|
-
content: [{ type: "text", text:
|
|
1078
|
+
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1094
1079
|
isError: false,
|
|
1095
1080
|
};
|
|
1096
1081
|
}
|
|
@@ -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 = {
|
|
@@ -245,8 +245,13 @@ async function callOllamaGenerate(url, model, prompt, system, maxTokens, tempera
|
|
|
245
245
|
if (data.error)
|
|
246
246
|
return { ok: false, reason: `ollama_err:${data.error}` };
|
|
247
247
|
const text = (data.message?.content ?? "").trim();
|
|
248
|
-
if (!text)
|
|
249
|
-
|
|
248
|
+
if (!text) {
|
|
249
|
+
// When think=true, the model may burn all tokens on <think> and
|
|
250
|
+
// produce empty content. Report this distinctly so the tier loop
|
|
251
|
+
// can retry the same model with think=false rather than skipping.
|
|
252
|
+
const hadThinking = !!(data.message?.thinking);
|
|
253
|
+
return { ok: false, reason: hadThinking ? "think_only" : "empty_response" };
|
|
254
|
+
}
|
|
250
255
|
return { ok: true, text, doneReason: data.done_reason, promptTokens: data.prompt_eval_count, completionTokens: data.eval_count };
|
|
251
256
|
}
|
|
252
257
|
catch (err) {
|
|
@@ -423,7 +428,7 @@ export async function runInfer(args, deps) {
|
|
|
423
428
|
const l1fn = deps.callLayer1 ?? defaultCallLayer1;
|
|
424
429
|
const l1Model = resolveOllamaName("prism-coder:4b", installed);
|
|
425
430
|
const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
|
|
426
|
-
if (l1
|
|
431
|
+
if (l1 === "OBVIOUS_RESERVED" || l1 === "UNCERTAIN") {
|
|
427
432
|
debugLog(`[prism_infer] Layer 1 verdict=${l1} — reserved content detected`);
|
|
428
433
|
attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
|
|
429
434
|
if (allowCloud) {
|
|
@@ -445,6 +450,33 @@ export async function runInfer(args, deps) {
|
|
|
445
450
|
}
|
|
446
451
|
throw new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
|
|
447
452
|
}
|
|
453
|
+
if (l1 === "ERROR") {
|
|
454
|
+
debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
|
|
455
|
+
attempts.push({ tier: "layer1", reason: "layer1_error" });
|
|
456
|
+
if (allowCloud) {
|
|
457
|
+
const cloudTimeout = args.timeout_ms ?? 90_000;
|
|
458
|
+
const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
|
|
459
|
+
if (cloud.ok && cloud.output) {
|
|
460
|
+
return await applyVerification(cloud.output, gatedArgs, deps, {
|
|
461
|
+
backend: cloud.backend ?? "synalux",
|
|
462
|
+
model_picked: null,
|
|
463
|
+
ram_free_mb: ramFreeMb,
|
|
464
|
+
latency_ms: Date.now() - t0,
|
|
465
|
+
used_cloud: true,
|
|
466
|
+
attempts,
|
|
467
|
+
plan: ent.plan,
|
|
468
|
+
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
472
|
+
}
|
|
473
|
+
const backstop = keywordBackstop(args.prompt);
|
|
474
|
+
debugLog(`[prism_infer] keyword backstop verdict=${backstop}`);
|
|
475
|
+
attempts.push({ tier: "keyword_backstop", reason: `backstop_${backstop.toLowerCase()}` });
|
|
476
|
+
if (backstop === "OBVIOUS_RESERVED") {
|
|
477
|
+
throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
448
480
|
debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
|
|
449
481
|
}
|
|
450
482
|
// ── end Layer 1 ─────────────────────────────────────────────────────────
|
|
@@ -534,7 +566,14 @@ export async function runInfer(args, deps) {
|
|
|
534
566
|
anyViable = true;
|
|
535
567
|
const timeout = args.timeout_ms ?? DEFAULT_TIMEOUTS[tier.tag] ?? 60_000;
|
|
536
568
|
const enableThink = args.think ?? (mode !== "route");
|
|
537
|
-
|
|
569
|
+
let result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, args.system, maxTokens, temperature, timeout, enableThink);
|
|
570
|
+
// Think-only retry: model burned all tokens on <think>, empty content.
|
|
571
|
+
// Retry same model with think=false rather than falling to a smaller tier.
|
|
572
|
+
// One-shot: think=false cannot re-trigger think_only (no thinking to burn).
|
|
573
|
+
if (!result.ok && result.reason === "think_only" && enableThink) {
|
|
574
|
+
debugLog(`[prism_infer] ${tier.tag} returned think-only — retrying with think=false`);
|
|
575
|
+
result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, args.system, maxTokens, temperature, timeout, false);
|
|
576
|
+
}
|
|
538
577
|
if (result.ok) {
|
|
539
578
|
const { stripped, thinkOnly } = stripThink(result.text);
|
|
540
579
|
const output = stripped;
|
package/dist/utils/layer1.js
CHANGED
|
@@ -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
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
3
|
+
"version": "20.0.4",
|
|
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",
|