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 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
- skillBlock = `\n\n[📜 ROLE SKILL: ${effectiveRole}]\n${skillContent.trim()}`;
913
- skillLoaded = true;
914
- loadedSkills.push(effectiveRole);
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 (loadedSkills.includes(name))
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
- skillBlock += `\n\n[📜 SKILL: ${name}]\n${content.trim()}`;
937
- loadedSkills.push(name);
938
- skillLoaded = true;
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 (loadedSkills.includes(name))
960
+ if (skillEntries.some((e) => e.name === name))
952
961
  continue;
953
962
  const content = v.trim();
954
- if (content && content.trim()) {
955
- skillBlock += '\n\n[📜 SKILL: ' + name + ']\n' + content.trim();
956
- loadedSkills.push(name);
957
- skillLoaded = true;
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
- async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
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: JSON.stringify({ prompt, max_tokens: maxTokens }),
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: JSON.stringify({ prompt, max_tokens: maxTokens }),
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 new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
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
- recordInference({ ...result, prompt_text: args.prompt });
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 the current session's local-model inference metrics — call count, " +
1822
- "local vs cloud split, token totals, per-model breakdown, and average latency. " +
1823
- "Read-only, no arguments. Reflects prism_infer delegation usage only, not the " +
1824
- "host model's (Claude's) own token spend (use /cost for that).",
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
- if (SKILLS_TOKEN)
47
- headers['Authorization'] = `Bearer ${SKILLS_TOKEN}`;
48
- const res = await fetch(`${SYNALUX_BASE}/api/v1/prism/resolve`, {
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.loaded.map((name, i) => ({
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: [], user_local: DEFAULT_UL,
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.6",
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",