prism-mcp-server 20.0.1 → 20.0.2

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
@@ -382,9 +382,9 @@ All on-device models are free to run locally via Ollama on every tier. A subscri
382
382
  |---|---|---|---|---|
383
383
  | Seats | 1 | 1 | up to 5 | up to 25 |
384
384
  | Local model ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
385
- | Daily cloud inference | -- | 200 | 2,000 | 100,000 |
386
- | Cloud Coder (Web IDE) | -- | 100/day | 1,000/day | 100,000/day |
387
- | Cloud search | -- | 50/day | 500/day | 100,000/day |
385
+ | Cloud inference | -- | | | (priority) |
386
+ | Cloud Coder (Web IDE) | -- | | | (priority) |
387
+ | Cloud search | -- | | | |
388
388
  | Max output tokens | 512 | 1,024 | 2,048 | 4,096 |
389
389
  | Cloud fallback | -- | Claude Opus 4.7 | Claude Opus 4.7 | Priority + Opus 4.7 |
390
390
  | Grounding verifier (fact-check AI output) | -- | ✅ | ✅ | ✅ |
@@ -644,11 +644,32 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
644
644
 
645
645
  ## License & Tiers
646
646
 
647
- - **This repository (the Prism MCP client)** is licensed under [Apache-2.0](./LICENSE).
648
- - **Free tier**: run Prism MCP locally against your own machine. No account required.
649
- - **Paid tiers**: cloud features (hosted inference cascade, cross-device memory,
650
- team features) are provided by the Synalux cloud service and governed by the
651
- Synalux Terms of Service — they are not part of this repository or its license.
647
+ **This repository (the Prism MCP client)** is licensed under [Apache-2.0](./LICENSE).
648
+
649
+ ### Free (no account)
650
+
651
+ | Feature | Details |
652
+ |---------|---------|
653
+ | Local inference | Ollama via `prism_infer`, capped at the 4B model tier |
654
+ | Session memory | Persistent sessions, handoffs, ledger — all local SQLite |
655
+ | Knowledge search | Semantic search across session history |
656
+ | Skills | All skills available locally (run `sync-skills.sh` to populate) |
657
+ | Drift detection | Server-side GATE 5 reminders |
658
+
659
+ ### Paid (Synalux subscription)
660
+
661
+ Everything in Free, plus:
662
+
663
+ | Feature | Details |
664
+ |---------|---------|
665
+ | Model ceiling | Up to 27B locally + cloud cascade (9B → 27B → Claude) when local is unavailable |
666
+ | Skill routing | Portal resolves which skills to load based on your project and prompt |
667
+ | Cross-device memory | Supabase cloud sync — sessions survive across machines |
668
+ | Grounding verifier | L3 NLI verification on model outputs |
669
+ | Team features | Multi-agent Hivemind, workspace collaboration |
670
+
671
+ The paid tier adds **intelligent routing** — the Synalux portal determines which skills are relevant to your current project and prompt, so your agent gets domain expertise (stripe patterns, training protocols, clinical standards) instead of loading everything. Free users with the repo can run `sync-skills.sh` to populate all skills locally; paid routing adds project-aware and prompt-aware selection.
672
+
652
673
  - Contributions require signing the [CLA](./CLA.md).
653
674
  - "Prism" and "Synalux" are trade names of Synalux LLC; the Apache license does
654
675
  not grant trademark rights (see §6 of the license).
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * boundaries.ts unit tests
3
3
  *
4
- * Verifies structural invariants of the operating boundaries export:
4
+ * Verifies structural invariants of the safety declaration:
5
5
  * - BOUNDARIES_VERSION is present and non-empty
6
- * - BOUNDARIES_TEXT covers the five required sections
7
- * - Safety-critical concepts are mentioned (so a future edit doesn't silently
8
- * remove them without also bumping the version and updating tests)
6
+ * - BOUNDARIES_TEXT covers safety-critical concepts
7
+ * - Architecture/routing docs moved to server instructions (not per-call)
9
8
  */
10
9
  import { describe, it, expect } from "vitest";
11
10
  import { BOUNDARIES_VERSION, BOUNDARIES_TEXT } from "../../boundaries/boundaries.js";
@@ -16,43 +15,32 @@ describe("BOUNDARIES_VERSION", () => {
16
15
  });
17
16
  });
18
17
  describe("BOUNDARIES_TEXT structure", () => {
19
- it("is a non-empty string", () => {
18
+ it("is a non-empty string under 500 chars (compressed from ~2000)", () => {
20
19
  expect(typeof BOUNDARIES_TEXT).toBe("string");
21
- expect(BOUNDARIES_TEXT.length).toBeGreaterThan(100);
20
+ expect(BOUNDARIES_TEXT.length).toBeGreaterThan(50);
21
+ expect(BOUNDARIES_TEXT.length).toBeLessThan(600);
22
22
  });
23
23
  it("has no leading or trailing whitespace (trim() was applied)", () => {
24
24
  expect(BOUNDARIES_TEXT).toBe(BOUNDARIES_TEXT.trim());
25
25
  });
26
- it("contains a safety gates section", () => {
27
- expect(BOUNDARIES_TEXT).toMatch(/safety gates/i);
28
- });
29
- it("contains a BCBA clinical standards section", () => {
30
- expect(BOUNDARIES_TEXT).toMatch(/bcba/i);
31
- });
32
- it("contains a correctness gates section", () => {
33
- expect(BOUNDARIES_TEXT).toMatch(/correctness gates/i);
34
- });
35
- it("contains an inference routing section", () => {
36
- expect(BOUNDARIES_TEXT).toMatch(/inference routing/i);
26
+ it("references BCBA reserved categories", () => {
27
+ expect(BOUNDARIES_TEXT).toMatch(/BCBA reserved/i);
37
28
  });
38
- it("contains a host note section", () => {
39
- expect(BOUNDARIES_TEXT).toMatch(/host note/i);
40
- });
41
- it("asserts that enforcement is server-side (not instruction-based)", () => {
42
- expect(BOUNDARIES_TEXT).toMatch(/server.*enforc|enforc.*server/i);
43
- });
44
- it("references the fail-closed rule for reserved + no-cloud", () => {
45
- expect(BOUNDARIES_TEXT).toMatch(/fail.?closed|refused/i);
29
+ it("references fail-closed refusal for reserved content", () => {
30
+ expect(BOUNDARIES_TEXT).toMatch(/refused/i);
46
31
  });
47
32
  it("states AAC access is never restricted as a consequence", () => {
48
33
  expect(BOUNDARIES_TEXT).toMatch(/AAC access is never restricted/i);
49
34
  });
50
- it("references restraint as a RESERVED category", () => {
35
+ it("references restraint as reserved", () => {
51
36
  expect(BOUNDARIES_TEXT).toMatch(/restraint/i);
52
- expect(BOUNDARIES_TEXT).toMatch(/RESERVED/i);
53
37
  });
54
- it("mentions session_save_ledger and session_save_handoff in correctness section", () => {
55
- expect(BOUNDARIES_TEXT).toContain("session_save_ledger");
56
- expect(BOUNDARIES_TEXT).toContain("session_save_handoff");
38
+ it("references crisis/self-harm interception", () => {
39
+ expect(BOUNDARIES_TEXT).toMatch(/crisis|self-harm/i);
40
+ });
41
+ it("does NOT contain architecture/routing docs (those moved to server instructions)", () => {
42
+ expect(BOUNDARIES_TEXT).not.toContain("session_save_ledger");
43
+ expect(BOUNDARIES_TEXT).not.toContain("inference routing");
44
+ expect(BOUNDARIES_TEXT).not.toMatch(/host note/i);
57
45
  });
58
46
  });
@@ -1,48 +1,23 @@
1
1
  /**
2
2
  * src/boundaries/boundaries.ts
3
3
  *
4
- * Operating boundaries delivered in every session_load_context result.
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).
5
7
  *
6
- * These boundaries are enforced server-side in code (prism_infer safety
7
- * gates, requireContextLoaded). This text is belt-and-suspenders for a
8
- * cooperative host it cannot be removed to bypass enforcement.
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.
9
11
  *
10
12
  * Update BOUNDARIES_VERSION any time the text changes so session drift
11
13
  * detection can flag stale sessions.
12
14
  */
13
- export const BOUNDARIES_VERSION = "1";
15
+ export const BOUNDARIES_VERSION = "2";
14
16
  export const BOUNDARIES_TEXT = `
15
- ## OPERATING BOUNDARIESserver-enforced, shown for transparency
17
+ Safety boundaries are enforced in code — shown so hosts avoid wasted round-trips.
16
18
 
17
- ### 1. Safety gates (unconditional — run before and after every model call)
18
- - Crisis/self-harm inputs are intercepted before reaching any model.
19
- - BCBA reserved categories (restraint, seclusion, physical management, dosing) route
20
- to cloud or refuse; they NEVER generate locally. Fail-closed: if cloud is unavailable
21
- and the prompt is reserved, the request is refused — never downgraded to local.
22
- - Dangerous output (restraint instructions, overdose methods, self-harm guidance)
23
- is blocked regardless of which host requested it.
24
-
25
- ### 2. BCBA clinical standards
26
- - Apply ABA principles grounded in the current BACB Ethics Code and Task List (5th Ed).
27
- - Use evidence-based interventions: FCT, DRA, DRO, NCR, antecedent modifications.
28
- - Least restrictive, dignity-preserving, trauma-informed procedures always.
19
+ - **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.
21
+ - **Dangerous output** (restraint instructions, overdose methods, self-harm guidance) is blocked regardless of host.
29
22
  - AAC access is never restricted as a consequence.
30
- - Physical management / restraint / seclusion are RESERVED — cloud only, with audit.
31
-
32
- ### 3. Correctness gates (project-scoped write tools)
33
- - session_save_ledger and session_save_handoff require a loaded project context
34
- (conversation_id that called session_load_context successfully).
35
- - This prevents a non-Claude host from writing state it hasn't confirmed.
36
-
37
- ### 4. Inference routing
38
- - Local inference (Ollama) runs ONLY for OBVIOUS_NOT_RESERVED prompts (Layer 1 verdict).
39
- - RESERVED / UNCERTAIN / classifier errors escalate to cloud. Never downgrade reserved
40
- prompts to local — that is exactly what Layer 1 flagged.
41
- - Cloud must never be reached directly by a host; all cloud inference routes through
42
- the Synalux portal for billing, tier-gating, and HIPAA audit.
43
-
44
- ### 5. Host note
45
- These boundaries are enforced by the server. They apply identically whether the host
46
- is Claude Code, Gemini, an autonomous script, or a cron job. A host that does not read
47
- this text still cannot bypass the enforcement — it is in code, not instructions.
48
23
  `.trim();
package/dist/server.js CHANGED
@@ -358,7 +358,7 @@ export function createServer() {
358
358
  },
359
359
  // Supplementary signal — not all clients support this field.
360
360
  // 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.`,
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
362
  });
363
363
  // ── Handler: Initialize ──
364
364
  // NOTE: The SDK's built-in _oninitialize() handles the Initialize request.
@@ -9,6 +9,7 @@
9
9
  * an exported reset function.
10
10
  */
11
11
  import { describe, it, expect, beforeEach, vi } from "vitest";
12
+ import { BOUNDARIES_VERSION } from "../../boundaries/boundaries.js";
12
13
  // Reset module registry before each test so the in-memory Map starts empty.
13
14
  let markContextLoaded;
14
15
  let requireContextLoaded;
@@ -56,7 +57,7 @@ describe("requireContextLoaded — fail-closed defaults", () => {
56
57
  });
57
58
  describe("markContextLoaded → requireContextLoaded lifecycle", () => {
58
59
  it("returns null (pass) after markContextLoaded is called", () => {
59
- markContextLoaded("conv-abc", "project-x", "1");
60
+ markContextLoaded("conv-abc", "project-x", BOUNDARIES_VERSION);
60
61
  expect(requireContextLoaded("conv-abc")).toBeNull();
61
62
  });
62
63
  it("records project and boundariesVersion on the session", () => {
@@ -68,30 +69,30 @@ describe("markContextLoaded → requireContextLoaded lifecycle", () => {
68
69
  expect(state.contextLoaded).toBe(true);
69
70
  });
70
71
  it("is idempotent — calling twice does not break state", () => {
71
- // Use the actual BOUNDARIES_VERSION ("1") so no drift warning fires.
72
- markContextLoaded("conv-idem", "proj", "1");
73
- markContextLoaded("conv-idem", "proj-updated", "1");
72
+ // Use the actual BOUNDARIES_VERSION so no drift warning fires.
73
+ markContextLoaded("conv-idem", "proj", BOUNDARIES_VERSION);
74
+ markContextLoaded("conv-idem", "proj-updated", BOUNDARIES_VERSION);
74
75
  const state = getSessionState("conv-idem");
75
76
  expect(state.project).toBe("proj-updated");
76
- expect(state.boundariesVersion).toBe("1");
77
+ expect(state.boundariesVersion).toBe(BOUNDARIES_VERSION);
77
78
  expect(requireContextLoaded("conv-idem")).toBeNull();
78
79
  });
79
80
  it("isolates sessions — loading one does not unblock another", () => {
80
- markContextLoaded("conv-A", "proj", "1");
81
+ markContextLoaded("conv-A", "proj", BOUNDARIES_VERSION);
81
82
  expect(requireContextLoaded("conv-A")).toBeNull();
82
83
  expect(requireContextLoaded("conv-B")).not.toBeNull();
83
84
  });
84
85
  });
85
86
  describe("noteInferenceForSession", () => {
86
87
  it("increments inferenceCalls on every call", () => {
87
- markContextLoaded("conv-inf", "proj", "1");
88
+ markContextLoaded("conv-inf", "proj", BOUNDARIES_VERSION);
88
89
  noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
89
90
  noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
90
91
  const state = getSessionState("conv-inf");
91
92
  expect(state.inferenceCalls).toBe(2);
92
93
  });
93
94
  it("increments usedCloudCalls only for cloud calls", () => {
94
- markContextLoaded("conv-cloud", "proj", "1");
95
+ markContextLoaded("conv-cloud", "proj", BOUNDARIES_VERSION);
95
96
  noteInferenceForSession("conv-cloud", { backend: "cloud", usedCloud: true });
96
97
  noteInferenceForSession("conv-cloud", { backend: "local", usedCloud: false });
97
98
  const state = getSessionState("conv-cloud");
@@ -112,7 +113,7 @@ describe("getSessionState", () => {
112
113
  expect(getSessionState("does-not-exist")).toBeNull();
113
114
  });
114
115
  it("returns the current state object for a known session", () => {
115
- markContextLoaded("conv-get", "proj", "1");
116
+ markContextLoaded("conv-get", "proj", BOUNDARIES_VERSION);
116
117
  const state = getSessionState("conv-get");
117
118
  expect(state).not.toBeNull();
118
119
  expect(state.contextLoaded).toBe(true);
@@ -120,7 +121,7 @@ describe("getSessionState", () => {
120
121
  });
121
122
  describe("lastSeen update", () => {
122
123
  it("updates lastSeen on every requireContextLoaded call", async () => {
123
- markContextLoaded("conv-ts", "proj", "1");
124
+ markContextLoaded("conv-ts", "proj", BOUNDARIES_VERSION);
124
125
  const before = getSessionState("conv-ts").lastSeen;
125
126
  // Advance time by mocking Date.now via vi.useFakeTimers
126
127
  vi.useFakeTimers();
@@ -909,6 +909,19 @@ export class SqliteStorage {
909
909
  async saveLedger(entry) {
910
910
  const id = entry.id || randomUUID();
911
911
  const now = new Date().toISOString();
912
+ // Dedup: skip if an identical (project, conversation_id, summary) exists within 5 minutes
913
+ if (entry.project && entry.conversation_id && entry.summary) {
914
+ const dup = await this.db.execute({
915
+ sql: `SELECT id FROM session_ledger
916
+ WHERE project = ? AND conversation_id = ? AND summary = ?
917
+ AND created_at > datetime('now', '-5 minutes')
918
+ LIMIT 1`,
919
+ args: [entry.project, entry.conversation_id, entry.summary],
920
+ });
921
+ if (dup.rows.length > 0) {
922
+ return { id: dup.rows[0].id, deduplicated: true };
923
+ }
924
+ }
912
925
  await this.db.execute({
913
926
  sql: `INSERT INTO session_ledger
914
927
  (id, project, conversation_id, user_id, role, summary, todos, files_changed,
@@ -75,6 +75,7 @@ function makeBaseDeps(overrides = {}) {
75
75
  listLoaded: async () => new Set(),
76
76
  callLocal: async () => ({ ok: true, text: "local response", doneReason: "stop" }),
77
77
  callCloud: async () => ({ ok: true, output: "cloud response", backend: "synalux" }),
78
+ callLayer1: async () => "OBVIOUS_NOT_RESERVED",
78
79
  ollamaUrl: "http://localhost:11434",
79
80
  entitlements: {
80
81
  plan: "pro",
@@ -138,11 +139,18 @@ describe("Layer 1 handler integration", () => {
138
139
  expect(callLocal).toHaveBeenCalled();
139
140
  expect(result.used_cloud).toBe(false);
140
141
  });
141
- it("cloud_fallback=false → Layer 1 skipped entirely", async () => {
142
+ it("cloud_fallback=false + RESERVED refuses (fail-closed, no local fallback)", async () => {
142
143
  const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
143
144
  const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
144
- const result = await runInfer({ prompt: "write something", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }));
145
- expect(callLayer1Mock).not.toHaveBeenCalled();
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);
146
+ expect(callLayer1Mock).toHaveBeenCalled();
147
+ expect(callLocal).not.toHaveBeenCalled();
148
+ });
149
+ it("cloud_fallback=false + NOT_RESERVED → proceeds to local", async () => {
150
+ const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_NOT_RESERVED");
151
+ const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
152
+ const result = await runInfer({ prompt: "what is 2+2?", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }));
153
+ expect(callLayer1Mock).toHaveBeenCalled();
146
154
  expect(result.used_cloud).toBe(false);
147
155
  });
148
156
  it("recursion guard: mode=route + max_tokens<=16 skips Layer 1", async () => {
@@ -531,11 +531,33 @@ describe("ledgerHandlers", () => {
531
531
  await sessionLoadContextHandler({ project: "test-project" });
532
532
  expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
533
533
  });
534
- it("prepends BOUNDARIES header to every response", async () => {
534
+ it("prepends safety header on no-history path", 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("OPERATING BOUNDARIES");
538
+ expect(text).toContain("Safety Boundaries");
539
+ expect(text).toContain("BOUNDARIES STUB");
540
+ expect(text).toContain("no previous session history");
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");
539
561
  expect(text).toContain("BOUNDARIES STUB");
540
562
  });
541
563
  });
@@ -670,10 +670,7 @@ export async function sessionLoadContextHandler(args) {
670
670
  noteDriftSessionStart(convId);
671
671
  }
672
672
  const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
673
- const boundariesHeader0 = `# OPERATING BOUNDARIES (v${BV0}) — enforced server-side, shown for transparency\n` +
674
- BT0 + "\n\n" +
675
- `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
676
- `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
673
+ const boundariesHeader0 = `# Safety Boundaries (v${BV0})\n${BT0}\n\n---\n\n`;
677
674
  return {
678
675
  content: [{
679
676
  type: "text",
@@ -944,7 +941,10 @@ export async function sessionLoadContextHandler(args) {
944
941
  skillLoaded = true;
945
942
  }
946
943
  }
947
- // Offline fallback: load whatever's in local DB
944
+ // Offline fallback: load ALL local skill: content (no tier gating).
945
+ // Deliberate: offline = degraded = best-effort. A repo-holder with
946
+ // sync-skills.sh has the full library locally regardless of tier.
947
+ // Tier gating is portal-side (name resolution); offline bypasses it.
948
948
  if (skillResolution.isOffline) {
949
949
  const allSettings = await storage.getAllSettings?.() || {};
950
950
  for (const [k, v] of Object.entries(allSettings)) {
@@ -1074,10 +1074,7 @@ export async function sessionLoadContextHandler(args) {
1074
1074
  noteDriftSessionStart(convId);
1075
1075
  }
1076
1076
  const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
1077
- const boundariesHeader = `# OPERATING BOUNDARIES (v${BV}) — enforced server-side, shown for transparency\n` +
1078
- BOUNDARIES_TEXT + "\n\n" +
1079
- `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1080
- `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1077
+ const boundariesHeader = `# Safety Boundaries (v${BV})\n${BOUNDARIES_TEXT}\n\n---\n\n`;
1081
1078
  return {
1082
1079
  content: [{ type: "text", text: boundariesHeader + responseText + MEMORY_BOUNDARY_SUFFIX }],
1083
1080
  isError: false,
@@ -1091,10 +1088,7 @@ export async function sessionLoadContextHandler(args) {
1091
1088
  noteDriftSessionStart(convId);
1092
1089
  }
1093
1090
  const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
1094
- const boundariesHeader2 = `# OPERATING BOUNDARIES (v${BV2}) — enforced server-side, shown for transparency\n` +
1095
- BOUNDARIES_TEXT + "\n\n" +
1096
- `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1097
- `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1091
+ const boundariesHeader2 = `# Safety Boundaries (v${BV2})\n${BOUNDARIES_TEXT}\n\n---\n\n`;
1098
1092
  return {
1099
1093
  content: [{ type: "text", text: boundariesHeader2 + responseText + MEMORY_BOUNDARY_SUFFIX }],
1100
1094
  isError: false,
@@ -412,36 +412,38 @@ export async function runInfer(args, deps) {
412
412
  attempts.push({ tier: "ollama_probe", reason: "unreachable" });
413
413
  }
414
414
  // ── §E Layer 1 semantic pre-classifier ──────────────────────────────────
415
- // Catches adversarial paraphrases the keyword stub misses.
416
- // Runs only when cloud escalation is possible without cloud, there is
417
- // nowhere to route a RESERVED verdict.
415
+ // Runs for ALL tiers when Ollama is reachable. RESERVED prompts escalate
416
+ // to cloud if available; otherwise refuse (fail-closed). Free-tier users
417
+ // without cloud still get classified — a RESERVED verdict refuses the
418
+ // request rather than silently routing to local.
418
419
  // Recursion guard: skip when this call IS the Layer 1 classification
419
420
  // (mode="route" + max_tokens<=16 is the Layer 1 call signature).
420
421
  const layer1RecursionGuard = mode === "route" && maxTokens <= 16;
421
- if (allowCloud && !layer1RecursionGuard) {
422
+ if (installed && !layer1RecursionGuard) {
422
423
  const l1fn = deps.callLayer1 ?? defaultCallLayer1;
423
- const l1Model = resolveOllamaName("prism-coder:4b", installed ?? new Set());
424
+ const l1Model = resolveOllamaName("prism-coder:4b", installed);
424
425
  const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
425
426
  if (l1 !== "OBVIOUS_NOT_RESERVED") {
426
- debugLog(`[prism_infer] Layer 1 verdict=${l1} — escalating to cloud`);
427
+ debugLog(`[prism_infer] Layer 1 verdict=${l1} — reserved content detected`);
427
428
  attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
428
- const cloudTimeout = args.timeout_ms ?? 90_000;
429
- const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
430
- if (cloud.ok && cloud.output) {
431
- return await applyVerification(cloud.output, gatedArgs, deps, {
432
- backend: cloud.backend ?? "synalux",
433
- model_picked: null,
434
- ram_free_mb: ramFreeMb,
435
- latency_ms: Date.now() - t0,
436
- used_cloud: true,
437
- attempts,
438
- plan: ent.plan,
439
- completion_tokens: Math.ceil(cloud.output.length / 4),
440
- });
429
+ if (allowCloud) {
430
+ const cloudTimeout = args.timeout_ms ?? 90_000;
431
+ const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
432
+ if (cloud.ok && cloud.output) {
433
+ return await applyVerification(cloud.output, gatedArgs, deps, {
434
+ backend: cloud.backend ?? "synalux",
435
+ model_picked: null,
436
+ ram_free_mb: ramFreeMb,
437
+ latency_ms: Date.now() - t0,
438
+ used_cloud: true,
439
+ attempts,
440
+ plan: ent.plan,
441
+ completion_tokens: Math.ceil(cloud.output.length / 4),
442
+ });
443
+ }
444
+ attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
441
445
  }
442
- attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
443
- // Layer 1 flagged RESERVED but cloud unavailable — fail closed, never fall through to local.
444
- throw new Error(`prism_infer: Layer 1 verdict=${l1} but cloud unavailable. attempts=${JSON.stringify(attempts)}`);
446
+ throw new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
445
447
  }
446
448
  debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
447
449
  }
@@ -15,7 +15,7 @@ import { debugLog } from "./logger.js";
15
15
  export const FREE_ENTITLEMENTS = {
16
16
  plan: "free",
17
17
  model_ceiling: "4b",
18
- daily_infer_limit: 50,
18
+ daily_infer_limit: 50, // reserved, not enforced; cloud limits are portal-side
19
19
  max_tokens: 512,
20
20
  max_seats: 1,
21
21
  features: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.0.1",
3
+ "version": "20.0.2",
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",