prism-mcp-server 20.0.8 → 20.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/boundaries/boundaries.js +2 -2
- package/dist/tools/__tests__/layer1Integration.test.js +97 -5
- package/dist/tools/prismInferHandler.js +128 -12
- package/dist/utils/entitlements.js +38 -10
- package/dist/utils/inferenceMetrics.js +19 -1
- package/dist/utils/layer1.js +57 -10
- package/dist/utils/modelPicker.js +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,22 @@ 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.1.0
|
|
22
|
+
|
|
23
|
+
### Every Inference Outcome Is Now Observable
|
|
24
|
+
`prism_infer` gains a failure contract: pass `escalation: "report"` and every call returns a structured `gate_outcome` — `success`, `degraded` (gate-failed output served anyway, explicitly flagged), or `refused` (typed, with reason, instead of a thrown error). Degraded output can no longer serve silently.
|
|
25
|
+
|
|
26
|
+
### Big Prompts Work Locally
|
|
27
|
+
Prompts over 4000 chars were blanket-refused when cloud was off. Now the full text gets a deterministic reserved-keyword scan plus a head+middle+tail excerpt classification — clean oversize prompts serve locally with a distinct `UNCERTAIN_LENGTH` audit marker. Clinical/reserved handling is unchanged (and its keyword floor got stronger).
|
|
28
|
+
|
|
29
|
+
### No More Silent Truncation
|
|
30
|
+
Tier context limits now match the live Modelfiles (27b/9b are 4096-token models; 4b/2b are 32768 — the old table had it backwards). Tiers that can't hold your prompt are skipped with a visible `ctx_insufficient` reason; if nothing fits, you get the full prompt on cloud or a loud error — never an answer computed from a silently-clipped prompt.
|
|
31
|
+
|
|
32
|
+
### Know Which Plan You're Actually Running Under
|
|
33
|
+
Entitlements carry a `source` field: `portal` (real), `unconfigured` (free by design), or `fallback_free` (portal unreachable — free limits ASSUMED). Pass `strict_entitlements: true` to fail loud instead of running degraded.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
21
37
|
## What's New in v20.0.8
|
|
22
38
|
|
|
23
39
|
### verify_behavior Works Again
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
* BOUNDARIES_VERSION is still used by markContextLoaded for session
|
|
18
18
|
* drift detection — bump it when the safety contract changes.
|
|
19
19
|
*/
|
|
20
|
-
export const BOUNDARIES_VERSION = "
|
|
20
|
+
export const BOUNDARIES_VERSION = "4";
|
|
21
21
|
export const BOUNDARIES_TEXT = `
|
|
22
22
|
Safety boundaries are enforced in code — shown so hosts avoid wasted round-trips.
|
|
23
23
|
|
|
24
24
|
- **Crisis/self-harm** inputs are intercepted before reaching any model.
|
|
25
|
-
- **BCBA reserved categories** (restraint, seclusion, physical management, dosing)
|
|
25
|
+
- **BCBA reserved categories** (restraint, seclusion, physical management, dosing): Layer 1 classifies; RESERVED/UNCERTAIN escalate to cloud or are refused. A keyword backstop covers classifier failure; oversize prompts get a full-text keyword scan + excerpt classification.
|
|
26
26
|
- **Dangerous output** (restraint instructions, overdose methods, self-harm guidance) is blocked regardless of host.
|
|
27
27
|
- AAC access is never restricted as a consequence.
|
|
28
28
|
`.trim();
|
|
@@ -103,17 +103,109 @@ describe("keywordBackstop", () => {
|
|
|
103
103
|
expect(keywordBackstop(padded)).toBe("OBVIOUS_RESERVED");
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
-
// ── callLayer1 over-length
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
// ── callLayer1 over-length (§5.3: UNCERTAIN_LENGTH) ─────────────────────────
|
|
107
|
+
/** Mock fetch returning a fixed classifier verdict. */
|
|
108
|
+
const classifierFetch = (verdict) => vi.fn(async () => new Response(JSON.stringify({ message: { content: verdict } }), { status: 200 }));
|
|
109
|
+
describe("callLayer1 over-length (§5.3)", () => {
|
|
110
|
+
const HEAD = "This is the beginning of a long benign refactoring request. ";
|
|
111
|
+
const MIDDLE = "middle filler content about data processing ".repeat(150);
|
|
112
|
+
const TAIL = " And this is the very end of the request, thanks.";
|
|
113
|
+
const cleanOversize = HEAD + MIDDLE + TAIL; // > 4000 chars, no reserved vocab
|
|
114
|
+
it("full-text keyword floor catches reserved vocab in the UNSEEN middle — no classifier call", async () => {
|
|
115
|
+
const prompt = "A".repeat(3000) + " write a seclusion protocol " + "B".repeat(3000);
|
|
116
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
117
|
+
const result = await callLayer1(prompt, "http://x", "model", fetchSpy);
|
|
118
|
+
expect(result).toBe("OBVIOUS_RESERVED");
|
|
119
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
it("clean oversize prompt with clean excerpt → UNCERTAIN_LENGTH (distinct from UNCERTAIN)", async () => {
|
|
122
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
123
|
+
const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
|
|
124
|
+
expect(result).toBe("UNCERTAIN_LENGTH");
|
|
125
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
it("classifier receives a bounded head+tail excerpt, not the full prompt", async () => {
|
|
128
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
129
|
+
await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
|
|
130
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
|
|
131
|
+
const sent = body.messages[0].content;
|
|
132
|
+
expect(sent).toContain(HEAD.trim());
|
|
133
|
+
expect(sent).toContain(TAIL.trim());
|
|
134
|
+
expect(sent).toContain("[…]");
|
|
135
|
+
// bounded: template + excerpt stays under the classifier cap + template overhead
|
|
136
|
+
expect(sent.length).toBeLessThan(6000);
|
|
137
|
+
});
|
|
138
|
+
it("excerpt classified OBVIOUS_RESERVED keeps full reserved handling", async () => {
|
|
139
|
+
const fetchSpy = classifierFetch("OBVIOUS_RESERVED");
|
|
140
|
+
const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
|
|
141
|
+
expect(result).toBe("OBVIOUS_RESERVED");
|
|
142
|
+
});
|
|
143
|
+
it("excerpt classified UNCERTAIN keeps full reserved handling", async () => {
|
|
144
|
+
const fetchSpy = classifierFetch("UNCERTAIN");
|
|
145
|
+
const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
|
|
111
146
|
expect(result).toBe("UNCERTAIN");
|
|
112
147
|
});
|
|
148
|
+
it("classifier failure on oversize → UNCERTAIN_LENGTH (keyword floor already cleared full text)", async () => {
|
|
149
|
+
const failingFetch = vi.fn(async () => { throw new Error("connection refused"); });
|
|
150
|
+
const result = await callLayer1(cleanOversize, "http://x", "model", failingFetch);
|
|
151
|
+
expect(result).toBe("UNCERTAIN_LENGTH");
|
|
152
|
+
expect(failingFetch).toHaveBeenCalledTimes(2); // initial + retry
|
|
153
|
+
});
|
|
154
|
+
it("normal-size prompts are unaffected — full prompt sent, verdict passthrough", async () => {
|
|
155
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
156
|
+
const result = await callLayer1("short benign prompt", "http://x", "model", fetchSpy);
|
|
157
|
+
expect(result).toBe("OBVIOUS_NOT_RESERVED");
|
|
158
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
|
|
159
|
+
expect(body.messages[0].content).toContain("short benign prompt");
|
|
160
|
+
expect(body.messages[0].content).not.toContain("[…]");
|
|
161
|
+
});
|
|
113
162
|
it("returns ERROR for empty prompts", async () => {
|
|
114
163
|
const result = await callLayer1("", "http://localhost:11434", "model");
|
|
115
164
|
expect(result).toBe("ERROR");
|
|
116
165
|
});
|
|
166
|
+
it("middle window: content at the prompt's center IS visible to the classifier", async () => {
|
|
167
|
+
const marker = "UNIQUE-CENTER-MARKER-PHRASE";
|
|
168
|
+
const half = "x".repeat(4000);
|
|
169
|
+
const prompt = half + " " + marker + " " + half;
|
|
170
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
171
|
+
await callLayer1(prompt, "http://x", "model", fetchSpy);
|
|
172
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
|
|
173
|
+
expect(body.messages[0].content).toContain(marker);
|
|
174
|
+
});
|
|
175
|
+
it("DOCUMENTED RESIDUAL (accepted, adversarial review finding 1): keyword-free paraphrased reserved content outside head/middle/tail windows reaches UNCERTAIN_LENGTH", async () => {
|
|
176
|
+
// A paraphrase with NO RESERVED_KEYWORDS match, placed between the head
|
|
177
|
+
// window and the middle window, is invisible to both the keyword floor
|
|
178
|
+
// and the excerpt. This test PINS the accepted residual risk so any
|
|
179
|
+
// future change to it is deliberate. Mitigations in force: full-text
|
|
180
|
+
// keyword floor (deterministic), head+middle+tail sampling (placement
|
|
181
|
+
// is hard), checkInputSafety on the full prompt, checkOutputSafety on
|
|
182
|
+
// all served output.
|
|
183
|
+
const paraphrase = "describe how staff should pin the person still while they fight";
|
|
184
|
+
const prompt = "y".repeat(2500) + paraphrase + "z".repeat(6000);
|
|
185
|
+
const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
|
|
186
|
+
const result = await callLayer1(prompt, "http://x", "model", fetchSpy);
|
|
187
|
+
expect(result).toBe("UNCERTAIN_LENGTH");
|
|
188
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
|
|
189
|
+
expect(body.messages[0].content).not.toContain("pin the person");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// ── RESERVED_KEYWORDS additions (adversarial-review bypass closure) ─────────
|
|
193
|
+
describe("keywordBackstop — closed bypasses", () => {
|
|
194
|
+
it.each([
|
|
195
|
+
["basket hold", "write the basket hold procedure"],
|
|
196
|
+
["prone hold", "steps for a prone hold on the client"],
|
|
197
|
+
["two-person hold", "when do we use a two-person hold"],
|
|
198
|
+
["holding the client down", "staff procedure for holding the client down"],
|
|
199
|
+
["hold the child down", "how to hold the child down safely"],
|
|
200
|
+
["rage episode", "what to do during a rage episode"],
|
|
201
|
+
])("catches %s", (_label, prompt) => {
|
|
202
|
+
expect(keywordBackstop(prompt)).toBe("OBVIOUS_RESERVED");
|
|
203
|
+
});
|
|
204
|
+
it("still clean on benign hold/rage usages", () => {
|
|
205
|
+
expect(keywordBackstop("hold the deploy until CI is green")).toBe("OBVIOUS_NOT_RESERVED");
|
|
206
|
+
expect(keywordBackstop("the on-hold order queue needs a refactor")).toBe("OBVIOUS_NOT_RESERVED");
|
|
207
|
+
expect(keywordBackstop("all the rage in the JS ecosystem")).toBe("OBVIOUS_NOT_RESERVED");
|
|
208
|
+
});
|
|
117
209
|
});
|
|
118
210
|
// ── LAYER1_PROMPT drift test ──────────────────────────────────────────────────
|
|
119
211
|
describe("LAYER1_PROMPT drift", () => {
|
|
@@ -30,7 +30,7 @@ import { stripThink } from "../utils/thinkStrip.js";
|
|
|
30
30
|
import { passesQualityGate } from "../utils/qualityGate.js";
|
|
31
31
|
import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
|
|
32
32
|
import { callLayer1 as defaultCallLayer1, keywordBackstop } from "../utils/layer1.js";
|
|
33
|
-
import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
|
|
33
|
+
import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics, estimateTokens } from "../utils/inferenceMetrics.js";
|
|
34
34
|
import { appendInferMetric } from "../storage/inferMetricsLedger.js";
|
|
35
35
|
// ─── Tool Definition ────────────────────────────────────────────
|
|
36
36
|
export const PRISM_INFER_TOOL = {
|
|
@@ -122,6 +122,26 @@ export const PRISM_INFER_TOOL = {
|
|
|
122
122
|
description: "Enable thinking mode (<think> blocks). Default: true for chat/code, false for route. " +
|
|
123
123
|
"Thinking improves quality on complex tasks but adds latency (~2-5s).",
|
|
124
124
|
},
|
|
125
|
+
strict_entitlements: {
|
|
126
|
+
type: "boolean",
|
|
127
|
+
description: "Fail loud instead of running with ASSUMED free-tier limits (plan v2 §5.5). " +
|
|
128
|
+
"When true and entitlement resolution fell back to free because the portal " +
|
|
129
|
+
"was unreachable (source='fallback_free'), the call throws instead of " +
|
|
130
|
+
"silently applying free clamps. Portal-confirmed free plans and " +
|
|
131
|
+
"unconfigured machines are unaffected. Default: false.",
|
|
132
|
+
default: false,
|
|
133
|
+
},
|
|
134
|
+
escalation: {
|
|
135
|
+
type: "string",
|
|
136
|
+
enum: ["serve", "report"],
|
|
137
|
+
description: "Failure contract (plan v2 §5.2). 'serve' (default) keeps legacy behavior: " +
|
|
138
|
+
"safety refusals throw, gate-failed output may be served. 'report' returns a " +
|
|
139
|
+
"structured gate_outcome on every terminal path — refused results come back as " +
|
|
140
|
+
"{status:'refused', output:''} instead of an error, and degraded (gate-failed, " +
|
|
141
|
+
"served-anyway) output is explicitly flagged so callers can distinguish " +
|
|
142
|
+
"success / degraded / refused.",
|
|
143
|
+
default: "serve",
|
|
144
|
+
},
|
|
125
145
|
},
|
|
126
146
|
required: ["prompt"],
|
|
127
147
|
},
|
|
@@ -158,6 +178,11 @@ export function isPrismInferArgs(args) {
|
|
|
158
178
|
return false;
|
|
159
179
|
if (a.verifier_timeout_ms !== undefined && typeof a.verifier_timeout_ms !== "number")
|
|
160
180
|
return false;
|
|
181
|
+
if (a.escalation !== undefined &&
|
|
182
|
+
!["serve", "report"].includes(a.escalation))
|
|
183
|
+
return false;
|
|
184
|
+
if (a.strict_entitlements !== undefined && typeof a.strict_entitlements !== "boolean")
|
|
185
|
+
return false;
|
|
161
186
|
if (a.evidence !== undefined) {
|
|
162
187
|
if (!Array.isArray(a.evidence))
|
|
163
188
|
return false;
|
|
@@ -278,9 +303,11 @@ export class ReservedRefusalError extends Error {
|
|
|
278
303
|
}
|
|
279
304
|
function makeReservedRefusal(verdict, attempts) {
|
|
280
305
|
// Ledger the refusal (fire-and-forget). No prompt content is persisted —
|
|
281
|
-
// same HIPAA posture as the safety_gate exclusion.
|
|
306
|
+
// same HIPAA posture as the safety_gate exclusion. gate_outcome mirrors
|
|
307
|
+
// the §5.2 report-mode row so refusal queries see both modes.
|
|
282
308
|
appendInferMetric({
|
|
283
309
|
backend: "refused", model: null, used_cloud: false,
|
|
310
|
+
gate_outcome: "refused",
|
|
284
311
|
refusal_reason: "layer1_reserved",
|
|
285
312
|
});
|
|
286
313
|
return new ReservedRefusalError(verdict, attempts);
|
|
@@ -396,9 +423,20 @@ export async function runInfer(args, deps) {
|
|
|
396
423
|
};
|
|
397
424
|
}
|
|
398
425
|
// ── Entitlement enforcement ──────────────────────────────────
|
|
399
|
-
//
|
|
400
|
-
// get 4b ceiling, 50 calls/day, 512 max tokens.
|
|
426
|
+
// Resolved per call (§5.5) — getEntitlements dedupes via a 5-min cache.
|
|
427
|
+
// Free users without auth get 4b ceiling, 50 calls/day, 512 max tokens.
|
|
401
428
|
const ent = deps.entitlements ?? await getEntitlements();
|
|
429
|
+
const entSource = ent.source ?? "portal";
|
|
430
|
+
// §5.5 fail-loud: "fallback_free" means auth IS configured but the
|
|
431
|
+
// portal couldn't be reached and no cached plan exists — the free-tier
|
|
432
|
+
// clamps below would be an ASSUMPTION, not the user's plan. Strict
|
|
433
|
+
// callers refuse to run on assumptions. This is an infrastructure
|
|
434
|
+
// failure, not a safety refusal — it throws in both escalation modes.
|
|
435
|
+
if (args.strict_entitlements && entSource === "fallback_free") {
|
|
436
|
+
throw new Error("prism_infer: entitlements_unavailable — portal resolution failed (source=fallback_free) " +
|
|
437
|
+
"and strict_entitlements=true; refusing to run with assumed free-tier limits. " +
|
|
438
|
+
"Retry, or drop strict_entitlements to accept free clamps.");
|
|
439
|
+
}
|
|
402
440
|
// MF2: In chat/code modes, request the 27B tier (subject to plan ceiling + RAM).
|
|
403
441
|
// mode:"code" implies quality → start higher in the cascade.
|
|
404
442
|
const mode = args.mode ?? "route";
|
|
@@ -415,6 +453,25 @@ export async function runInfer(args, deps) {
|
|
|
415
453
|
const attempts = [];
|
|
416
454
|
// Strip verification args if plan lacks grounding_verifier
|
|
417
455
|
const gatedArgs = canVerify ? args : { ...args, verify: false, evidence: undefined };
|
|
456
|
+
// §5.2 failure contract: under escalation:"report", safety refusals return
|
|
457
|
+
// a typed result (output:"") instead of throwing. Infra exhaustion (no
|
|
458
|
+
// backend produced output) still throws in BOTH modes — an infrastructure
|
|
459
|
+
// failure is not a refusal (§5.1 distinction).
|
|
460
|
+
const wantReport = args.escalation === "report";
|
|
461
|
+
// Shared per-result entitlement metadata (§5.5) — spread into every
|
|
462
|
+
// terminal result so callers can audit which plan/provenance applied.
|
|
463
|
+
const entMeta = { plan: ent.plan, entitlements_source: entSource };
|
|
464
|
+
const refusedResult = (reason) => ({
|
|
465
|
+
output: "",
|
|
466
|
+
backend: "refused",
|
|
467
|
+
model_picked: null,
|
|
468
|
+
ram_free_mb: ramFreeMb,
|
|
469
|
+
latency_ms: Date.now() - t0,
|
|
470
|
+
used_cloud: false,
|
|
471
|
+
attempts,
|
|
472
|
+
...entMeta,
|
|
473
|
+
gate_outcome: { status: "refused", reason, served_anyway: false },
|
|
474
|
+
});
|
|
418
475
|
debugLog(`[prism_infer] plan=${ent.plan} ceiling=${effectiveCeiling} max_tokens=${maxTokens} cloud=${allowCloud} verify=${canVerify}`);
|
|
419
476
|
// Log tier enforcement to Datadog for monetization visibility
|
|
420
477
|
const ceilingClamped = effectiveCeiling !== (args.model_ceiling ?? ent.model_ceiling);
|
|
@@ -423,7 +480,7 @@ export async function runInfer(args, deps) {
|
|
|
423
480
|
const verifierBlocked = (args.verify === true || (args.evidence?.length ?? 0) > 0) && !canVerify;
|
|
424
481
|
if (ceilingClamped || tokensClamped || cloudBlocked || verifierBlocked) {
|
|
425
482
|
ddLog("info", "prism_infer.tier_enforcement", {
|
|
426
|
-
|
|
483
|
+
...entMeta,
|
|
427
484
|
requested_ceiling: args.model_ceiling,
|
|
428
485
|
effective_ceiling: effectiveCeiling,
|
|
429
486
|
ceiling_clamped: ceilingClamped,
|
|
@@ -473,6 +530,8 @@ export async function runInfer(args, deps) {
|
|
|
473
530
|
const weakBackend = /^(ollama-|openrouter-)/.test(cloud.backend ?? "");
|
|
474
531
|
if (weakBackend) {
|
|
475
532
|
attempts.push({ tier: "synalux", reason: `reserved_weak_backend:${cloud.backend}` });
|
|
533
|
+
if (wantReport)
|
|
534
|
+
return refusedResult("layer1_reserved");
|
|
476
535
|
throw makeReservedRefusal(l1, attempts);
|
|
477
536
|
}
|
|
478
537
|
return await applyVerification(cloud.output, gatedArgs, deps, {
|
|
@@ -482,14 +541,27 @@ export async function runInfer(args, deps) {
|
|
|
482
541
|
latency_ms: Date.now() - t0,
|
|
483
542
|
used_cloud: true,
|
|
484
543
|
attempts,
|
|
485
|
-
|
|
544
|
+
...entMeta,
|
|
486
545
|
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
546
|
+
gate_outcome: { status: "success", served_anyway: false },
|
|
487
547
|
});
|
|
488
548
|
}
|
|
489
549
|
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
490
550
|
}
|
|
551
|
+
if (wantReport)
|
|
552
|
+
return refusedResult("layer1_reserved");
|
|
491
553
|
throw makeReservedRefusal(l1, attempts);
|
|
492
554
|
}
|
|
555
|
+
if (l1 === "UNCERTAIN_LENGTH") {
|
|
556
|
+
// §5.3: prompt too long to classify in full, but the full-text
|
|
557
|
+
// keyword floor was clean AND the head+tail excerpt classified
|
|
558
|
+
// clean. Proceed to the local tier walk with a distinct audit
|
|
559
|
+
// marker — "too long to classify" is not a safety verdict.
|
|
560
|
+
// Whether the prompt FITS a local tier's context is the §5.4
|
|
561
|
+
// ctx gate's job, not Layer 1's.
|
|
562
|
+
debugLog(`[prism_infer] Layer 1 verdict=UNCERTAIN_LENGTH — oversize prompt cleared by keyword floor + excerpt, proceeding local`);
|
|
563
|
+
attempts.push({ tier: "layer1", reason: "layer1_uncertain_length" });
|
|
564
|
+
}
|
|
493
565
|
if (l1 === "ERROR") {
|
|
494
566
|
debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
|
|
495
567
|
attempts.push({ tier: "layer1", reason: "layer1_error" });
|
|
@@ -504,8 +576,9 @@ export async function runInfer(args, deps) {
|
|
|
504
576
|
latency_ms: Date.now() - t0,
|
|
505
577
|
used_cloud: true,
|
|
506
578
|
attempts,
|
|
507
|
-
|
|
579
|
+
...entMeta,
|
|
508
580
|
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
581
|
+
gate_outcome: { status: "success", served_anyway: false },
|
|
509
582
|
});
|
|
510
583
|
}
|
|
511
584
|
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
@@ -514,10 +587,21 @@ export async function runInfer(args, deps) {
|
|
|
514
587
|
debugLog(`[prism_infer] keyword backstop verdict=${backstop}`);
|
|
515
588
|
attempts.push({ tier: "keyword_backstop", reason: `backstop_${backstop.toLowerCase()}` });
|
|
516
589
|
if (backstop === "OBVIOUS_RESERVED") {
|
|
590
|
+
if (wantReport)
|
|
591
|
+
return refusedResult("keyword_backstop_reserved");
|
|
592
|
+
// Serve-mode backstop refusal previously wrote NO ledger row —
|
|
593
|
+
// ledger it like every other refusal (no prompt content persisted).
|
|
594
|
+
appendInferMetric({
|
|
595
|
+
backend: "refused", model: null, used_cloud: false,
|
|
596
|
+
gate_outcome: "refused",
|
|
597
|
+
refusal_reason: "keyword_backstop_reserved",
|
|
598
|
+
});
|
|
517
599
|
throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
|
|
518
600
|
}
|
|
519
601
|
}
|
|
520
|
-
|
|
602
|
+
if (l1 === "OBVIOUS_NOT_RESERVED") {
|
|
603
|
+
debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
|
|
604
|
+
}
|
|
521
605
|
}
|
|
522
606
|
// ── end Layer 1 ─────────────────────────────────────────────────────────
|
|
523
607
|
// Walk the tier table top → bottom, capped by model_ceiling. Each tier
|
|
@@ -603,6 +687,23 @@ export async function runInfer(args, deps) {
|
|
|
603
687
|
attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
|
|
604
688
|
continue;
|
|
605
689
|
}
|
|
690
|
+
// Ctx gate (§5.4): skip tiers whose live Modelfile num_ctx cannot
|
|
691
|
+
// hold the PROMPT (+ system + small template margin). Ollama
|
|
692
|
+
// silently truncates an over-ctx prompt and answers from the
|
|
693
|
+
// fragment — "never silent truncation" (plan §7). Generated
|
|
694
|
+
// tokens shift the window rather than truncate the prompt, so
|
|
695
|
+
// max_tokens is deliberately NOT reserved here — requiring
|
|
696
|
+
// prompt+output ≤ ctx would make a max_tokens=4096 request
|
|
697
|
+
// unroutable to the 4096-ctx tiers even for tiny prompts.
|
|
698
|
+
// ctxTokens mirrors the live Modelfile values (see MODEL_TIERS).
|
|
699
|
+
const CTX_TEMPLATE_MARGIN = 64;
|
|
700
|
+
const promptTokensEst = estimateTokens(args.prompt)
|
|
701
|
+
+ (args.system ? estimateTokens(args.system) : 0)
|
|
702
|
+
+ CTX_TEMPLATE_MARGIN;
|
|
703
|
+
if (promptTokensEst > tier.ctxTokens) {
|
|
704
|
+
attempts.push({ tier: tier.tag, reason: "ctx_insufficient" });
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
606
707
|
anyViable = true;
|
|
607
708
|
const timeout = args.timeout_ms ?? DEFAULT_TIMEOUTS[tier.tag] ?? 60_000;
|
|
608
709
|
const enableThink = args.think ?? (mode !== "route");
|
|
@@ -624,11 +725,14 @@ export async function runInfer(args, deps) {
|
|
|
624
725
|
debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
|
|
625
726
|
attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
|
|
626
727
|
if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
|
|
627
|
-
localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
|
|
728
|
+
localDraft = { output, tier: tier.tag, gateReason: gate.reason, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
|
|
628
729
|
}
|
|
629
730
|
break;
|
|
630
731
|
}
|
|
631
732
|
if (!gate.pass) {
|
|
733
|
+
// §5.2: this served-anyway path used to be silent — the result
|
|
734
|
+
// carried no flag at all. Now both quality_gate_failed and
|
|
735
|
+
// gate_outcome mark it degraded.
|
|
632
736
|
debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
|
|
633
737
|
}
|
|
634
738
|
return await applyVerification(output, gatedArgs, deps, {
|
|
@@ -638,9 +742,13 @@ export async function runInfer(args, deps) {
|
|
|
638
742
|
latency_ms: Date.now() - t0,
|
|
639
743
|
used_cloud: false,
|
|
640
744
|
attempts,
|
|
641
|
-
|
|
745
|
+
...entMeta,
|
|
642
746
|
prompt_tokens: result.promptTokens,
|
|
643
747
|
completion_tokens: result.completionTokens,
|
|
748
|
+
quality_gate_failed: gate.pass ? undefined : true,
|
|
749
|
+
gate_outcome: gate.pass
|
|
750
|
+
? { status: "success", served_anyway: false }
|
|
751
|
+
: { status: "degraded", reason: gate.reason, served_anyway: true },
|
|
644
752
|
});
|
|
645
753
|
}
|
|
646
754
|
attempts.push({ tier: tier.tag, reason: result.reason });
|
|
@@ -663,11 +771,12 @@ export async function runInfer(args, deps) {
|
|
|
663
771
|
latency_ms: Date.now() - t0,
|
|
664
772
|
used_cloud: true,
|
|
665
773
|
attempts,
|
|
666
|
-
|
|
774
|
+
...entMeta,
|
|
667
775
|
// T4: omit prompt_tokens — cloud doesn't return Ollama actual eval count.
|
|
668
776
|
// recordInference receives prompt_text and computes submittedEst via
|
|
669
777
|
// estimateTokens(), keeping promptTokensEvaluated=0 (correct for cloud).
|
|
670
778
|
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
779
|
+
gate_outcome: { status: "success", served_anyway: false },
|
|
671
780
|
});
|
|
672
781
|
}
|
|
673
782
|
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
@@ -685,10 +794,11 @@ export async function runInfer(args, deps) {
|
|
|
685
794
|
latency_ms: Date.now() - t0,
|
|
686
795
|
used_cloud: false,
|
|
687
796
|
attempts,
|
|
688
|
-
|
|
797
|
+
...entMeta,
|
|
689
798
|
prompt_tokens: localDraft.promptTokens,
|
|
690
799
|
completion_tokens: localDraft.completionTokens,
|
|
691
800
|
quality_gate_failed: true,
|
|
801
|
+
gate_outcome: { status: "degraded", reason: localDraft.gateReason, served_anyway: true },
|
|
692
802
|
});
|
|
693
803
|
}
|
|
694
804
|
const err = new Error(`prism_infer: no backend produced output. attempts=${JSON.stringify(attempts)}, free=${fmtGb(freeBytes)}`);
|
|
@@ -785,6 +895,12 @@ export async function prismInferHandler(args) {
|
|
|
785
895
|
` used_cloud=${result.used_cloud}` +
|
|
786
896
|
tokenStr +
|
|
787
897
|
(result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
|
|
898
|
+
(result.gate_outcome && result.gate_outcome.status !== "success"
|
|
899
|
+
? ` gate=${result.gate_outcome.status}${result.gate_outcome.reason ? `:${result.gate_outcome.reason}` : ""}`
|
|
900
|
+
: "") +
|
|
901
|
+
(result.entitlements_source && result.entitlements_source !== "portal"
|
|
902
|
+
? ` ent_source=${result.entitlements_source}`
|
|
903
|
+
: "") +
|
|
788
904
|
(result.verification ? ` verify=${result.verification.action}` : "") +
|
|
789
905
|
(result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
|
|
790
906
|
// Append periodic session-level stats to the header line.
|
|
@@ -31,6 +31,13 @@ export const FREE_ENTITLEMENTS = {
|
|
|
31
31
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
32
32
|
let cache = null;
|
|
33
33
|
let inFlight = null;
|
|
34
|
+
// §5.5 negative cache: fallback_free is kept OUT of the main 5-min cache
|
|
35
|
+
// (recovery must not wait a full TTL), but an un-cached failure would make
|
|
36
|
+
// every sequential call during an outage pay the full JWT+fetch attempt
|
|
37
|
+
// (worst case ~10s on a hanging network). A short negative TTL caps that
|
|
38
|
+
// amplification while still retrying promptly.
|
|
39
|
+
const FALLBACK_NEGATIVE_TTL_MS = 25_000;
|
|
40
|
+
let negativeCache = null;
|
|
34
41
|
// ── Model tier ordering for ceiling enforcement ───────────────────
|
|
35
42
|
const TIER_ORDER = ["2b", "4b", "9b", "27b"];
|
|
36
43
|
/**
|
|
@@ -63,12 +70,12 @@ export function clampCeiling(requested, planCeiling) {
|
|
|
63
70
|
async function fetchEntitlements() {
|
|
64
71
|
if (!SYNALUX_CONFIGURED || !PRISM_SYNALUX_BASE_URL) {
|
|
65
72
|
debugLog("[entitlements] no Synalux auth configured — free tier");
|
|
66
|
-
return FREE_ENTITLEMENTS;
|
|
73
|
+
return { ...FREE_ENTITLEMENTS, source: "unconfigured" };
|
|
67
74
|
}
|
|
68
75
|
const jwt = await getSynaluxJwt();
|
|
69
76
|
if (!jwt) {
|
|
70
|
-
debugLog("[entitlements] JWT exchange failed — free tier fallback");
|
|
71
|
-
return FREE_ENTITLEMENTS;
|
|
77
|
+
debugLog("[entitlements] JWT exchange failed — free tier fallback (fallback_free)");
|
|
78
|
+
return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
|
|
72
79
|
}
|
|
73
80
|
try {
|
|
74
81
|
const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/entitlements`;
|
|
@@ -84,15 +91,18 @@ async function fetchEntitlements() {
|
|
|
84
91
|
debugLog("[entitlements] using last-known-good (safety fail-closed)");
|
|
85
92
|
return cache.entitlements;
|
|
86
93
|
}
|
|
87
|
-
return FREE_ENTITLEMENTS;
|
|
94
|
+
return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
|
|
88
95
|
}
|
|
89
96
|
const data = (await res.json());
|
|
90
97
|
if (!data.plan || !data.model_ceiling) {
|
|
91
98
|
debugLog("[entitlements] malformed response");
|
|
92
99
|
if (cache)
|
|
93
100
|
return cache.entitlements;
|
|
94
|
-
return FREE_ENTITLEMENTS;
|
|
101
|
+
return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
|
|
95
102
|
}
|
|
103
|
+
// §5.5: provenance — this is REAL portal data (a portal "free" plan
|
|
104
|
+
// gets source:"portal", distinguishing it from an assumed fallback).
|
|
105
|
+
data.source = "portal";
|
|
96
106
|
// Normalize legacy ceiling values to the current fleet.
|
|
97
107
|
if (data.model_ceiling === "14b") {
|
|
98
108
|
debugLog("[entitlements] grandfathered 14b ceiling → 9b");
|
|
@@ -114,20 +124,27 @@ async function fetchEntitlements() {
|
|
|
114
124
|
debugLog("[entitlements] using last-known-good (safety fail-closed)");
|
|
115
125
|
return cache.entitlements;
|
|
116
126
|
}
|
|
117
|
-
debugLog("[entitlements] no cached entitlements — free tier fallback (cold start)");
|
|
118
|
-
return FREE_ENTITLEMENTS;
|
|
127
|
+
debugLog("[entitlements] no cached entitlements — free tier fallback (cold start, fallback_free)");
|
|
128
|
+
return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
|
|
119
129
|
}
|
|
120
130
|
}
|
|
121
131
|
// ── Public API ────────────────────────────────────────────────────
|
|
122
132
|
/**
|
|
123
|
-
* Get the current user's entitlements (
|
|
124
|
-
* Concurrent callers share a single in-flight fetch.
|
|
133
|
+
* Get the current user's entitlements (5-minute cache; resolved per call —
|
|
134
|
+
* plan v2 §5.5). Concurrent callers share a single in-flight fetch.
|
|
135
|
+
* fallback_free results are never cached, so degraded resolution retries
|
|
136
|
+
* on the next call.
|
|
125
137
|
*/
|
|
126
138
|
export async function getEntitlements() {
|
|
127
139
|
const now = Date.now();
|
|
128
140
|
if (cache && cache.expiresAt > now) {
|
|
129
141
|
return cache.entitlements;
|
|
130
142
|
}
|
|
143
|
+
// §5.5: within the short negative window after a fallback_free
|
|
144
|
+
// resolution, return it without re-attempting the portal.
|
|
145
|
+
if (negativeCache && negativeCache.until > now) {
|
|
146
|
+
return negativeCache.entitlements;
|
|
147
|
+
}
|
|
131
148
|
if (inFlight)
|
|
132
149
|
return inFlight;
|
|
133
150
|
inFlight = (async () => {
|
|
@@ -136,11 +153,20 @@ export async function getEntitlements() {
|
|
|
136
153
|
// Only update cache if this is a REAL fetch (not a cached fallback).
|
|
137
154
|
// fetchEntitlements returns cache.entitlements on error — detect by
|
|
138
155
|
// checking if the returned object is the exact same reference.
|
|
139
|
-
|
|
156
|
+
// §5.5: fallback_free results are also never cached — pinning an
|
|
157
|
+
// assumed-free degradation for the TTL would delay recovery; the
|
|
158
|
+
// next call retries the portal instead.
|
|
159
|
+
const isFallback = (cache && ent === cache.entitlements) || ent.source === "fallback_free";
|
|
140
160
|
if (!isFallback) {
|
|
141
161
|
cache = { entitlements: ent, expiresAt: Date.now() + CACHE_TTL_MS };
|
|
142
162
|
}
|
|
143
163
|
// On fallback: DON'T refresh expiresAt — let it expire so we retry.
|
|
164
|
+
if (ent.source === "fallback_free") {
|
|
165
|
+
negativeCache = { entitlements: ent, until: Date.now() + FALLBACK_NEGATIVE_TTL_MS };
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
negativeCache = null;
|
|
169
|
+
}
|
|
144
170
|
return ent;
|
|
145
171
|
}
|
|
146
172
|
finally {
|
|
@@ -154,11 +180,13 @@ export async function getEntitlements() {
|
|
|
154
180
|
*/
|
|
155
181
|
export function invalidateEntitlements() {
|
|
156
182
|
cache = null;
|
|
183
|
+
negativeCache = null;
|
|
157
184
|
}
|
|
158
185
|
/** Test-only: reset all state. */
|
|
159
186
|
export function _resetEntitlementsForTest() {
|
|
160
187
|
cache = null;
|
|
161
188
|
inFlight = null;
|
|
189
|
+
negativeCache = null;
|
|
162
190
|
}
|
|
163
191
|
/** Test-only: inject a cached entitlement. */
|
|
164
192
|
export function _setCacheForTest(ent, ttlMs = CACHE_TTL_MS) {
|
|
@@ -55,17 +55,35 @@ export function recordInference(result) {
|
|
|
55
55
|
return;
|
|
56
56
|
// Durable ledger row (fire-and-forget; in-memory counters below remain the
|
|
57
57
|
// per-session view). safety_gate is excluded by the early return above.
|
|
58
|
+
// §5.2: prefer the structured gate_outcome; keep the legacy
|
|
59
|
+
// "gate_failed_served" string for degraded rows so existing ledger
|
|
60
|
+
// queries stay valid. Refused rows (escalation:"report") carry the
|
|
61
|
+
// refusal reason — the serve-mode throw paths ledger directly at the
|
|
62
|
+
// refusal site (makeReservedRefusal / backstop), so exactly one row
|
|
63
|
+
// is written either way.
|
|
64
|
+
const gateOutcomeStr = result.gate_outcome
|
|
65
|
+
? (result.gate_outcome.status === "degraded" ? "gate_failed_served" : result.gate_outcome.status)
|
|
66
|
+
: (result.quality_gate_failed ? "gate_failed_served" : undefined);
|
|
58
67
|
appendInferMetric({
|
|
59
68
|
backend: result.backend,
|
|
60
69
|
model: result.model_picked,
|
|
61
70
|
used_cloud: result.used_cloud,
|
|
62
71
|
mode: result.mode,
|
|
63
|
-
gate_outcome:
|
|
72
|
+
gate_outcome: gateOutcomeStr,
|
|
73
|
+
refusal_reason: result.gate_outcome?.status === "refused" ? result.gate_outcome.reason : undefined,
|
|
64
74
|
prompt_tokens: result.prompt_tokens,
|
|
65
75
|
completion_tokens: result.completion_tokens,
|
|
66
76
|
latency_ms: result.latency_ms,
|
|
67
77
|
ram_free_mb: result.ram_free_mb,
|
|
68
78
|
});
|
|
79
|
+
// §5.2: refused results (escalation:"report") get a ledger row above but
|
|
80
|
+
// must NOT touch the session accumulators — no model ran and nothing was
|
|
81
|
+
// served, so counting them as local serves would inflate local% and
|
|
82
|
+
// cloudTokensSavedEst (the GATE 6 KPI: "improves only when local inference
|
|
83
|
+
// replaces a cloud call"). Serve-mode refusals throw and never reach here,
|
|
84
|
+
// so skipping keeps both modes' accounting identical.
|
|
85
|
+
if (result.backend === "refused")
|
|
86
|
+
return;
|
|
69
87
|
const key = result.model_picked ?? result.backend;
|
|
70
88
|
if (result.used_cloud) {
|
|
71
89
|
cloudCalls++;
|
package/dist/utils/layer1.js
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* a prompt is OBVIOUS_RESERVED, OBVIOUS_NOT_RESERVED, or UNCERTAIN.
|
|
6
6
|
*
|
|
7
7
|
* Fail-closed contract:
|
|
8
|
-
* OBVIOUS_NOT_RESERVED →
|
|
8
|
+
* OBVIOUS_NOT_RESERVED → permits local routing
|
|
9
9
|
* OBVIOUS_RESERVED → escalate to cloud
|
|
10
10
|
* UNCERTAIN → escalate to cloud (conservative)
|
|
11
|
+
* UNCERTAIN_LENGTH → §5.3: prompt too long to classify in full, but the
|
|
12
|
+
* full-text keyword floor is clean AND a head+tail
|
|
13
|
+
* excerpt classified clean — permits local routing
|
|
14
|
+
* with a distinct audit marker ("too long to classify"
|
|
15
|
+
* ≠ "semantically uncertain")
|
|
11
16
|
* ERROR → escalate to cloud (never fail-open)
|
|
12
17
|
*
|
|
13
18
|
* The prompt below is VERBATIM from §E of prism-infer-boundaries/SKILL.md.
|
|
@@ -66,7 +71,7 @@ const LAYER1_RETRY_TIMEOUT_MS = 5_000;
|
|
|
66
71
|
// Not sufficient alone (adversaries can paraphrase), but as an ERROR-path
|
|
67
72
|
// floor they block the obvious cases that padding/injection attacks
|
|
68
73
|
// would otherwise smuggle through.
|
|
69
|
-
const RESERVED_KEYWORDS = /\b(restrain\w*|seclu(?:sion|d\w*)|physical\s*holds?|containment|self[- ]?harm\w*|suicid\w*|overdos\w*|dos(?:age|ing)\s*(?:mg|schedule)|crisis\s*de[- ]?escalation|meltdown\s*management|elopement\s*incident)\b/i;
|
|
74
|
+
const RESERVED_KEYWORDS = /\b(restrain\w*|seclu(?:sion|d\w*)|physical\s*holds?|(?:prone|supine|basket|therapeutic|manual|two[- ]?person)\s+holds?|hold(?:ing)?\s+(?:the\s+)?(?:client|child|student|patient)\s+down|containment|self[- ]?harm\w*|suicid\w*|overdos\w*|dos(?:age|ing)\s*(?:mg|schedule)|crisis\s*de[- ]?escalation|meltdown\s*management|rage\s+episode|elopement\s*incident)\b/i;
|
|
70
75
|
/**
|
|
71
76
|
* Deterministic keyword check — the ERROR-path floor.
|
|
72
77
|
* Returns OBVIOUS_RESERVED if reserved vocabulary is present,
|
|
@@ -77,21 +82,54 @@ export function keywordBackstop(prompt) {
|
|
|
77
82
|
return RESERVED_KEYWORDS.test(prompt) ? "OBVIOUS_RESERVED" : "OBVIOUS_NOT_RESERVED";
|
|
78
83
|
}
|
|
79
84
|
// Over-length prompts are attacker-controlled — don't let length
|
|
80
|
-
// select the ERROR branch.
|
|
81
|
-
//
|
|
85
|
+
// select the ERROR branch. §5.3: instead of a blanket UNCERTAIN (which
|
|
86
|
+
// routed every big benign prompt to cloud-or-refuse — v1 FATAL #3), run
|
|
87
|
+
// the deterministic keyword floor over the FULL text, then classify a
|
|
88
|
+
// bounded head+tail excerpt. A clean floor + clean excerpt yields the
|
|
89
|
+
// distinct UNCERTAIN_LENGTH verdict so callers can tell "too long to
|
|
90
|
+
// classify" from "semantically uncertain".
|
|
82
91
|
const MAX_CLASSIFIER_PROMPT_LENGTH = 4_000;
|
|
92
|
+
// Excerpt budget: head + middle + tail must stay under the classifier cap
|
|
93
|
+
// with room for the LAYER1_PROMPT template. The sampled middle window
|
|
94
|
+
// narrows the region an attacker can hide paraphrased reserved content in
|
|
95
|
+
// (adversarial-review finding: keyword-free paraphrases in the unseen
|
|
96
|
+
// middle are the residual gap — the window makes padding placement
|
|
97
|
+
// harder; the full-text keyword floor remains the deterministic net).
|
|
98
|
+
const EXCERPT_HEAD_CHARS = 2_000;
|
|
99
|
+
const EXCERPT_MID_CHARS = 400;
|
|
100
|
+
const EXCERPT_TAIL_CHARS = 1_400;
|
|
101
|
+
/** Bounded head+middle+tail excerpt of an oversize prompt (§5.3). Exported for tests. */
|
|
102
|
+
export function buildOversizeExcerpt(prompt) {
|
|
103
|
+
const midStart = Math.max(EXCERPT_HEAD_CHARS, Math.floor(prompt.length / 2) - EXCERPT_MID_CHARS / 2);
|
|
104
|
+
const middle = prompt.slice(midStart, midStart + EXCERPT_MID_CHARS);
|
|
105
|
+
return (prompt.slice(0, EXCERPT_HEAD_CHARS) +
|
|
106
|
+
"\n[…]\n" +
|
|
107
|
+
middle +
|
|
108
|
+
"\n[…]\n" +
|
|
109
|
+
prompt.slice(-EXCERPT_TAIL_CHARS));
|
|
110
|
+
}
|
|
83
111
|
/**
|
|
84
112
|
* Run the Layer 1 classifier with retry on cold-model timeout.
|
|
85
113
|
* Returns a verdict; never throws.
|
|
86
114
|
*
|
|
87
115
|
* Flow: classify → if ERROR, retry once with longer timeout →
|
|
88
116
|
* if still ERROR, return ERROR (caller uses keywordBackstop).
|
|
117
|
+
*
|
|
118
|
+
* Oversize flow (§5.3): full-text keyword floor → excerpt classification →
|
|
119
|
+
* reserved/uncertain excerpt verdicts keep full reserved handling;
|
|
120
|
+
* a clean excerpt returns UNCERTAIN_LENGTH.
|
|
89
121
|
*/
|
|
90
122
|
export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch) {
|
|
91
123
|
if (!userPrompt || !userPrompt.trim())
|
|
92
124
|
return "ERROR";
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
const oversize = userPrompt.length > MAX_CLASSIFIER_PROMPT_LENGTH;
|
|
126
|
+
if (oversize && keywordBackstop(userPrompt) === "OBVIOUS_RESERVED") {
|
|
127
|
+
// The regex floor has no length limit — reserved vocabulary anywhere
|
|
128
|
+
// in the full text (including the middle the excerpt can't see)
|
|
129
|
+
// short-circuits to reserved handling.
|
|
130
|
+
return "OBVIOUS_RESERVED";
|
|
131
|
+
}
|
|
132
|
+
const classifierInput = oversize ? buildOversizeExcerpt(userPrompt) : userPrompt;
|
|
95
133
|
const classify = async (timeoutMs) => {
|
|
96
134
|
let res;
|
|
97
135
|
try {
|
|
@@ -101,7 +139,7 @@ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch
|
|
|
101
139
|
body: JSON.stringify({
|
|
102
140
|
model,
|
|
103
141
|
messages: [
|
|
104
|
-
{ role: "user", content: LAYER1_PROMPT.replace("{prompt}",
|
|
142
|
+
{ role: "user", content: LAYER1_PROMPT.replace("{prompt}", classifierInput) },
|
|
105
143
|
],
|
|
106
144
|
stream: false,
|
|
107
145
|
think: false,
|
|
@@ -128,7 +166,16 @@ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch
|
|
|
128
166
|
return parseLayer1(text);
|
|
129
167
|
};
|
|
130
168
|
const first = await classify(LAYER1_TIMEOUT_MS);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
const verdict = first !== "ERROR" ? first : await classify(LAYER1_RETRY_TIMEOUT_MS);
|
|
170
|
+
if (!oversize)
|
|
171
|
+
return verdict;
|
|
172
|
+
// §5.3 oversize mapping:
|
|
173
|
+
// excerpt OBVIOUS_RESERVED / UNCERTAIN → keep full reserved handling
|
|
174
|
+
// excerpt OBVIOUS_NOT_RESERVED → UNCERTAIN_LENGTH (distinct verdict)
|
|
175
|
+
// excerpt ERROR → UNCERTAIN_LENGTH — the deterministic
|
|
176
|
+
// keyword floor already cleared the FULL text above, which is the same
|
|
177
|
+
// floor the normal-size ERROR path falls back to via the caller.
|
|
178
|
+
if (verdict === "OBVIOUS_RESERVED" || verdict === "UNCERTAIN")
|
|
179
|
+
return verdict;
|
|
180
|
+
return "UNCERTAIN_LENGTH";
|
|
134
181
|
}
|
|
@@ -24,12 +24,21 @@ const GB = 1024 ** 3;
|
|
|
24
24
|
/**
|
|
25
25
|
* Tier table, ordered LARGEST → SMALLEST. Picker walks this and returns
|
|
26
26
|
* the first row whose minFreeGb fits within freeBytes.
|
|
27
|
+
*
|
|
28
|
+
* ctxTokens (§5.4): aligned with the LIVE Modelfile `num_ctx` values
|
|
29
|
+
* (verified via `ollama show <tag> --modelfile` on 2026-07-20). The values
|
|
30
|
+
* are inverted from intuition — the 27b/9b Modelfiles bake num_ctx 4096
|
|
31
|
+
* while 4b/2b bake 32768. The tier walk skips tiers whose ctx cannot hold
|
|
32
|
+
* prompt + output (reason: ctx_insufficient) — Ollama otherwise silently
|
|
33
|
+
* truncates the prompt and answers from the fragment. If a Modelfile's
|
|
34
|
+
* num_ctx is ever raised, update the row here (a raise is a measured
|
|
35
|
+
* decision with KV-cache RAM costed — plan v2 §5.4).
|
|
27
36
|
*/
|
|
28
37
|
export const MODEL_TIERS = [
|
|
29
|
-
{ tag: 'prism-coder:27b', weightsGb: 16, minFreeGb: 20, ctxTokens:
|
|
30
|
-
{ tag: 'prism-coder:9b', weightsGb: 5.8, minFreeGb: 8, ctxTokens:
|
|
38
|
+
{ tag: 'prism-coder:27b', weightsGb: 16, minFreeGb: 20, ctxTokens: 4_096 },
|
|
39
|
+
{ tag: 'prism-coder:9b', weightsGb: 5.8, minFreeGb: 8, ctxTokens: 4_096 },
|
|
31
40
|
{ tag: 'prism-coder:4b', weightsGb: 3.4, minFreeGb: 5, ctxTokens: 32_768 },
|
|
32
|
-
{ tag: 'prism-coder:2b', weightsGb: 2.3, minFreeGb: 3, ctxTokens:
|
|
41
|
+
{ tag: 'prism-coder:2b', weightsGb: 2.3, minFreeGb: 3, ctxTokens: 32_768 },
|
|
33
42
|
];
|
|
34
43
|
/**
|
|
35
44
|
* True when `installed` matches `tierTag` either as a bare tag
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.0
|
|
3
|
+
"version": "20.1.0",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
|
|
6
6
|
"module": "index.ts",
|