prism-mcp-server 20.0.1 → 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 +46 -9
- package/dist/boundaries/__tests__/boundaries.test.js +18 -30
- package/dist/boundaries/boundaries.js +18 -38
- package/dist/server.js +2 -1
- package/dist/session/__tests__/sessionContext.test.js +11 -10
- package/dist/storage/sqlite.js +13 -0
- package/dist/tools/__tests__/layer1Integration.test.js +97 -5
- package/dist/tools/__tests__/ledgerHandlers.test.js +4 -3
- package/dist/tools/ledgerHandlers.js +8 -21
- package/dist/tools/prismInferHandler.js +53 -24
- package/dist/utils/entitlements.js +1 -1
- package/dist/utils/layer1.js +65 -41
- package/package.json +1 -1
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.
|
|
@@ -382,9 +398,9 @@ All on-device models are free to run locally via Ollama on every tier. A subscri
|
|
|
382
398
|
|---|---|---|---|---|
|
|
383
399
|
| Seats | 1 | 1 | up to 5 | up to 25 |
|
|
384
400
|
| Local model ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
|
|
385
|
-
|
|
|
386
|
-
| Cloud Coder (Web IDE) | -- |
|
|
387
|
-
| Cloud search | -- |
|
|
401
|
+
| Cloud inference | -- | ✅ | ✅ | ✅ (priority) |
|
|
402
|
+
| Cloud Coder (Web IDE) | -- | ✅ | ✅ | ✅ (priority) |
|
|
403
|
+
| Cloud search | -- | ✅ | ✅ | ✅ |
|
|
388
404
|
| Max output tokens | 512 | 1,024 | 2,048 | 4,096 |
|
|
389
405
|
| Cloud fallback | -- | Claude Opus 4.7 | Claude Opus 4.7 | Priority + Opus 4.7 |
|
|
390
406
|
| Grounding verifier (fact-check AI output) | -- | ✅ | ✅ | ✅ |
|
|
@@ -644,11 +660,32 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
|
|
|
644
660
|
|
|
645
661
|
## License & Tiers
|
|
646
662
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
663
|
+
**This repository (the Prism MCP client)** is licensed under [Apache-2.0](./LICENSE).
|
|
664
|
+
|
|
665
|
+
### Free (no account)
|
|
666
|
+
|
|
667
|
+
| Feature | Details |
|
|
668
|
+
|---------|---------|
|
|
669
|
+
| Local inference | Ollama via `prism_infer`, capped at the 4B model tier |
|
|
670
|
+
| Session memory | Persistent sessions, handoffs, ledger — all local SQLite |
|
|
671
|
+
| Knowledge search | Semantic search across session history |
|
|
672
|
+
| Skills | All skills available locally (run `sync-skills.sh` to populate) |
|
|
673
|
+
| Drift detection | Server-side GATE 5 reminders |
|
|
674
|
+
|
|
675
|
+
### Paid (Synalux subscription)
|
|
676
|
+
|
|
677
|
+
Everything in Free, plus:
|
|
678
|
+
|
|
679
|
+
| Feature | Details |
|
|
680
|
+
|---------|---------|
|
|
681
|
+
| Model ceiling | Up to 27B locally + cloud cascade (9B → 27B → Claude) when local is unavailable |
|
|
682
|
+
| Skill routing | Portal resolves which skills to load based on your project and prompt |
|
|
683
|
+
| Cross-device memory | Supabase cloud sync — sessions survive across machines |
|
|
684
|
+
| Grounding verifier | L3 NLI verification on model outputs |
|
|
685
|
+
| Team features | Multi-agent Hivemind, workspace collaboration |
|
|
686
|
+
|
|
687
|
+
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.
|
|
688
|
+
|
|
652
689
|
- Contributions require signing the [CLA](./CLA.md).
|
|
653
690
|
- "Prism" and "Synalux" are trade names of Synalux LLC; the Apache license does
|
|
654
691
|
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
|
|
4
|
+
* Verifies structural invariants of the safety declaration:
|
|
5
5
|
* - BOUNDARIES_VERSION is present and non-empty
|
|
6
|
-
* - BOUNDARIES_TEXT covers
|
|
7
|
-
* -
|
|
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(
|
|
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("
|
|
27
|
-
expect(BOUNDARIES_TEXT).toMatch(/
|
|
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("
|
|
39
|
-
expect(BOUNDARIES_TEXT).toMatch(/
|
|
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
|
|
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("
|
|
55
|
-
expect(BOUNDARIES_TEXT).
|
|
56
|
-
|
|
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,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/boundaries/boundaries.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Safety declaration text and version tracking.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
19
|
*/
|
|
13
|
-
export const BOUNDARIES_VERSION = "
|
|
20
|
+
export const BOUNDARIES_VERSION = "3";
|
|
14
21
|
export const BOUNDARIES_TEXT = `
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
22
|
+
Safety boundaries are enforced in code — shown so hosts avoid wasted round-trips.
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- Least restrictive, dignity-preserving, trauma-informed procedures always.
|
|
24
|
+
- **Crisis/self-harm** inputs are intercepted before reaching any model.
|
|
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.
|
|
26
|
+
- **Dangerous output** (restraint instructions, overdose methods, self-harm guidance) is blocked regardless of host.
|
|
29
27
|
- 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
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.`,
|
|
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.
|
|
@@ -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",
|
|
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
|
|
72
|
-
markContextLoaded("conv-idem", "proj",
|
|
73
|
-
markContextLoaded("conv-idem", "proj-updated",
|
|
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(
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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();
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -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,
|
|
@@ -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", () => {
|
|
@@ -75,6 +138,7 @@ function makeBaseDeps(overrides = {}) {
|
|
|
75
138
|
listLoaded: async () => new Set(),
|
|
76
139
|
callLocal: async () => ({ ok: true, text: "local response", doneReason: "stop" }),
|
|
77
140
|
callCloud: async () => ({ ok: true, output: "cloud response", backend: "synalux" }),
|
|
141
|
+
callLayer1: async () => "OBVIOUS_NOT_RESERVED",
|
|
78
142
|
ollamaUrl: "http://localhost:11434",
|
|
79
143
|
entitlements: {
|
|
80
144
|
plan: "pro",
|
|
@@ -120,7 +184,7 @@ describe("Layer 1 handler integration", () => {
|
|
|
120
184
|
expect(callLocal).not.toHaveBeenCalled();
|
|
121
185
|
expect(result.used_cloud).toBe(true);
|
|
122
186
|
});
|
|
123
|
-
it("ERROR → escalates to cloud
|
|
187
|
+
it("ERROR + cloud available → escalates to cloud", async () => {
|
|
124
188
|
const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response" });
|
|
125
189
|
const callCloud = vi.fn().mockResolvedValue({ ok: true, output: "cloud response", backend: "synalux" });
|
|
126
190
|
const callLayer1Mock = vi.fn().mockResolvedValue("ERROR");
|
|
@@ -129,6 +193,27 @@ describe("Layer 1 handler integration", () => {
|
|
|
129
193
|
expect(callLocal).not.toHaveBeenCalled();
|
|
130
194
|
expect(result.used_cloud).toBe(true);
|
|
131
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
|
+
});
|
|
132
217
|
it("OBVIOUS_NOT_RESERVED → proceeds to local tier", async () => {
|
|
133
218
|
const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
|
|
134
219
|
const callCloud = vi.fn();
|
|
@@ -138,11 +223,18 @@ describe("Layer 1 handler integration", () => {
|
|
|
138
223
|
expect(callLocal).toHaveBeenCalled();
|
|
139
224
|
expect(result.used_cloud).toBe(false);
|
|
140
225
|
});
|
|
141
|
-
it("cloud_fallback=false →
|
|
226
|
+
it("cloud_fallback=false + RESERVED → refuses (fail-closed, no local fallback)", async () => {
|
|
142
227
|
const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
|
|
143
228
|
const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
|
|
144
|
-
|
|
145
|
-
expect(callLayer1Mock).
|
|
229
|
+
await expect(runInfer({ prompt: "write something", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }))).rejects.toThrow(/content refused/i);
|
|
230
|
+
expect(callLayer1Mock).toHaveBeenCalled();
|
|
231
|
+
expect(callLocal).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
it("cloud_fallback=false + NOT_RESERVED → proceeds to local", async () => {
|
|
234
|
+
const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_NOT_RESERVED");
|
|
235
|
+
const callLocal = vi.fn().mockResolvedValue({ ok: true, text: "local response", doneReason: "stop" });
|
|
236
|
+
const result = await runInfer({ prompt: "what is 2+2?", mode: "code", cloud_fallback: false, max_tokens: 512 }, makeBaseDeps({ callLayer1: callLayer1Mock, callLocal }));
|
|
237
|
+
expect(callLayer1Mock).toHaveBeenCalled();
|
|
146
238
|
expect(result.used_cloud).toBe(false);
|
|
147
239
|
});
|
|
148
240
|
it("recursion guard: mode=route + max_tokens<=16 skips Layer 1", async () => {
|
|
@@ -531,12 +531,13 @@ describe("ledgerHandlers", () => {
|
|
|
531
531
|
await sessionLoadContextHandler({ project: "test-project" });
|
|
532
532
|
expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
|
|
533
533
|
});
|
|
534
|
-
it("
|
|
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("
|
|
539
|
-
expect(text).toContain("BOUNDARIES STUB");
|
|
538
|
+
expect(text).not.toContain("[Safety Boundaries");
|
|
539
|
+
expect(text).not.toContain("BOUNDARIES STUB");
|
|
540
|
+
expect(text).toContain("no previous session history");
|
|
540
541
|
});
|
|
541
542
|
});
|
|
542
543
|
// ====================================================================
|
|
@@ -669,16 +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 = `# 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`;
|
|
677
672
|
return {
|
|
678
673
|
content: [{
|
|
679
674
|
type: "text",
|
|
680
|
-
text:
|
|
681
|
-
`No session context found for project "${project}" at level ${level}.\n` +
|
|
675
|
+
text: `No session context found for project "${project}" at level ${level}.\n` +
|
|
682
676
|
`This project has no previous session history. Starting fresh.` +
|
|
683
677
|
freshSkillBlock,
|
|
684
678
|
}],
|
|
@@ -944,7 +938,10 @@ export async function sessionLoadContextHandler(args) {
|
|
|
944
938
|
skillLoaded = true;
|
|
945
939
|
}
|
|
946
940
|
}
|
|
947
|
-
// Offline fallback: load
|
|
941
|
+
// Offline fallback: load ALL local skill: content (no tier gating).
|
|
942
|
+
// Deliberate: offline = degraded = best-effort. A repo-holder with
|
|
943
|
+
// sync-skills.sh has the full library locally regardless of tier.
|
|
944
|
+
// Tier gating is portal-side (name resolution); offline bypasses it.
|
|
948
945
|
if (skillResolution.isOffline) {
|
|
949
946
|
const allSettings = await storage.getAllSettings?.() || {};
|
|
950
947
|
for (const [k, v] of Object.entries(allSettings)) {
|
|
@@ -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
|
}
|
|
@@ -1073,13 +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 = `# 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`;
|
|
1081
1073
|
return {
|
|
1082
|
-
content: [{ type: "text", text:
|
|
1074
|
+
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1083
1075
|
isError: false,
|
|
1084
1076
|
};
|
|
1085
1077
|
}
|
|
@@ -1090,13 +1082,8 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1090
1082
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
1091
1083
|
noteDriftSessionStart(convId);
|
|
1092
1084
|
}
|
|
1093
|
-
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`;
|
|
1098
1085
|
return {
|
|
1099
|
-
content: [{ type: "text", text:
|
|
1086
|
+
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1100
1087
|
isError: false,
|
|
1101
1088
|
};
|
|
1102
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 = {
|
|
@@ -412,36 +412,65 @@ 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
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
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 (
|
|
422
|
+
if (installed && !layer1RecursionGuard) {
|
|
422
423
|
const l1fn = deps.callLayer1 ?? defaultCallLayer1;
|
|
423
|
-
const l1Model = resolveOllamaName("prism-coder:4b", installed
|
|
424
|
+
const l1Model = resolveOllamaName("prism-coder:4b", installed);
|
|
424
425
|
const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
|
|
425
|
-
if (l1
|
|
426
|
-
debugLog(`[prism_infer] Layer 1 verdict=${l1} —
|
|
426
|
+
if (l1 === "OBVIOUS_RESERVED" || l1 === "UNCERTAIN") {
|
|
427
|
+
debugLog(`[prism_infer] Layer 1 verdict=${l1} — reserved content detected`);
|
|
427
428
|
attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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" });
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
|
|
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)}`);
|
|
441
473
|
}
|
|
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)}`);
|
|
445
474
|
}
|
|
446
475
|
debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
|
|
447
476
|
}
|
|
@@ -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/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.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",
|