prism-mcp-server 20.0.6 → 20.0.8
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 +10 -0
- package/dist/server.js +1 -1
- package/dist/storage/inferMetricsLedger.js +159 -0
- package/dist/tools/__tests__/layer1Integration.test.js +34 -0
- package/dist/tools/ledgerHandlers.js +40 -13
- package/dist/tools/prismInferHandler.js +48 -6
- package/dist/tools/sessionMemoryDefinitions.js +12 -5
- package/dist/tools/skillRouting.js +57 -8
- package/dist/utils/inferenceMetrics.js +38 -1
- package/dist/utils/skillBudget.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,16 @@ 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.0.8
|
|
22
|
+
|
|
23
|
+
### verify_behavior Works Again
|
|
24
|
+
The `verify_behavior` tool crashed on every call (`-32602 expected object, received string`) — the handler returned a bare string instead of an MCP `CallToolResult` object. Fixed, with contract + fail-closed regression tests so the safety gate can never silently break again. If you're on 20.0.6/20.0.7, update.
|
|
25
|
+
|
|
26
|
+
### From v20.0.7: Reserved-Content Safety, Skills Auth, Delegation Metrics
|
|
27
|
+
Reserved clinical content is now Claude-or-refuse (never served by a smaller model than the one that refused it), skill delivery gained a JWT auth fallback (paid-tier skills now reach machines using only `PRISM_SYNALUX_API_KEY`), and every `prism_infer` call is recorded in a persistent `infer_metrics` ledger. Full details in [CHANGELOG.md](CHANGELOG.md).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
21
31
|
## What's New in v20.0.5
|
|
22
32
|
|
|
23
33
|
### Local-First Delegation — 15 Categories, Measured Rate
|
package/dist/server.js
CHANGED
|
@@ -985,7 +985,7 @@ export function createServer() {
|
|
|
985
985
|
result = await knowledgeIngestHandler(args);
|
|
986
986
|
break;
|
|
987
987
|
case "inference_metrics":
|
|
988
|
-
result = await inferenceMetricsHandler();
|
|
988
|
+
result = await inferenceMetricsHandler(args);
|
|
989
989
|
break;
|
|
990
990
|
default:
|
|
991
991
|
result = {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent inference-metrics ledger — append-only rows in the same
|
|
3
|
+
* ~/.prism-mcp/prism-config.db used by configStorage.
|
|
4
|
+
*
|
|
5
|
+
* Purpose: the in-memory counters in utils/inferenceMetrics.ts reset with
|
|
6
|
+
* every MCP server process, which made "how much do we actually delegate?"
|
|
7
|
+
* unanswerable. This ledger is the durable record that delegation goal
|
|
8
|
+
* metrics (local vs cloud volume over time) are computed from.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - appendInferMetric() is fire-and-forget: it must NEVER throw or delay
|
|
12
|
+
* the inference hot path. Failures are debug-logged and dropped.
|
|
13
|
+
* - safety_gate calls are excluded by the caller (recordInference returns
|
|
14
|
+
* before reaching us) — crisis-filter triggers are never persisted.
|
|
15
|
+
* - gate_outcome / refusal_reason / caller are nullable now and filled by
|
|
16
|
+
* the Phase-1 failure contract without a schema migration.
|
|
17
|
+
*/
|
|
18
|
+
import { createClient } from "@libsql/client";
|
|
19
|
+
import { resolve, dirname } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { existsSync, mkdirSync } from "fs";
|
|
22
|
+
import { debugLog } from "../utils/logger.js";
|
|
23
|
+
// Resolution order:
|
|
24
|
+
// 1. PRISM_INFER_LEDGER_DB_PATH — explicit override (tests, relocation)
|
|
25
|
+
// 2. PRISM_DATA_DIR — the test-suite sandbox (tests/setup.ts) and any
|
|
26
|
+
// operator-relocated data root; REQUIRED so `npm test` never writes
|
|
27
|
+
// fabricated rows into the real user ledger
|
|
28
|
+
// 3. default ~/.prism-mcp/prism-config.db (shared with configStorage)
|
|
29
|
+
function dbPath() {
|
|
30
|
+
if (process.env.PRISM_INFER_LEDGER_DB_PATH)
|
|
31
|
+
return process.env.PRISM_INFER_LEDGER_DB_PATH;
|
|
32
|
+
if (process.env.PRISM_DATA_DIR)
|
|
33
|
+
return resolve(process.env.PRISM_DATA_DIR, "prism-config.db");
|
|
34
|
+
return resolve(homedir(), ".prism-mcp", "prism-config.db");
|
|
35
|
+
}
|
|
36
|
+
let client = null;
|
|
37
|
+
let ensured = null;
|
|
38
|
+
let disabled = false;
|
|
39
|
+
let initFailures = 0;
|
|
40
|
+
const MAX_INIT_FAILURES = 3;
|
|
41
|
+
function ensureTable() {
|
|
42
|
+
if (!ensured) {
|
|
43
|
+
ensured = (async () => {
|
|
44
|
+
const path = dbPath();
|
|
45
|
+
const dir = dirname(path);
|
|
46
|
+
if (!existsSync(dir))
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
client = createClient({ url: `file:${path}` });
|
|
49
|
+
// Shared file with configStorage — wait out short write locks
|
|
50
|
+
// instead of failing (transient SQLITE_BUSY must not kill the
|
|
51
|
+
// ledger). Best-effort: an unsupported PRAGMA must not disable us.
|
|
52
|
+
await client.execute(`PRAGMA busy_timeout = 2000`).catch(() => { });
|
|
53
|
+
await client.execute(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS infer_metrics (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
ts INTEGER NOT NULL,
|
|
57
|
+
caller TEXT,
|
|
58
|
+
mode TEXT,
|
|
59
|
+
backend TEXT NOT NULL,
|
|
60
|
+
model TEXT,
|
|
61
|
+
used_cloud INTEGER NOT NULL,
|
|
62
|
+
gate_outcome TEXT,
|
|
63
|
+
refusal_reason TEXT,
|
|
64
|
+
prompt_tokens INTEGER,
|
|
65
|
+
completion_tokens INTEGER,
|
|
66
|
+
latency_ms INTEGER,
|
|
67
|
+
ram_free_mb INTEGER
|
|
68
|
+
)`);
|
|
69
|
+
await client.execute(`CREATE INDEX IF NOT EXISTS idx_infer_metrics_ts ON infer_metrics (ts)`);
|
|
70
|
+
})().catch((e) => {
|
|
71
|
+
// Transient failures (missing dir on first run, SQLITE_BUSY) retry on
|
|
72
|
+
// the next append; only repeated failure disables for the process.
|
|
73
|
+
initFailures++;
|
|
74
|
+
ensured = null;
|
|
75
|
+
client = null;
|
|
76
|
+
if (initFailures >= MAX_INIT_FAILURES)
|
|
77
|
+
disabled = true;
|
|
78
|
+
debugLog(`[infer-ledger] init failed (${initFailures}/${MAX_INIT_FAILURES}${disabled ? ", ledger disabled" : ", will retry"}): ${e instanceof Error ? e.message : e}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return ensured;
|
|
82
|
+
}
|
|
83
|
+
/** Append one row. Fire-and-forget — never throws, never blocks the caller. */
|
|
84
|
+
export function appendInferMetric(row) {
|
|
85
|
+
if (disabled)
|
|
86
|
+
return;
|
|
87
|
+
void (async () => {
|
|
88
|
+
await ensureTable();
|
|
89
|
+
if (disabled || !client)
|
|
90
|
+
return;
|
|
91
|
+
await client.execute({
|
|
92
|
+
sql: `INSERT INTO infer_metrics
|
|
93
|
+
(ts, caller, mode, backend, model, used_cloud, gate_outcome,
|
|
94
|
+
refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
96
|
+
args: [
|
|
97
|
+
Date.now(), row.caller ?? "mcp", row.mode ?? null, row.backend,
|
|
98
|
+
row.model, row.used_cloud ? 1 : 0, row.gate_outcome ?? null,
|
|
99
|
+
row.refusal_reason ?? null, row.prompt_tokens ?? null,
|
|
100
|
+
row.completion_tokens ?? null, row.latency_ms ?? null,
|
|
101
|
+
row.ram_free_mb ?? null,
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
})().catch((e) => {
|
|
105
|
+
debugLog(`[infer-ledger] append failed: ${e instanceof Error ? e.message : e}`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Aggregate all persisted rows (optionally since a timestamp). */
|
|
109
|
+
export async function queryInferMetrics(sinceTs) {
|
|
110
|
+
try {
|
|
111
|
+
await ensureTable();
|
|
112
|
+
if (disabled || !client)
|
|
113
|
+
return null;
|
|
114
|
+
const where = sinceTs != null ? `WHERE ts >= ?` : "";
|
|
115
|
+
const whereArgs = sinceTs != null ? [Math.floor(sinceTs)] : [];
|
|
116
|
+
const agg = await client.execute({
|
|
117
|
+
sql: `
|
|
118
|
+
SELECT COUNT(*) AS total,
|
|
119
|
+
SUM(CASE WHEN used_cloud = 0 THEN 1 ELSE 0 END) AS local,
|
|
120
|
+
SUM(CASE WHEN used_cloud = 1 THEN 1 ELSE 0 END) AS cloud,
|
|
121
|
+
COALESCE(SUM(prompt_tokens), 0) AS pt,
|
|
122
|
+
COALESCE(SUM(completion_tokens), 0) AS ct,
|
|
123
|
+
COALESCE(AVG(latency_ms), 0) AS avg_lat,
|
|
124
|
+
MIN(ts) AS first_ts, MAX(ts) AS last_ts
|
|
125
|
+
FROM infer_metrics ${where}`, args: whereArgs
|
|
126
|
+
});
|
|
127
|
+
const byB = await client.execute({
|
|
128
|
+
sql: `SELECT backend, COUNT(*) AS n FROM infer_metrics ${where} GROUP BY backend`,
|
|
129
|
+
args: whereArgs
|
|
130
|
+
});
|
|
131
|
+
const r = agg.rows[0];
|
|
132
|
+
const by_backend = {};
|
|
133
|
+
for (const row of byB.rows) {
|
|
134
|
+
by_backend[String(row.backend)] = Number(row.n);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
total: Number(r.total ?? 0),
|
|
138
|
+
local: Number(r.local ?? 0),
|
|
139
|
+
cloud: Number(r.cloud ?? 0),
|
|
140
|
+
prompt_tokens: Number(r.pt ?? 0),
|
|
141
|
+
completion_tokens: Number(r.ct ?? 0),
|
|
142
|
+
avg_latency_ms: Math.round(Number(r.avg_lat ?? 0)),
|
|
143
|
+
first_ts: r.first_ts == null ? null : Number(r.first_ts),
|
|
144
|
+
last_ts: r.last_ts == null ? null : Number(r.last_ts),
|
|
145
|
+
by_backend,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
debugLog(`[infer-ledger] query failed: ${e instanceof Error ? e.message : e}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Test hook — reset module state so a fresh DB path/env can be exercised. */
|
|
154
|
+
export function _resetInferLedgerForTest() {
|
|
155
|
+
client = null;
|
|
156
|
+
ensured = null;
|
|
157
|
+
disabled = false;
|
|
158
|
+
initFailures = 0;
|
|
159
|
+
}
|
|
@@ -462,3 +462,37 @@ describe.skipIf(!LIVE)("Layer 1 live model — adversarial fixtures (PRISM_LIVE_
|
|
|
462
462
|
}
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
+
// ─── Reserved-content escalation routing (plan v2 §5.1) ─────────────────────
|
|
466
|
+
// The escalation target for reserved content must be at least as capable as
|
|
467
|
+
// the local model that refused it: Claude-or-refuse, never a small local tier
|
|
468
|
+
// or OpenRouter. These pin both the wire contract and the client-side defense.
|
|
469
|
+
import { ReservedRefusalError } from "../prismInferHandler.js";
|
|
470
|
+
describe("reserved-content escalation (§5.1)", () => {
|
|
471
|
+
const RESERVED_PROMPT = "draft a physical restraint procedure for the client";
|
|
472
|
+
it("passes reserved:true to callCloud when Layer 1 refuses", async () => {
|
|
473
|
+
const callCloud = vi.fn().mockResolvedValue({ ok: true, output: "safe cloud answer", backend: "claude-reserved" });
|
|
474
|
+
const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
|
|
475
|
+
const result = await runInfer({ prompt: RESERVED_PROMPT, mode: "code", cloud_fallback: true, max_tokens: 512 }, makeBaseDeps({ callCloud, callLayer1: callLayer1Mock }));
|
|
476
|
+
expect(callCloud).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number), expect.objectContaining({ reserved: true }));
|
|
477
|
+
expect(result.used_cloud).toBe(true);
|
|
478
|
+
expect(result.backend).toBe("claude-reserved");
|
|
479
|
+
});
|
|
480
|
+
it("REFUSES when the portal serves a reserved turn from a weak backend (defense in depth)", async () => {
|
|
481
|
+
const callCloud = vi.fn().mockResolvedValue({ ok: true, output: "answer from tiny model", backend: "ollama-2b" });
|
|
482
|
+
const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
|
|
483
|
+
await expect(runInfer({ prompt: RESERVED_PROMPT, mode: "code", cloud_fallback: true, max_tokens: 512 }, makeBaseDeps({ callCloud, callLayer1: callLayer1Mock }))).rejects.toMatchObject({ name: "ReservedRefusalError", refusal_reason: "layer1_reserved" });
|
|
484
|
+
});
|
|
485
|
+
it("REFUSES on openrouter backends the same way", async () => {
|
|
486
|
+
const callCloud = vi.fn().mockResolvedValue({ ok: true, output: "qwen answer", backend: "openrouter-9b" });
|
|
487
|
+
const callLayer1Mock = vi.fn().mockResolvedValue("UNCERTAIN");
|
|
488
|
+
await expect(runInfer({ prompt: RESERVED_PROMPT, mode: "chat", cloud_fallback: true, max_tokens: 512 }, makeBaseDeps({ callCloud, callLayer1: callLayer1Mock }))).rejects.toBeInstanceOf(ReservedRefusalError);
|
|
489
|
+
});
|
|
490
|
+
it("refusal without cloud carries the typed reason (free-tier path)", async () => {
|
|
491
|
+
const callLocal = vi.fn();
|
|
492
|
+
const callLayer1Mock = vi.fn().mockResolvedValue("OBVIOUS_RESERVED");
|
|
493
|
+
const deps = makeBaseDeps({ callLocal, callLayer1: callLayer1Mock });
|
|
494
|
+
deps.entitlements.features.cloud_fallback = false;
|
|
495
|
+
await expect(runInfer({ prompt: RESERVED_PROMPT, mode: "code", cloud_fallback: true, max_tokens: 512 }, deps)).rejects.toMatchObject({ refusal_reason: "layer1_reserved" });
|
|
496
|
+
expect(callLocal).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
@@ -904,14 +904,14 @@ export async function sessionLoadContextHandler(args) {
|
|
|
904
904
|
// If the active role has a skill document stored, append it so the
|
|
905
905
|
// agent loads its rules/conventions automatically at session start.
|
|
906
906
|
let skillBlock = "";
|
|
907
|
-
let skillLoaded = false;
|
|
908
907
|
const loadedSkills = [];
|
|
908
|
+
const skillEntries = [];
|
|
909
909
|
if (effectiveRole) {
|
|
910
910
|
const skillContent = await getSetting(`skill:${effectiveRole}`, "");
|
|
911
911
|
if (skillContent && skillContent.trim()) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
912
|
+
// protected: the role skill is deliberate per-agent configuration and
|
|
913
|
+
// was unconditionally injected before budgeting existed.
|
|
914
|
+
skillEntries.push({ name: effectiveRole, content: skillContent, protected: true, category: "role", priority: -1 });
|
|
915
915
|
debugLog(`[session_load_context] Injecting skill for role="${effectiveRole}" (${skillContent.length} chars)`);
|
|
916
916
|
}
|
|
917
917
|
}
|
|
@@ -928,14 +928,23 @@ export async function sessionLoadContextHandler(args) {
|
|
|
928
928
|
} });
|
|
929
929
|
const skillResolution = await resolveSkills(project, prompt, effectiveRole);
|
|
930
930
|
// Client-renders-content: portal returns names, we load content from local DB
|
|
931
|
+
const resolvedMeta = new Map((skillResolution.skills || []).map((s) => [s.name, s]));
|
|
932
|
+
// Legacy last-good caches carry names without metadata; OFFLINE_FALLBACK
|
|
933
|
+
// membership is the floor of last resort so core rules stay inlined.
|
|
934
|
+
const { OFFLINE_FALLBACK } = await import("./skillRouting.js");
|
|
935
|
+
const fallbackFloor = new Set(OFFLINE_FALLBACK.universal.map((e) => (typeof e === "string" ? e : e.name)));
|
|
931
936
|
for (const name of skillResolution.names || []) {
|
|
932
|
-
if (
|
|
937
|
+
if (skillEntries.some((e) => e.name === name))
|
|
933
938
|
continue;
|
|
934
939
|
const content = await getSetting(`skill:${name}`, "");
|
|
935
940
|
if (content?.trim()) {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
941
|
+
const meta = resolvedMeta.get(name);
|
|
942
|
+
skillEntries.push({
|
|
943
|
+
name, content,
|
|
944
|
+
protected: meta?.protected ?? (skillResolution.isOffline && fallbackFloor.has(name)),
|
|
945
|
+
category: meta?.category ?? "universal",
|
|
946
|
+
priority: meta?.priority ?? 999,
|
|
947
|
+
});
|
|
939
948
|
}
|
|
940
949
|
}
|
|
941
950
|
// Offline fallback: load ALL local skill: content (no tier gating).
|
|
@@ -948,16 +957,34 @@ export async function sessionLoadContextHandler(args) {
|
|
|
948
957
|
if (!k.startsWith("skill:") || !v)
|
|
949
958
|
continue;
|
|
950
959
|
const name = k.replace("skill:", "");
|
|
951
|
-
if (
|
|
960
|
+
if (skillEntries.some((e) => e.name === name))
|
|
952
961
|
continue;
|
|
953
962
|
const content = v.trim();
|
|
954
|
-
if (content
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
963
|
+
if (content) {
|
|
964
|
+
// Offline can't know protected flags; OFFLINE_FALLBACK names are the
|
|
965
|
+
// best available floor — mark those protected so they always inline.
|
|
966
|
+
skillEntries.push({ name, content, protected: fallbackFloor.has(name), category: "offline", priority: 999 });
|
|
958
967
|
}
|
|
959
968
|
}
|
|
960
969
|
}
|
|
970
|
+
// ─── Skill delivery budget (local-first plan v2 Phase 1) ────
|
|
971
|
+
// Paid-tier resolution returns 30+ skills (~114KB measured). Unbudgeted
|
|
972
|
+
// inlining exceeds host tool-result caps and the whole response gets
|
|
973
|
+
// file-diverted — the agent receives NOTHING. Budgeted assembly: protected
|
|
974
|
+
// always full, prompt-matched next, tail by priority, overflow by name.
|
|
975
|
+
// 60% of the response budget: skills must not saturate the whole allowance
|
|
976
|
+
// or T5 truncation zeroes out briefing/history — the memory this tool
|
|
977
|
+
// exists to deliver. The protected floor may still exceed this tranche
|
|
978
|
+
// (always inlined); the reserved 40% keeps history alive whenever the
|
|
979
|
+
// caller's budget covers the floor at all.
|
|
980
|
+
const skillBudgetChars = maxTokens && maxTokens > 0 ? Math.floor(maxTokens * 3.5 * 0.6) : Number.POSITIVE_INFINITY;
|
|
981
|
+
const { assembleSkillBlock } = await import("../utils/skillBudget.js");
|
|
982
|
+
const budgeted = assembleSkillBlock(skillEntries, skillBudgetChars);
|
|
983
|
+
skillBlock = budgeted.block;
|
|
984
|
+
loadedSkills.push(...budgeted.inlined);
|
|
985
|
+
if (budgeted.overflow.length > 0) {
|
|
986
|
+
debugLog(`[session_load_context] skill budget: inlined ${budgeted.inlined.length}, overflow ${budgeted.overflow.length} (${skillBudgetChars} chars)`);
|
|
987
|
+
}
|
|
961
988
|
// ─── Agent Greeting Block ────────────────────────────────────
|
|
962
989
|
// Shows agent identity (name + role) and skill status after briefing.
|
|
963
990
|
let greetingBlock = "";
|
|
@@ -31,6 +31,7 @@ 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
33
|
import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
|
|
34
|
+
import { appendInferMetric } from "../storage/inferMetricsLedger.js";
|
|
34
35
|
// ─── Tool Definition ────────────────────────────────────────────
|
|
35
36
|
export const PRISM_INFER_TOOL = {
|
|
36
37
|
name: "prism_infer",
|
|
@@ -259,13 +260,42 @@ async function callOllamaGenerate(url, model, prompt, system, maxTokens, tempera
|
|
|
259
260
|
return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "timeout" : "network" };
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
|
-
|
|
263
|
+
// ─── Cloud fallback via synalux portal ─────────────────────────
|
|
264
|
+
/**
|
|
265
|
+
* Typed refusal for reserved clinical content (plan v2 §5.1) — callers can
|
|
266
|
+
* distinguish "refused for safety" from infrastructure failure via
|
|
267
|
+
* `refusal_reason` instead of parsing the message. Also ledgered so refusals
|
|
268
|
+
* are visible in delegation metrics (backend='refused').
|
|
269
|
+
*/
|
|
270
|
+
export class ReservedRefusalError extends Error {
|
|
271
|
+
attempts;
|
|
272
|
+
refusal_reason = "layer1_reserved";
|
|
273
|
+
constructor(verdict, attempts) {
|
|
274
|
+
super(`prism_infer: Layer 1 verdict=${verdict}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
|
|
275
|
+
this.attempts = attempts;
|
|
276
|
+
this.name = "ReservedRefusalError";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function makeReservedRefusal(verdict, attempts) {
|
|
280
|
+
// Ledger the refusal (fire-and-forget). No prompt content is persisted —
|
|
281
|
+
// same HIPAA posture as the safety_gate exclusion.
|
|
282
|
+
appendInferMetric({
|
|
283
|
+
backend: "refused", model: null, used_cloud: false,
|
|
284
|
+
refusal_reason: "layer1_reserved",
|
|
285
|
+
});
|
|
286
|
+
return new ReservedRefusalError(verdict, attempts);
|
|
287
|
+
}
|
|
288
|
+
async function callSynaluxInference(prompt, maxTokens, timeoutMs, opts) {
|
|
263
289
|
if (!PRISM_SYNALUX_BASE_URL)
|
|
264
290
|
return { ok: false, reason: "no_synalux_base_url" };
|
|
265
291
|
const jwt = await getSynaluxJwt();
|
|
266
292
|
if (!jwt)
|
|
267
293
|
return { ok: false, reason: "jwt_exchange_failed" };
|
|
268
294
|
const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/inference`;
|
|
295
|
+
// reserved=true tells the portal this prompt was refused by local Layer-1
|
|
296
|
+
// as reserved clinical content: it must be served by Claude or refused —
|
|
297
|
+
// never by a small local model or OpenRouter (plan v2 §5.1).
|
|
298
|
+
const reqBody = JSON.stringify({ prompt, max_tokens: maxTokens, ...(opts?.reserved ? { reserved: true } : {}) });
|
|
269
299
|
try {
|
|
270
300
|
let res = await fetch(url, {
|
|
271
301
|
method: "POST",
|
|
@@ -273,7 +303,7 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
|
|
|
273
303
|
"Authorization": `Bearer ${jwt}`,
|
|
274
304
|
"Content-Type": "application/json",
|
|
275
305
|
},
|
|
276
|
-
body:
|
|
306
|
+
body: reqBody,
|
|
277
307
|
signal: AbortSignal.timeout(timeoutMs),
|
|
278
308
|
redirect: "error",
|
|
279
309
|
});
|
|
@@ -289,7 +319,7 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
|
|
|
289
319
|
"Authorization": `Bearer ${fresh}`,
|
|
290
320
|
"Content-Type": "application/json",
|
|
291
321
|
},
|
|
292
|
-
body:
|
|
322
|
+
body: reqBody,
|
|
293
323
|
signal: AbortSignal.timeout(timeoutMs),
|
|
294
324
|
redirect: "error",
|
|
295
325
|
});
|
|
@@ -433,8 +463,18 @@ export async function runInfer(args, deps) {
|
|
|
433
463
|
attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
|
|
434
464
|
if (allowCloud) {
|
|
435
465
|
const cloudTimeout = args.timeout_ms ?? 90_000;
|
|
436
|
-
const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
|
|
466
|
+
const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout, { reserved: true });
|
|
437
467
|
if (cloud.ok && cloud.output) {
|
|
468
|
+
// Defense in depth (§5.1): the escalation target for reserved
|
|
469
|
+
// content must be STRONGER than the local model that refused
|
|
470
|
+
// it. An old/unpatched portal that ignores the reserved flag
|
|
471
|
+
// can answer from a small local tier or OpenRouter — never
|
|
472
|
+
// serve that; refuse instead.
|
|
473
|
+
const weakBackend = /^(ollama-|openrouter-)/.test(cloud.backend ?? "");
|
|
474
|
+
if (weakBackend) {
|
|
475
|
+
attempts.push({ tier: "synalux", reason: `reserved_weak_backend:${cloud.backend}` });
|
|
476
|
+
throw makeReservedRefusal(l1, attempts);
|
|
477
|
+
}
|
|
438
478
|
return await applyVerification(cloud.output, gatedArgs, deps, {
|
|
439
479
|
backend: cloud.backend ?? "synalux",
|
|
440
480
|
model_picked: null,
|
|
@@ -448,7 +488,7 @@ export async function runInfer(args, deps) {
|
|
|
448
488
|
}
|
|
449
489
|
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
450
490
|
}
|
|
451
|
-
throw
|
|
491
|
+
throw makeReservedRefusal(l1, attempts);
|
|
452
492
|
}
|
|
453
493
|
if (l1 === "ERROR") {
|
|
454
494
|
debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
|
|
@@ -708,7 +748,9 @@ export async function prismInferHandler(args) {
|
|
|
708
748
|
// Local accumulator — sole source of the user-facing metrics block.
|
|
709
749
|
// T4: pass prompt_text so recordInference computes submittedEst via
|
|
710
750
|
// estimateTokens() — critical for cloud path where prompt_tokens is unset.
|
|
711
|
-
|
|
751
|
+
// mode lives on args, not the result — pass it explicitly or the
|
|
752
|
+
// ledger's mode column is silently NULL forever.
|
|
753
|
+
recordInference({ ...result, prompt_text: args.prompt, mode: args.mode ?? "route" });
|
|
712
754
|
// Best-effort session telemetry — records that inference ran for this
|
|
713
755
|
// conversation. Never affects routing or safety decisions.
|
|
714
756
|
const _convId = args.conversation_id;
|
|
@@ -1818,12 +1818,19 @@ export function isVerifyBehaviorArgs(a) {
|
|
|
1818
1818
|
// ─── v19.2: Inference Metrics Tool ──────────────────────────
|
|
1819
1819
|
export const INFERENCE_METRICS_TOOL = {
|
|
1820
1820
|
name: "inference_metrics",
|
|
1821
|
-
description: "Returns
|
|
1822
|
-
"
|
|
1823
|
-
"
|
|
1824
|
-
"
|
|
1821
|
+
description: "Returns local-model inference metrics — call count, local vs cloud split, " +
|
|
1822
|
+
"token totals, per-model breakdown, and average latency. Reflects prism_infer " +
|
|
1823
|
+
"delegation usage only, not the host model's (Claude's) own token spend " +
|
|
1824
|
+
"(use /cost for that). period: 'session' (default, in-memory since server " +
|
|
1825
|
+
"start) or 'all' (persisted ledger across restarts).",
|
|
1825
1826
|
inputSchema: {
|
|
1826
1827
|
type: "object",
|
|
1827
|
-
properties: {
|
|
1828
|
+
properties: {
|
|
1829
|
+
period: {
|
|
1830
|
+
type: "string",
|
|
1831
|
+
enum: ["session", "all"],
|
|
1832
|
+
description: "Metrics window: 'session' (this server process) or 'all' (durable ledger).",
|
|
1833
|
+
},
|
|
1834
|
+
},
|
|
1828
1835
|
},
|
|
1829
1836
|
};
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Cache: keyed on (project,prompt,role), 5-min live / 30s failure.
|
|
5
5
|
* Offline: last-good from local DB, or empty with warning.
|
|
6
6
|
*/
|
|
7
|
+
import { getSynaluxJwt, invalidateSynaluxJwt } from '../utils/synaluxJwt.js';
|
|
7
8
|
// -- Constants ----------------------------------------------------------------
|
|
8
9
|
const SYNALUX_BASE = process.env.SYNALUX_BASE_URL || 'https://synalux.ai';
|
|
9
|
-
const SKILLS_TOKEN = process.env.PRISM_SKILLS_TOKEN || '';
|
|
10
10
|
const LIVE_TTL = 5 * 60 * 1000;
|
|
11
11
|
const FAIL_TTL = 30_000;
|
|
12
12
|
const DEFAULT_UL = { enabled: false, key_prefix: 'user_skill:' };
|
|
@@ -20,6 +20,24 @@ export const OFFLINE_FALLBACK = {
|
|
|
20
20
|
projects: {},
|
|
21
21
|
user_local: DEFAULT_UL,
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Map a portal response to ResolvedSkill[]. Uses the portal's per-skill
|
|
25
|
+
* metadata when present; for older portals that send names only, falls back
|
|
26
|
+
* to neutral defaults (protected:false) — the budgeting floor then relies on
|
|
27
|
+
* the caller's own knowledge (e.g. OFFLINE_FALLBACK). NEVER fabricate
|
|
28
|
+
* protected:true here: an over-broad floor would defeat budgeting entirely.
|
|
29
|
+
*/
|
|
30
|
+
function toResolvedSkills(resp) {
|
|
31
|
+
if (resp.skills && resp.skills.length > 0) {
|
|
32
|
+
return resp.skills.map((s) => ({
|
|
33
|
+
name: s.name, priority: s.priority, protected: s.protected,
|
|
34
|
+
category: s.category ?? 'universal',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
return resp.loaded.map((name, i) => ({
|
|
38
|
+
name, priority: i, protected: false, category: 'universal',
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
23
41
|
const cache = new Map();
|
|
24
42
|
const inflightMap = new Map();
|
|
25
43
|
function cacheKey(project, prompt) {
|
|
@@ -43,12 +61,45 @@ async function callPortal(project, prompt, role) {
|
|
|
43
61
|
'Content-Type': 'application/json',
|
|
44
62
|
'Accept': 'application/json',
|
|
45
63
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
// Auth precedence: static PRISM_SKILLS_TOKEN (legacy/CI) → JWT exchanged
|
|
65
|
+
// from the synalux API key. The JWT path uses the same per-user identity
|
|
66
|
+
// as inference, so skills and inference resolve the SAME tier — without
|
|
67
|
+
// it, machines with only PRISM_SYNALUX_API_KEY silently resolve tier=free
|
|
68
|
+
// and never receive unprotected/prompt-routed skills.
|
|
69
|
+
const staticToken = process.env.PRISM_SKILLS_TOKEN || '';
|
|
70
|
+
let usedJwt = false;
|
|
71
|
+
if (staticToken) {
|
|
72
|
+
headers['Authorization'] = `Bearer ${staticToken}`;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Bound the exchange so a hanging JWT endpoint cannot stall
|
|
76
|
+
// session_load_context startup: after 4s proceed unauthenticated
|
|
77
|
+
// (free-tier resolve) — the exchange keeps running and its cached
|
|
78
|
+
// result authenticates the next call.
|
|
79
|
+
const jwt = await Promise.race([
|
|
80
|
+
getSynaluxJwt(),
|
|
81
|
+
new Promise((r) => setTimeout(r, 4_000, null)),
|
|
82
|
+
]);
|
|
83
|
+
if (jwt) {
|
|
84
|
+
headers['Authorization'] = `Bearer ${jwt}`;
|
|
85
|
+
usedJwt = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const doFetch = () => fetch(`${SYNALUX_BASE}/api/v1/prism/resolve`, {
|
|
49
89
|
method: 'POST', headers, body: JSON.stringify(body),
|
|
50
90
|
signal: AbortSignal.timeout(5_000),
|
|
91
|
+
redirect: 'error', // never follow a redirect with a credential attached
|
|
51
92
|
});
|
|
93
|
+
let res = await doFetch();
|
|
94
|
+
if (res.status === 401 && usedJwt) {
|
|
95
|
+
// Expired/rotated JWT — invalidate and retry once with a fresh one.
|
|
96
|
+
invalidateSynaluxJwt();
|
|
97
|
+
const fresh = await getSynaluxJwt();
|
|
98
|
+
if (fresh) {
|
|
99
|
+
headers['Authorization'] = `Bearer ${fresh}`;
|
|
100
|
+
res = await doFetch();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
52
103
|
if (!res.ok)
|
|
53
104
|
throw new Error(`HTTP ${res.status}`);
|
|
54
105
|
return (await res.json());
|
|
@@ -92,9 +143,7 @@ export async function resolveSkills(project, prompt, role) {
|
|
|
92
143
|
if (cached) {
|
|
93
144
|
return {
|
|
94
145
|
names: cached.resp.loaded,
|
|
95
|
-
skills: cached.resp
|
|
96
|
-
name, priority: i, protected: false, category: 'universal',
|
|
97
|
-
})),
|
|
146
|
+
skills: toResolvedSkills(cached.resp),
|
|
98
147
|
user_local: DEFAULT_UL,
|
|
99
148
|
isOffline: !cached.live,
|
|
100
149
|
routing_version: cached.resp.routing_version,
|
|
@@ -107,7 +156,7 @@ export async function resolveSkills(project, prompt, role) {
|
|
|
107
156
|
if (stored) {
|
|
108
157
|
const resp = JSON.parse(stored);
|
|
109
158
|
return {
|
|
110
|
-
names: resp.loaded, skills:
|
|
159
|
+
names: resp.loaded, skills: toResolvedSkills(resp), user_local: DEFAULT_UL,
|
|
111
160
|
isOffline: true,
|
|
112
161
|
routing_version: resp.routing_version,
|
|
113
162
|
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* undercounts on repeated system-prompt calls; submittedEst shows actual load.
|
|
13
13
|
*/
|
|
14
14
|
import { debugLog } from "./logger.js";
|
|
15
|
+
import { appendInferMetric, queryInferMetrics } from "../storage/inferMetricsLedger.js";
|
|
15
16
|
// T1 fix: content-aware token estimator. Replaces flat text.length / 4 which
|
|
16
17
|
// underestimates emoji (~2 UTF-16 units but 1.5-2.5 BPE tokens) and CJK
|
|
17
18
|
// (~1 char ≈ 1 token) by 15-40%, and overestimates dense code (~3.3 chars/token).
|
|
@@ -52,6 +53,19 @@ export function recordPromptSeen(delegated) {
|
|
|
52
53
|
export function recordInference(result) {
|
|
53
54
|
if (result.backend === "safety_gate")
|
|
54
55
|
return;
|
|
56
|
+
// Durable ledger row (fire-and-forget; in-memory counters below remain the
|
|
57
|
+
// per-session view). safety_gate is excluded by the early return above.
|
|
58
|
+
appendInferMetric({
|
|
59
|
+
backend: result.backend,
|
|
60
|
+
model: result.model_picked,
|
|
61
|
+
used_cloud: result.used_cloud,
|
|
62
|
+
mode: result.mode,
|
|
63
|
+
gate_outcome: result.quality_gate_failed ? "gate_failed_served" : undefined,
|
|
64
|
+
prompt_tokens: result.prompt_tokens,
|
|
65
|
+
completion_tokens: result.completion_tokens,
|
|
66
|
+
latency_ms: result.latency_ms,
|
|
67
|
+
ram_free_mb: result.ram_free_mb,
|
|
68
|
+
});
|
|
55
69
|
const key = result.model_picked ?? result.backend;
|
|
56
70
|
if (result.used_cloud) {
|
|
57
71
|
cloudCalls++;
|
|
@@ -135,7 +149,30 @@ export function resetInferenceMetrics() {
|
|
|
135
149
|
}
|
|
136
150
|
debugLog("[inference-metrics] Session metrics reset");
|
|
137
151
|
}
|
|
138
|
-
export async function inferenceMetricsHandler() {
|
|
152
|
+
export async function inferenceMetricsHandler(args) {
|
|
153
|
+
if (args?.period === "all") {
|
|
154
|
+
const agg = await queryInferMetrics();
|
|
155
|
+
if (!agg || agg.total === 0) {
|
|
156
|
+
return { content: [{ type: "text", text: "No persisted prism_infer calls yet (ledger empty)." }] };
|
|
157
|
+
}
|
|
158
|
+
const localPct = agg.total ? Math.round((agg.local / agg.total) * 100) : 0;
|
|
159
|
+
const cloudPct = agg.total ? Math.round((agg.cloud / agg.total) * 100) : 0;
|
|
160
|
+
const span = agg.first_ts && agg.last_ts
|
|
161
|
+
? `${new Date(agg.first_ts).toISOString().slice(0, 10)} → ${new Date(agg.last_ts).toISOString().slice(0, 10)}`
|
|
162
|
+
: "n/a";
|
|
163
|
+
const byB = Object.entries(agg.by_backend)
|
|
164
|
+
.sort((a, b) => b[1] - a[1])
|
|
165
|
+
.map(([k, v]) => ` ${k}: ${v}`).join("\n");
|
|
166
|
+
return {
|
|
167
|
+
content: [{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: `📊 Delegation Metrics — ALL TIME (persisted ledger, ${span})\n` +
|
|
170
|
+
`Total calls: ${agg.total} — Local: ${agg.local} (${localPct}%) | Cloud: ${agg.cloud} (${cloudPct}%)\n` +
|
|
171
|
+
`Prompt tokens: ${agg.prompt_tokens} | Completion tokens: ${agg.completion_tokens}\n` +
|
|
172
|
+
`Avg latency: ${agg.avg_latency_ms}ms\nBy backend:\n${byB}`,
|
|
173
|
+
}],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
139
176
|
const block = formatInferenceMetrics();
|
|
140
177
|
return {
|
|
141
178
|
content: [{
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill-delivery budgeting — makes the skill block honor the caller's
|
|
3
|
+
* max_tokens budget instead of inlining every resolved skill.
|
|
4
|
+
*
|
|
5
|
+
* Why: paid-tier resolution returns 30+ skills (~114KB measured). Unbudgeted
|
|
6
|
+
* inlining exceeds host tool-result caps, and hosts divert the WHOLE response
|
|
7
|
+
* to a file — the agent receives none of it. A budgeted block that fits is
|
|
8
|
+
* strictly more context than an unbudgeted one that gets diverted.
|
|
9
|
+
*
|
|
10
|
+
* Policy (in fill order):
|
|
11
|
+
* 1. protected skills — ALWAYS inlined in full, even over budget. This is
|
|
12
|
+
* the documented floor (2026-06-13 incident: silent truncation stripped
|
|
13
|
+
* the agent's core behavioral rules; "protected" exists to prevent that).
|
|
14
|
+
* 2. prompt-category skills — they matched THIS prompt's keywords; they are
|
|
15
|
+
* usually the reason the caller passed `prompt` at all.
|
|
16
|
+
* 3. everything else (unprotected universal, project, role) by ascending
|
|
17
|
+
* priority — while budget remains.
|
|
18
|
+
* Skills that do not fit are NEVER silently dropped: they are listed in an
|
|
19
|
+
* overflow manifest so the agent can read them on demand or re-load with a
|
|
20
|
+
* higher max_tokens.
|
|
21
|
+
*/
|
|
22
|
+
function fillOrder(a, b) {
|
|
23
|
+
const rank = (e) => e.protected ? 0 : e.category === "prompt" ? 1 : e.category === "role" ? 2 : 3;
|
|
24
|
+
return rank(a) - rank(b) || a.priority - b.priority;
|
|
25
|
+
}
|
|
26
|
+
function render(e) {
|
|
27
|
+
const label = e.category === "role" ? "ROLE SKILL" : "SKILL";
|
|
28
|
+
return `\n\n[📜 ${label}: ${e.name}]\n${e.content.trim()}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Assemble the skill block within `budgetChars`. `budgetChars` ≤ 0 or
|
|
32
|
+
* non-finite means unbudgeted (legacy behavior: inline everything).
|
|
33
|
+
*/
|
|
34
|
+
export function assembleSkillBlock(entries, budgetChars) {
|
|
35
|
+
const ordered = [...entries].sort(fillOrder);
|
|
36
|
+
const unbudgeted = !Number.isFinite(budgetChars) || budgetChars <= 0;
|
|
37
|
+
let block = "";
|
|
38
|
+
const inlined = [];
|
|
39
|
+
const overflow = [];
|
|
40
|
+
for (const e of ordered) {
|
|
41
|
+
const piece = render(e);
|
|
42
|
+
// Protected always inline; others only while they fit.
|
|
43
|
+
if (unbudgeted || e.protected || block.length + piece.length <= budgetChars) {
|
|
44
|
+
block += piece;
|
|
45
|
+
inlined.push(e.name);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
overflow.push(e.name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (overflow.length > 0) {
|
|
52
|
+
block +=
|
|
53
|
+
`\n\n[📦 SKILLS NOT INLINED — max_tokens budget reached]\n` +
|
|
54
|
+
`${overflow.join(", ")}\n` +
|
|
55
|
+
`To inline them, re-call session_load_context with a higher max_tokens.`;
|
|
56
|
+
}
|
|
57
|
+
return { block, inlined, overflow };
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.0.
|
|
3
|
+
"version": "20.0.8",
|
|
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",
|