prism-mcp-server 19.2.7 → 19.2.9

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.
@@ -22,14 +22,15 @@
22
22
  import { pickLocalModel, fmtGb, MODEL_TIERS, resolveOllamaName } from "../utils/modelPicker.js";
23
23
  import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
24
24
  import { getAvailableMemoryBytes } from "../utils/availableMemory.js";
25
- import { PRISM_SYNALUX_BASE_URL, PRISM_LOCAL_LLM_URL, } from "../config.js";
25
+ import { PRISM_SYNALUX_BASE_URL, PRISM_LOCAL_LLM_URL, SYNALUX_CONFIGURED, } from "../config.js";
26
26
  import { debugLog } from "../utils/logger.js";
27
27
  import { getEntitlements, clampCeiling } from "../utils/entitlements.js";
28
28
  import { ddLog } from "../utils/ddLogger.js";
29
29
  import { stripThink } from "../utils/thinkStrip.js";
30
30
  import { passesQualityGate } from "../utils/qualityGate.js";
31
31
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
32
- import { recordInference } from "../utils/inferenceMetrics.js";
32
+ import { callLayer1 as defaultCallLayer1 } from "../utils/layer1.js";
33
+ import { recordInference, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
33
34
  // ─── Tool Definition ────────────────────────────────────────────
34
35
  export const PRISM_INFER_TOOL = {
35
36
  name: "prism_infer",
@@ -299,6 +300,48 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
299
300
  return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "synalux_timeout" : "synalux_network" };
300
301
  }
301
302
  }
303
+ // ─── Portal verifier (thin-client HTTP call) ──────────────────
304
+ async function callSynaluxVerifier(opts) {
305
+ if (!PRISM_SYNALUX_BASE_URL)
306
+ throw new Error("no_synalux_base_url");
307
+ const jwt = await getSynaluxJwt();
308
+ if (!jwt)
309
+ throw new Error("jwt_exchange_failed");
310
+ const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/verify-grounding`;
311
+ const res = await fetch(url, {
312
+ method: "POST",
313
+ headers: {
314
+ "Authorization": `Bearer ${jwt}`,
315
+ "Content-Type": "application/json",
316
+ },
317
+ body: JSON.stringify({
318
+ draft: opts.draft,
319
+ evidence: opts.evidence,
320
+ verifierModel: opts.verifierModel,
321
+ // Give portal 500ms headroom before our own AbortSignal fires.
322
+ timeoutMs: Math.max(500, (opts.timeoutMs ?? 5_000) - 500),
323
+ }),
324
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),
325
+ redirect: "error",
326
+ });
327
+ if (!res.ok)
328
+ throw new Error(`synalux_verifier_http_${res.status}`);
329
+ return res.json();
330
+ }
331
+ // In-process mutex that serialises eviction so concurrent requests don't evict
332
+ // a model that another in-flight inference is actively using (F3 fix).
333
+ const _evictionMutex = (() => {
334
+ let _lock = Promise.resolve();
335
+ return {
336
+ acquire() {
337
+ let release;
338
+ const next = new Promise(resolve => { release = resolve; });
339
+ const chain = _lock.then(() => release);
340
+ _lock = _lock.then(() => next);
341
+ return chain;
342
+ },
343
+ };
344
+ })();
302
345
  export async function runInfer(args, deps) {
303
346
  const t0 = Date.now();
304
347
  const temperature = args.temperature ?? 0;
@@ -366,14 +409,105 @@ export async function runInfer(args, deps) {
366
409
  if (installed === null) {
367
410
  attempts.push({ tier: "ollama_probe", reason: "unreachable" });
368
411
  }
412
+ // ── §E Layer 1 semantic pre-classifier ──────────────────────────────────
413
+ // Catches adversarial paraphrases the keyword stub misses.
414
+ // Runs only when cloud escalation is possible — without cloud, there is
415
+ // nowhere to route a RESERVED verdict.
416
+ // Recursion guard: skip when this call IS the Layer 1 classification
417
+ // (mode="route" + max_tokens<=16 is the Layer 1 call signature).
418
+ const layer1RecursionGuard = mode === "route" && maxTokens <= 16;
419
+ if (allowCloud && !layer1RecursionGuard) {
420
+ const l1fn = deps.callLayer1 ?? defaultCallLayer1;
421
+ const l1Model = resolveOllamaName("prism-coder:4b", installed ?? new Set());
422
+ const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
423
+ if (l1 !== "OBVIOUS_NOT_RESERVED") {
424
+ debugLog(`[prism_infer] Layer 1 verdict=${l1} — escalating to cloud`);
425
+ attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
426
+ const cloudTimeout = args.timeout_ms ?? 90_000;
427
+ const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
428
+ if (cloud.ok && cloud.output) {
429
+ return await applyVerification(cloud.output, gatedArgs, deps, {
430
+ backend: cloud.backend ?? "synalux",
431
+ model_picked: null,
432
+ ram_free_mb: ramFreeMb,
433
+ latency_ms: Date.now() - t0,
434
+ used_cloud: true,
435
+ attempts,
436
+ plan: ent.plan,
437
+ completion_tokens: Math.ceil(cloud.output.length / 4),
438
+ });
439
+ }
440
+ attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
441
+ // Layer 1 flagged RESERVED but cloud unavailable — fail closed, never fall through to local.
442
+ throw new Error(`prism_infer: Layer 1 verdict=${l1} but cloud unavailable. attempts=${JSON.stringify(attempts)}`);
443
+ }
444
+ debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
445
+ }
446
+ // ── end Layer 1 ─────────────────────────────────────────────────────────
369
447
  // Walk the tier table top → bottom, capped by model_ceiling. Each tier
370
448
  // logs its skip reason ("not_pulled" / "ram_insufficient" / fail reason)
371
449
  // so the caller can see exactly why each tier was bypassed.
372
450
  let localDraft = null;
373
451
  if (installed) {
374
- const ceilStart = effectiveCeiling
375
- ? Math.max(0, MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`)))
376
- : 0;
452
+ // F4 fix: guard ceiling-not-found — Math.max(0,-1) silently targets tier 0 (27b).
453
+ // Instead of defaulting to the largest tier, treat not-found as "no ceiling" (start=0).
454
+ const ceilIdx = effectiveCeiling
455
+ ? MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`))
456
+ : -1;
457
+ const ceilStart = ceilIdx >= 0 ? ceilIdx : 0;
458
+ // Auto-evict: if the ceiling tier is installed but not warm and prism's
459
+ // own smaller tier models are warm, unload them to make room.
460
+ // Operates only on prism tier models — never evicts arbitrary Ollama models
461
+ // the caller doesn't own (F1). Uses an in-process mutex to prevent a
462
+ // concurrent request from evicting a model mid-inference (F3).
463
+ let freeAfterEvict = freeBytes;
464
+ if (loaded && loaded.size > 0) {
465
+ const ceilTier = MODEL_TIERS[ceilIdx >= 0 ? ceilIdx : 0];
466
+ const ceilName = ceilTier ? resolveOllamaName(ceilTier.tag, installed) : null;
467
+ const ceilInstalled = ceilName ? installed.has(ceilName) : false;
468
+ const ceilWarm = ceilName ? loaded.has(ceilName) : false;
469
+ if (ceilInstalled && !ceilWarm) {
470
+ // F1 fix: only count and evict prism tier models — not arbitrary warm models.
471
+ const tierModelsToEvict = MODEL_TIERS
472
+ .map(t => resolveOllamaName(t.tag, installed))
473
+ .filter(name => loaded.has(name));
474
+ const tierWarmBytes = tierModelsToEvict.reduce((sum, name) => {
475
+ const t = MODEL_TIERS.find(t => resolveOllamaName(t.tag, installed) === name);
476
+ return sum + (t ? t.weightsGb * 1024 ** 3 : 0);
477
+ }, 0);
478
+ if (freeBytes + tierWarmBytes >= ceilTier.minFreeGb * 1024 ** 3) {
479
+ // F3 fix: hold eviction mutex so no concurrent request evicts a model
480
+ // that another in-flight inference is actively using.
481
+ const released = await _evictionMutex.acquire();
482
+ try {
483
+ // F2 fix: await each evict call; log failures; don't proceed blind.
484
+ const evictResults = await Promise.allSettled(tierModelsToEvict.map(m => fetch(`${deps.ollamaUrl}/api/generate`, {
485
+ method: "POST",
486
+ body: JSON.stringify({ model: m, keep_alive: 0 }),
487
+ signal: AbortSignal.timeout(3_000),
488
+ })));
489
+ const failed = evictResults.filter(r => r.status === "rejected").length;
490
+ if (failed > 0) {
491
+ debugLog(`[prism_infer] evict: ${failed}/${tierModelsToEvict.length} unload requests failed`);
492
+ }
493
+ // Settle: give Ollama time to release buffers before re-reading RAM.
494
+ await new Promise(r => setTimeout(r, 800));
495
+ freeAfterEvict = deps.freemem();
496
+ debugLog(`[prism_infer] auto-evicted ${tierModelsToEvict.join(", ")} ` +
497
+ `(${fmtGb(tierWarmBytes)}) → freeAfterEvict=${fmtGb(freeAfterEvict)}`);
498
+ // F2 fix: if still insufficient after eviction, log and fall through
499
+ // cleanly — the tier loop will emit ram_insufficient rather than
500
+ // proceeding on a stale freeBytes value.
501
+ if (freeAfterEvict < ceilTier.minFreeGb * 1024 ** 3) {
502
+ debugLog(`[prism_infer] evict completed but RAM still insufficient for ${ceilTier.tag}`);
503
+ }
504
+ }
505
+ finally {
506
+ released();
507
+ }
508
+ }
509
+ }
510
+ }
377
511
  let anyViable = false;
378
512
  for (let i = ceilStart; i < MODEL_TIERS.length; i++) {
379
513
  const tier = MODEL_TIERS[i];
@@ -389,7 +523,7 @@ export async function runInfer(args, deps) {
389
523
  // RAM gate — but skip the check if the tier is already warm in
390
524
  // Ollama. Reused models don't reallocate weight buffers.
391
525
  const isWarm = loaded.has(ollamaName);
392
- if (!isWarm && freeBytes < tier.minFreeGb * (1024 ** 3)) {
526
+ if (!isWarm && freeAfterEvict < tier.minFreeGb * (1024 ** 3)) {
393
527
  attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
394
528
  continue;
395
529
  }
@@ -400,20 +534,18 @@ export async function runInfer(args, deps) {
400
534
  if (result.ok) {
401
535
  const { stripped, thinkOnly } = stripThink(result.text);
402
536
  const output = stripped;
403
- // Quality gate for chat/code modes
404
- if (mode !== "route") {
405
- const gate = passesQualityGate(output, thinkOnly, result.doneReason);
406
- if (!gate.pass && allowCloud) {
407
- debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
408
- attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
409
- if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
410
- localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
411
- }
412
- break;
413
- }
414
- if (!gate.pass) {
415
- debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
537
+ // Quality gate all modes. Route uses mode-aware empty floor (length===0).
538
+ const gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
539
+ if (!gate.pass && allowCloud) {
540
+ debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) escalating to cloud`);
541
+ attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
542
+ if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
543
+ localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
416
544
  }
545
+ break;
546
+ }
547
+ if (!gate.pass) {
548
+ debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
417
549
  }
418
550
  return await applyVerification(output, gatedArgs, deps, {
419
551
  backend: `ollama-${tier.tag.replace("prism-coder:", "")}`,
@@ -448,7 +580,9 @@ export async function runInfer(args, deps) {
448
580
  used_cloud: true,
449
581
  attempts,
450
582
  plan: ent.plan,
451
- prompt_tokens: Math.ceil(args.prompt.length / 4),
583
+ // T4: omit prompt_tokens — cloud doesn't return Ollama actual eval count.
584
+ // recordInference receives prompt_text and computes submittedEst via
585
+ // estimateTokens(), keeping promptTokensEvaluated=0 (correct for cloud).
452
586
  completion_tokens: Math.ceil(cloud.output.length / 4),
453
587
  });
454
588
  }
@@ -524,10 +658,13 @@ export async function prismInferHandler(args) {
524
658
  callLocal: callOllamaGenerate,
525
659
  callCloud: callSynaluxInference,
526
660
  ollamaUrl: PRISM_LOCAL_LLM_URL,
661
+ callVerifier: SYNALUX_CONFIGURED ? callSynaluxVerifier : undefined,
527
662
  });
528
663
  debugLog(`[prism_infer] backend=${result.backend} model=${result.model_picked} latency=${result.latency_ms}ms free=${result.ram_free_mb}MB`);
529
664
  // Local accumulator — sole source of the user-facing metrics block.
530
- recordInference(result);
665
+ // T4: pass prompt_text so recordInference computes submittedEst via
666
+ // estimateTokens() — critical for cloud path where prompt_tokens is unset.
667
+ recordInference({ ...result, prompt_text: args.prompt });
531
668
  // Best-effort portal forwarding (independent analytics stream).
532
669
  // safety_gate excluded — logging crisis filter triggers is a HIPAA concern.
533
670
  if (result.backend !== "safety_gate") {
@@ -543,7 +680,7 @@ export async function prismInferHandler(args) {
543
680
  const tokenStr = result.prompt_tokens != null || result.completion_tokens != null
544
681
  ? ` tokens=${result.prompt_tokens ?? "?"}in/${result.completion_tokens ?? "?"}out`
545
682
  : "";
546
- const header = `[prism_infer] backend=${result.backend}` +
683
+ const headerBase = `[prism_infer] backend=${result.backend}` +
547
684
  ` model=${result.model_picked ?? "n/a"}` +
548
685
  ` plan=${result.plan ?? "unknown"}` +
549
686
  ` free_ram=${result.ram_free_mb}MB` +
@@ -553,6 +690,11 @@ export async function prismInferHandler(args) {
553
690
  (result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
554
691
  (result.verification ? ` verify=${result.verification.action}` : "") +
555
692
  (result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
693
+ // Append periodic session-level stats to the header line.
694
+ // compact=true is threshold-gated (PRISM_METRICS_EVERY, default every 5 calls)
695
+ // so it doesn't appear on every response — only as a rolling summary.
696
+ const metricsLine = formatInferenceMetrics(true);
697
+ const header = metricsLine ? `${headerBase}\n${metricsLine}` : headerBase;
556
698
  return {
557
699
  content: [
558
700
  { type: "text", text: header },
@@ -5,7 +5,7 @@
5
5
  * (POST /api/v1/prism/memory action=detect_drift) which owns the embedding
6
6
  * + detection logic, and returns the structured result.
7
7
  *
8
- * Prism-mcp never does NLP or embedding here — that lives in synalux-private.
8
+ * Prism-mcp never does NLP or embedding here — that is portal-side.
9
9
  */
10
10
  import { isSessionDetectDriftArgs } from "./sessionMemoryDefinitions.js";
11
11
  import { getStorage } from "../storage/index.js";
@@ -9,9 +9,8 @@
9
9
  * free-tier / disconnected installations still get the BCBA universal
10
10
  * skill loaded.
11
11
  *
12
- * To change the routing for production, edit
13
- * synalux-private/portal/src/app/api/v1/skills/routing/route.ts
14
- * and deploy synalux. prism-mcp picks up the new config within 5 minutes.
12
+ * To change the routing for production, edit the portal routing endpoint
13
+ * and deploy. prism-mcp picks up the new config within 5 minutes.
15
14
  *
16
15
  * Do NOT add hardcoded skill names here outside the OFFLINE_FALLBACK block
17
16
  * — that defeats the single-source-of-truth design.
@@ -28,7 +27,10 @@ const OFFLINE_FALLBACK = {
28
27
  user_local: { enabled: false, key_prefix: 'user_skill:' },
29
28
  };
30
29
  const SYNALUX_BASE = process.env.SYNALUX_BASE_URL || 'https://synalux.ai';
31
- const CACHE_TTL_MS = 5 * 60 * 1000;
30
+ const LIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5min for successful fetches
31
+ const FAILURE_BACKOFF_MS = 30_000; // F5 fix: 30s retry after failure (not 5min)
32
+ // F5 fix: track live vs fallback separately so a transient failure doesn't
33
+ // negative-cache the full routing table for 5 minutes, dropping 19 skills.
32
34
  let cached = null;
33
35
  let inflight = null;
34
36
  async function fetchOnce() {
@@ -51,7 +53,9 @@ async function fetchOnce() {
51
53
  return body;
52
54
  }
53
55
  catch {
54
- return OFFLINE_FALLBACK;
56
+ // On failure: return the last live table (stale-while-revalidate) or OFFLINE_FALLBACK.
57
+ // The caller marks this as isLive=false so it retries after FAILURE_BACKOFF_MS, not 5min.
58
+ return cached?.table ?? OFFLINE_FALLBACK;
55
59
  }
56
60
  }
57
61
  /**
@@ -68,15 +72,21 @@ function normalizeEntry(entry, defaultPriority) {
68
72
  }
69
73
  export async function resolveSkillsForProject(project) {
70
74
  const now = Date.now();
71
- if (!cached || now - cached.fetchedAt > CACHE_TTL_MS) {
75
+ // F5 fix: use shorter backoff TTL after failures so a 30s synalux hiccup doesn't
76
+ // lock the session into OFFLINE_FALLBACK for 5 minutes.
77
+ const ttl = (cached?.isLive ?? true) ? LIVE_CACHE_TTL_MS : FAILURE_BACKOFF_MS;
78
+ if (!cached || now - cached.fetchedAt > ttl) {
72
79
  if (!inflight) {
73
80
  inflight = fetchOnce().then((table) => {
74
- cached = { table, fetchedAt: Date.now() };
81
+ // isLive = true only when we got a live response (not stale cache / OFFLINE_FALLBACK)
82
+ const isLive = table !== OFFLINE_FALLBACK && table !== cached?.table;
83
+ cached = { table, fetchedAt: Date.now(), isLive };
75
84
  return table;
76
85
  }).finally(() => { inflight = null; });
77
86
  }
78
87
  await inflight;
79
88
  }
89
+ const isOffline = !(cached?.isLive ?? false);
80
90
  const table = cached.table;
81
91
  const seen = new Set();
82
92
  const skills = [];
@@ -104,6 +114,7 @@ export async function resolveSkillsForProject(project) {
104
114
  names: skills.map(s => s.name),
105
115
  skills,
106
116
  user_local: table.user_local ?? OFFLINE_FALLBACK.user_local,
117
+ isOffline,
107
118
  };
108
119
  }
109
120
  /**
@@ -113,10 +124,12 @@ export async function resolveSkillsForProject(project) {
113
124
  */
114
125
  export async function resolveSkillsForPrompt(prompt, baseSkills = []) {
115
126
  const now = Date.now();
116
- if (!cached || now - cached.fetchedAt > CACHE_TTL_MS) {
127
+ const ttl = (cached?.isLive ?? true) ? LIVE_CACHE_TTL_MS : FAILURE_BACKOFF_MS;
128
+ if (!cached || now - cached.fetchedAt > ttl) {
117
129
  if (!inflight) {
118
130
  inflight = fetchOnce().then((table) => {
119
- cached = { table, fetchedAt: Date.now() };
131
+ const isLive = table !== OFFLINE_FALLBACK && table !== cached?.table;
132
+ cached = { table, fetchedAt: Date.now(), isLive };
120
133
  return table;
121
134
  }).finally(() => { inflight = null; });
122
135
  }
@@ -93,6 +93,15 @@ async function fetchEntitlements() {
93
93
  return cache.entitlements;
94
94
  return FREE_ENTITLEMENTS;
95
95
  }
96
+ // Normalize legacy ceiling values to the current fleet.
97
+ if (data.model_ceiling === "14b") {
98
+ debugLog("[entitlements] grandfathered 14b ceiling → 9b");
99
+ data.model_ceiling = "9b";
100
+ }
101
+ if (data.model_ceiling === "32b") {
102
+ debugLog("[entitlements] grandfathered 32b ceiling → 27b");
103
+ data.model_ceiling = "27b";
104
+ }
96
105
  debugLog(`[entitlements] plan=${data.plan} ceiling=${data.model_ceiling} ` +
97
106
  `daily=${data.daily_infer_limit} max_tokens=${data.max_tokens}`);
98
107
  return data;
@@ -1,20 +1,40 @@
1
1
  /**
2
2
  * Inference metrics — local accumulator for user-facing display.
3
3
  *
4
- * The local accumulator is the SOLE source for the session metrics block
5
- * shown in session_save_ledger/handoff. It tracks what THIS prism process
6
- * did THIS session — prism is the natural and only complete source for
7
- * this data (the portal only sees what prism forwards).
4
+ * Tracks what THIS prism process did THIS session. Portal forwarding
5
+ * (ddLog) is a separate best-effort stream display never depends on it.
8
6
  *
9
- * Portal forwarding (ddLog /api/v1/telemetry) is a separate, best-effort
10
- * analytics stream that the display path never depends on. If the portal
11
- * is down, unconfigured, or the token is missing, users still see metrics.
7
+ * T1 fix: content-aware chars/token estimator (was flat /4, biased for
8
+ * emoji-dense/code/CJK payloads by 15–40%).
9
+ * T2 fix: dual-column prompt tokens `evaluated` (Ollama actual) vs
10
+ * `submittedEst` (estimated submitted, including KV-cached prefixes).
11
+ * Ollama returns prompt_eval_count=0 for cached prompts, so "evaluated"
12
+ * undercounts on repeated system-prompt calls; submittedEst shows actual load.
12
13
  */
13
14
  import { debugLog } from "./logger.js";
15
+ // T1 fix: content-aware token estimator. Replaces flat text.length / 4 which
16
+ // underestimates emoji (~2 UTF-16 units but 1.5-2.5 BPE tokens) and CJK
17
+ // (~1 char ≈ 1 token) by 15-40%, and overestimates dense code (~3.3 chars/token).
18
+ export function estimateTokens(text) {
19
+ if (!text)
20
+ return 0;
21
+ const cjkCount = (text.match(/[ -鿿豈-﫿]/g) ?? []).length;
22
+ const emojiCount = (text.match(/[\u{1F000}-\u{1FFFF}]/gu) ?? []).length;
23
+ // Code density check: >2% of chars are code punctuation → use code divisor
24
+ const codePunct = (text.match(/[`{};\[\]=>|#@$%^&*\\]/g) ?? []).length;
25
+ const isCode = text.length > 0 && codePunct / text.length > 0.02;
26
+ // UTF-16 length minus CJK and emoji codepoints (emoji are 2 units each)
27
+ const latinLen = text.length - cjkCount - emojiCount * 2;
28
+ const latinTokens = latinLen / (isCode ? 3.3 : 4.0);
29
+ const cjkTokens = cjkCount; // ~1 token per CJK char
30
+ const emojiTokens = emojiCount * 1.5; // ~1.5 BPE tokens per emoji
31
+ return Math.ceil(Math.max(0, latinTokens) + cjkTokens + emojiTokens);
32
+ }
14
33
  const byModel = {};
15
34
  let localCalls = 0;
16
35
  let cloudCalls = 0;
17
- let totalPromptTokens = 0;
36
+ let promptTokensEvaluated = 0;
37
+ let promptTokensSubmittedEst = 0;
18
38
  let totalCompletionTokens = 0;
19
39
  let totalLatencyMs = 0;
20
40
  export function recordInference(result) {
@@ -27,16 +47,35 @@ export function recordInference(result) {
27
47
  else {
28
48
  localCalls++;
29
49
  }
30
- const pt = result.prompt_tokens ?? 0;
50
+ const evaluated = result.prompt_tokens ?? 0;
31
51
  const ct = result.completion_tokens ?? 0;
32
- totalPromptTokens += pt;
52
+ // T2: when Ollama returns 0 evaluated (KV-cache hit), estimate submitted tokens
53
+ // from the prompt text/length so submittedEst reflects actual context load.
54
+ let submittedEst = evaluated; // default: evaluated is the best estimate
55
+ if (evaluated === 0 && !result.used_cloud) {
56
+ if (result.prompt_text) {
57
+ submittedEst = estimateTokens(result.prompt_text);
58
+ }
59
+ else if (result.prompt_length && result.prompt_length > 0) {
60
+ submittedEst = Math.ceil(result.prompt_length / 4); // flat fallback without text
61
+ }
62
+ }
63
+ promptTokensEvaluated += evaluated;
64
+ promptTokensSubmittedEst += submittedEst;
33
65
  totalCompletionTokens += ct;
34
66
  totalLatencyMs += result.latency_ms;
35
67
  if (!byModel[key]) {
36
- byModel[key] = { calls: 0, promptTokens: 0, completionTokens: 0, totalLatencyMs: 0 };
68
+ byModel[key] = {
69
+ calls: 0,
70
+ promptTokensEvaluated: 0,
71
+ promptTokensSubmittedEst: 0,
72
+ completionTokens: 0,
73
+ totalLatencyMs: 0,
74
+ };
37
75
  }
38
76
  byModel[key].calls++;
39
- byModel[key].promptTokens += pt;
77
+ byModel[key].promptTokensEvaluated += evaluated;
78
+ byModel[key].promptTokensSubmittedEst += submittedEst;
40
79
  byModel[key].completionTokens += ct;
41
80
  byModel[key].totalLatencyMs += result.latency_ms;
42
81
  }
@@ -52,9 +91,10 @@ export function getInferenceSnapshot() {
52
91
  totalCalls: total,
53
92
  localPct: total > 0 ? Math.round((localCalls / total) * 100) : 0,
54
93
  cloudPct: total > 0 ? 100 - Math.round((localCalls / total) * 100) : 0,
55
- totalPromptTokens,
94
+ promptTokensEvaluated,
95
+ promptTokensSubmittedEst,
56
96
  totalCompletionTokens,
57
- totalTokens: totalPromptTokens + totalCompletionTokens,
97
+ totalTokens: promptTokensSubmittedEst + totalCompletionTokens,
58
98
  avgLatencyMs: total > 0 ? Math.round(totalLatencyMs / total) : 0,
59
99
  byModel: modelCopy,
60
100
  };
@@ -62,7 +102,8 @@ export function getInferenceSnapshot() {
62
102
  export function resetInferenceMetrics() {
63
103
  localCalls = 0;
64
104
  cloudCalls = 0;
65
- totalPromptTokens = 0;
105
+ promptTokensEvaluated = 0;
106
+ promptTokensSubmittedEst = 0;
66
107
  totalCompletionTokens = 0;
67
108
  totalLatencyMs = 0;
68
109
  for (const key of Object.keys(byModel)) {
@@ -75,27 +116,54 @@ export async function inferenceMetricsHandler() {
75
116
  return {
76
117
  content: [{
77
118
  type: "text",
78
- text: block || "No prism_infer calls this session. Metrics track local-model delegation only — not the host model's (Claude's) token spend.",
119
+ text: block || "No prism_infer calls this session.\n" +
120
+ "📊 Delegation Metrics track local-model delegation — not the host model's (Claude's) spend.",
79
121
  }],
80
122
  };
81
123
  }
82
- export function formatInferenceMetrics() {
124
+ /**
125
+ * Format inference metrics.
126
+ *
127
+ * @param compact - When true, returns a single-line footer for appending to
128
+ * prism_infer responses. Output is threshold-gated: only emits every
129
+ * PRISM_METRICS_EVERY calls (default 5) so it doesn't drown per-response output.
130
+ * When false (default), returns the full multi-line block used by the explicit
131
+ * inference_metrics tool.
132
+ */
133
+ export function formatInferenceMetrics(compact = false) {
83
134
  const snap = getInferenceSnapshot();
84
135
  if (snap.totalCalls === 0)
85
136
  return "";
137
+ if (compact) {
138
+ // Threshold gate: only emit every N calls so the footer is periodic, not per-call noise.
139
+ // The per-call header already shows backend/model/latency; this is the session rollup.
140
+ const every = parseInt(process.env["PRISM_METRICS_EVERY"] ?? "5", 10);
141
+ // Always emit on the first call (totalCalls===1) so short sessions (1–4 calls)
142
+ // see at least one rollup. Otherwise emit every N calls as the rolling summary.
143
+ if (snap.totalCalls !== 1 && snap.totalCalls % every !== 0)
144
+ return "";
145
+ return `📊 local ${snap.localCalls} (${snap.localPct}%) · cloud ${snap.cloudCalls} (${snap.cloudPct}%) · ~${snap.totalTokens.toLocaleString()} tok · avg ${snap.avgLatencyMs}ms`;
146
+ }
147
+ // Full multi-line block (explicit inference_metrics tool call).
148
+ // T2: show both evaluated (Ollama actual) and submitted estimate.
149
+ // When they differ, the gap is KV-cached prompt tokens (real load, not counted by Ollama).
150
+ const promptLine = snap.promptTokensEvaluated !== snap.promptTokensSubmittedEst
151
+ ? ` Prompt tokens: ${snap.promptTokensEvaluated.toLocaleString()} evaluated / ${snap.promptTokensSubmittedEst.toLocaleString()} submitted est.`
152
+ : ` Prompt tokens: ${snap.promptTokensEvaluated.toLocaleString()}`;
86
153
  const lines = [
87
- `\n📊 Inference Metrics — local-model delegation (this session):`,
154
+ `\n📊 Delegation Metrics — local-model calls this session (not host model spend):`,
88
155
  ` Total calls: ${snap.totalCalls} — Local: ${snap.localCalls} (${snap.localPct}%) | Cloud: ${snap.cloudCalls} (${snap.cloudPct}%)`,
89
- ` Tokens: ${snap.totalPromptTokens.toLocaleString()} in + ${snap.totalCompletionTokens.toLocaleString()} out = ${snap.totalTokens.toLocaleString()} total`,
156
+ promptLine,
157
+ ` Completion tokens: ${snap.totalCompletionTokens.toLocaleString()}`,
90
158
  ` Avg latency: ${snap.avgLatencyMs}ms`,
91
159
  ];
92
160
  const models = Object.entries(snap.byModel).sort((a, b) => b[1].calls - a[1].calls);
93
161
  if (models.length > 1) {
94
162
  lines.push(` By model:`);
95
163
  for (const [name, stats] of models) {
96
- const tokens = stats.promptTokens + stats.completionTokens;
164
+ const tokens = stats.promptTokensSubmittedEst + stats.completionTokens;
97
165
  const avgMs = stats.calls > 0 ? Math.round(stats.totalLatencyMs / stats.calls) : 0;
98
- lines.push(` ${name}: ${stats.calls} calls, ${tokens.toLocaleString()} tokens, avg ${avgMs}ms`);
166
+ lines.push(` ${name}: ${stats.calls} calls, ${tokens.toLocaleString()} tokens est., avg ${avgMs}ms`);
99
167
  }
100
168
  }
101
169
  return lines.join("\n");
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Layer 1 semantic pre-classifier for prism_infer.
3
+ * ─────────────────────────────────────────────────────────────
4
+ * Calls dcostenco/prism-coder:4b via Ollama to classify whether
5
+ * a prompt is OBVIOUS_RESERVED, OBVIOUS_NOT_RESERVED, or UNCERTAIN.
6
+ *
7
+ * Fail-closed contract:
8
+ * OBVIOUS_NOT_RESERVED → the ONLY verdict that permits local routing
9
+ * OBVIOUS_RESERVED → escalate to cloud
10
+ * UNCERTAIN → escalate to cloud (conservative)
11
+ * ERROR → escalate to cloud (never fail-open)
12
+ *
13
+ * The prompt below is VERBATIM from §E of prism-infer-boundaries/SKILL.md.
14
+ * It is duplicated here (not imported) because prism is a thin client with no
15
+ * access to the skills tree at runtime. A drift test asserts byte-for-byte match.
16
+ *
17
+ * Must not run when mode="route" and max_tokens<=16 — that IS a Layer 1 call.
18
+ */
19
+ // VERBATIM — §E prism-infer-boundaries/SKILL.md. Do not edit without re-running eval-layer1.mjs.
20
+ export const LAYER1_PROMPT = `Classify this request with exactly one word: OBVIOUS_RESERVED, OBVIOUS_NOT_RESERVED, or UNCERTAIN.
21
+
22
+ OBVIOUS_RESERVED — clearly involves:
23
+ - Behavioral crisis: de-escalation, meltdown management, rage episode, violent behavior response
24
+ - Physical restraint, holds, containment, physical management of clients
25
+ - Self-harm: client bites/hits/scratches/bangs self, draws blood, injures self
26
+ - Suicide, homicide, harm to self or others, safety planning
27
+ - Client physical elopement incidents — client ran away, bolted, or escaped (with prior documented incidents)
28
+ - Writing auth/login/JWT/token/session validation code
29
+ - Determining if code bypasses auth or allows unauthenticated access
30
+ - Assessing whether code actually exposes PHI or constitutes a HIPAA violation
31
+ - Whether to push, ship, deploy, or block a production release
32
+ - Diagnosis code assignment
33
+
34
+ OBVIOUS_NOT_RESERVED — general coding, code review, analysis, documentation, test generation, data processing.
35
+
36
+ UNCERTAIN — if the request touches reserved vocabulary but the task is non-security, non-clinical operational work (e.g., deleting or renaming a file, adding a non-auth data field to a form, reviewing existing code for dead exports or hook order) rather than writing clinical safety protocols, implementing auth or security code, making push or deploy decisions, or determining compliance.
37
+
38
+ Request: "{prompt}"
39
+
40
+ Answer (one word):`;
41
+ const VALID = new Set([
42
+ "OBVIOUS_RESERVED",
43
+ "OBVIOUS_NOT_RESERVED",
44
+ "UNCERTAIN",
45
+ ]);
46
+ /**
47
+ * Parse the model's raw text into a verdict. Extracts the first token of
48
+ * letters/underscores (ignoring leading punctuation, quotes, whitespace).
49
+ * Anything not in the valid set → ERROR, which the caller escalates.
50
+ * Note: OBVIOUS_NOT_RESERVED contains "RESERVED" as a substring — whole-token
51
+ * matching via Set prevents the substring trap from inverting the gate.
52
+ */
53
+ export function parseLayer1(raw) {
54
+ if (!raw)
55
+ return "ERROR";
56
+ const m = raw.trim().toUpperCase().match(/[A-Z_]+/);
57
+ if (!m)
58
+ return "ERROR";
59
+ const token = m[0];
60
+ return VALID.has(token) ? token : "ERROR";
61
+ }
62
+ // p95 measured 484–511ms on warm 4b; 1500ms is a generous ceiling that still
63
+ // fails closed on model stall without blocking the handler for too long.
64
+ const LAYER1_TIMEOUT_MS = 1_500;
65
+ /**
66
+ * Run the Layer 1 classifier. Returns a verdict; never throws.
67
+ * On ANY failure path returns "ERROR" so the caller escalates to cloud.
68
+ *
69
+ * @param userPrompt the end-user prompt being classified (NOT the LAYER1_PROMPT)
70
+ * @param ollamaUrl Ollama base URL
71
+ * @param model classifier model tag (caller-resolved via resolveOllamaName)
72
+ * @param fetchImpl injected for tests; defaults to global fetch
73
+ */
74
+ export async function callLayer1(userPrompt, ollamaUrl, model, fetchImpl = fetch) {
75
+ if (!userPrompt || !userPrompt.trim())
76
+ return "ERROR";
77
+ let res;
78
+ try {
79
+ res = await fetchImpl(`${ollamaUrl}/api/chat`, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ model,
84
+ messages: [
85
+ { role: "user", content: LAYER1_PROMPT.replace("{prompt}", userPrompt) },
86
+ ],
87
+ stream: false,
88
+ think: false,
89
+ options: { num_predict: 16, temperature: 0 },
90
+ }),
91
+ signal: AbortSignal.timeout(LAYER1_TIMEOUT_MS),
92
+ });
93
+ }
94
+ catch {
95
+ return "ERROR";
96
+ }
97
+ if (!res.ok)
98
+ return "ERROR";
99
+ let data;
100
+ try {
101
+ data = await res.json();
102
+ }
103
+ catch {
104
+ return "ERROR";
105
+ }
106
+ if (data?.error)
107
+ return "ERROR";
108
+ const text = data?.message?.content;
109
+ return parseLayer1(text);
110
+ }