prism-mcp-server 19.2.8 → 19.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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",
@@ -148,6 +149,8 @@ export function isPrismInferArgs(args) {
148
149
  return false;
149
150
  if (a.think !== undefined && typeof a.think !== "boolean")
150
151
  return false;
152
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
153
+ return false;
151
154
  if (a.verify !== undefined && typeof a.verify !== "boolean")
152
155
  return false;
153
156
  if (a.verifier_model !== undefined && typeof a.verifier_model !== "string")
@@ -299,6 +302,48 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
299
302
  return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "synalux_timeout" : "synalux_network" };
300
303
  }
301
304
  }
305
+ // ─── Portal verifier (thin-client HTTP call) ──────────────────
306
+ async function callSynaluxVerifier(opts) {
307
+ if (!PRISM_SYNALUX_BASE_URL)
308
+ throw new Error("no_synalux_base_url");
309
+ const jwt = await getSynaluxJwt();
310
+ if (!jwt)
311
+ throw new Error("jwt_exchange_failed");
312
+ const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/verify-grounding`;
313
+ const res = await fetch(url, {
314
+ method: "POST",
315
+ headers: {
316
+ "Authorization": `Bearer ${jwt}`,
317
+ "Content-Type": "application/json",
318
+ },
319
+ body: JSON.stringify({
320
+ draft: opts.draft,
321
+ evidence: opts.evidence,
322
+ verifierModel: opts.verifierModel,
323
+ // Give portal 500ms headroom before our own AbortSignal fires.
324
+ timeoutMs: Math.max(500, (opts.timeoutMs ?? 5_000) - 500),
325
+ }),
326
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),
327
+ redirect: "error",
328
+ });
329
+ if (!res.ok)
330
+ throw new Error(`synalux_verifier_http_${res.status}`);
331
+ return res.json();
332
+ }
333
+ // In-process mutex that serialises eviction so concurrent requests don't evict
334
+ // a model that another in-flight inference is actively using (F3 fix).
335
+ const _evictionMutex = (() => {
336
+ let _lock = Promise.resolve();
337
+ return {
338
+ acquire() {
339
+ let release;
340
+ const next = new Promise(resolve => { release = resolve; });
341
+ const chain = _lock.then(() => release);
342
+ _lock = _lock.then(() => next);
343
+ return chain;
344
+ },
345
+ };
346
+ })();
302
347
  export async function runInfer(args, deps) {
303
348
  const t0 = Date.now();
304
349
  const temperature = args.temperature ?? 0;
@@ -366,14 +411,105 @@ export async function runInfer(args, deps) {
366
411
  if (installed === null) {
367
412
  attempts.push({ tier: "ollama_probe", reason: "unreachable" });
368
413
  }
414
+ // ── §E Layer 1 semantic pre-classifier ──────────────────────────────────
415
+ // Catches adversarial paraphrases the keyword stub misses.
416
+ // Runs only when cloud escalation is possible — without cloud, there is
417
+ // nowhere to route a RESERVED verdict.
418
+ // Recursion guard: skip when this call IS the Layer 1 classification
419
+ // (mode="route" + max_tokens<=16 is the Layer 1 call signature).
420
+ const layer1RecursionGuard = mode === "route" && maxTokens <= 16;
421
+ if (allowCloud && !layer1RecursionGuard) {
422
+ const l1fn = deps.callLayer1 ?? defaultCallLayer1;
423
+ const l1Model = resolveOllamaName("prism-coder:4b", installed ?? new Set());
424
+ const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
425
+ if (l1 !== "OBVIOUS_NOT_RESERVED") {
426
+ debugLog(`[prism_infer] Layer 1 verdict=${l1} — escalating to cloud`);
427
+ attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
428
+ const cloudTimeout = args.timeout_ms ?? 90_000;
429
+ const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
430
+ if (cloud.ok && cloud.output) {
431
+ return await applyVerification(cloud.output, gatedArgs, deps, {
432
+ backend: cloud.backend ?? "synalux",
433
+ model_picked: null,
434
+ ram_free_mb: ramFreeMb,
435
+ latency_ms: Date.now() - t0,
436
+ used_cloud: true,
437
+ attempts,
438
+ plan: ent.plan,
439
+ completion_tokens: Math.ceil(cloud.output.length / 4),
440
+ });
441
+ }
442
+ attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
443
+ // Layer 1 flagged RESERVED but cloud unavailable — fail closed, never fall through to local.
444
+ throw new Error(`prism_infer: Layer 1 verdict=${l1} but cloud unavailable. attempts=${JSON.stringify(attempts)}`);
445
+ }
446
+ debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
447
+ }
448
+ // ── end Layer 1 ─────────────────────────────────────────────────────────
369
449
  // Walk the tier table top → bottom, capped by model_ceiling. Each tier
370
450
  // logs its skip reason ("not_pulled" / "ram_insufficient" / fail reason)
371
451
  // so the caller can see exactly why each tier was bypassed.
372
452
  let localDraft = null;
373
453
  if (installed) {
374
- const ceilStart = effectiveCeiling
375
- ? Math.max(0, MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`)))
376
- : 0;
454
+ // F4 fix: guard ceiling-not-found — Math.max(0,-1) silently targets tier 0 (27b).
455
+ // Instead of defaulting to the largest tier, treat not-found as "no ceiling" (start=0).
456
+ const ceilIdx = effectiveCeiling
457
+ ? MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`))
458
+ : -1;
459
+ const ceilStart = ceilIdx >= 0 ? ceilIdx : 0;
460
+ // Auto-evict: if the ceiling tier is installed but not warm and prism's
461
+ // own smaller tier models are warm, unload them to make room.
462
+ // Operates only on prism tier models — never evicts arbitrary Ollama models
463
+ // the caller doesn't own (F1). Uses an in-process mutex to prevent a
464
+ // concurrent request from evicting a model mid-inference (F3).
465
+ let freeAfterEvict = freeBytes;
466
+ if (loaded && loaded.size > 0) {
467
+ const ceilTier = MODEL_TIERS[ceilIdx >= 0 ? ceilIdx : 0];
468
+ const ceilName = ceilTier ? resolveOllamaName(ceilTier.tag, installed) : null;
469
+ const ceilInstalled = ceilName ? installed.has(ceilName) : false;
470
+ const ceilWarm = ceilName ? loaded.has(ceilName) : false;
471
+ if (ceilInstalled && !ceilWarm) {
472
+ // F1 fix: only count and evict prism tier models — not arbitrary warm models.
473
+ const tierModelsToEvict = MODEL_TIERS
474
+ .map(t => resolveOllamaName(t.tag, installed))
475
+ .filter(name => loaded.has(name));
476
+ const tierWarmBytes = tierModelsToEvict.reduce((sum, name) => {
477
+ const t = MODEL_TIERS.find(t => resolveOllamaName(t.tag, installed) === name);
478
+ return sum + (t ? t.weightsGb * 1024 ** 3 : 0);
479
+ }, 0);
480
+ if (freeBytes + tierWarmBytes >= ceilTier.minFreeGb * 1024 ** 3) {
481
+ // F3 fix: hold eviction mutex so no concurrent request evicts a model
482
+ // that another in-flight inference is actively using.
483
+ const released = await _evictionMutex.acquire();
484
+ try {
485
+ // F2 fix: await each evict call; log failures; don't proceed blind.
486
+ const evictResults = await Promise.allSettled(tierModelsToEvict.map(m => fetch(`${deps.ollamaUrl}/api/generate`, {
487
+ method: "POST",
488
+ body: JSON.stringify({ model: m, keep_alive: 0 }),
489
+ signal: AbortSignal.timeout(3_000),
490
+ })));
491
+ const failed = evictResults.filter(r => r.status === "rejected").length;
492
+ if (failed > 0) {
493
+ debugLog(`[prism_infer] evict: ${failed}/${tierModelsToEvict.length} unload requests failed`);
494
+ }
495
+ // Settle: give Ollama time to release buffers before re-reading RAM.
496
+ await new Promise(r => setTimeout(r, 800));
497
+ freeAfterEvict = deps.freemem();
498
+ debugLog(`[prism_infer] auto-evicted ${tierModelsToEvict.join(", ")} ` +
499
+ `(${fmtGb(tierWarmBytes)}) → freeAfterEvict=${fmtGb(freeAfterEvict)}`);
500
+ // F2 fix: if still insufficient after eviction, log and fall through
501
+ // cleanly — the tier loop will emit ram_insufficient rather than
502
+ // proceeding on a stale freeBytes value.
503
+ if (freeAfterEvict < ceilTier.minFreeGb * 1024 ** 3) {
504
+ debugLog(`[prism_infer] evict completed but RAM still insufficient for ${ceilTier.tag}`);
505
+ }
506
+ }
507
+ finally {
508
+ released();
509
+ }
510
+ }
511
+ }
512
+ }
377
513
  let anyViable = false;
378
514
  for (let i = ceilStart; i < MODEL_TIERS.length; i++) {
379
515
  const tier = MODEL_TIERS[i];
@@ -389,7 +525,7 @@ export async function runInfer(args, deps) {
389
525
  // RAM gate — but skip the check if the tier is already warm in
390
526
  // Ollama. Reused models don't reallocate weight buffers.
391
527
  const isWarm = loaded.has(ollamaName);
392
- if (!isWarm && freeBytes < tier.minFreeGb * (1024 ** 3)) {
528
+ if (!isWarm && freeAfterEvict < tier.minFreeGb * (1024 ** 3)) {
393
529
  attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
394
530
  continue;
395
531
  }
@@ -400,20 +536,18 @@ export async function runInfer(args, deps) {
400
536
  if (result.ok) {
401
537
  const { stripped, thinkOnly } = stripThink(result.text);
402
538
  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`);
539
+ // Quality gate all modes. Route uses mode-aware empty floor (length===0).
540
+ const gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
541
+ if (!gate.pass && allowCloud) {
542
+ debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) escalating to cloud`);
543
+ attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
544
+ if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
545
+ localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
416
546
  }
547
+ break;
548
+ }
549
+ if (!gate.pass) {
550
+ debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
417
551
  }
418
552
  return await applyVerification(output, gatedArgs, deps, {
419
553
  backend: `ollama-${tier.tag.replace("prism-coder:", "")}`,
@@ -448,7 +582,9 @@ export async function runInfer(args, deps) {
448
582
  used_cloud: true,
449
583
  attempts,
450
584
  plan: ent.plan,
451
- prompt_tokens: Math.ceil(args.prompt.length / 4),
585
+ // T4: omit prompt_tokens — cloud doesn't return Ollama actual eval count.
586
+ // recordInference receives prompt_text and computes submittedEst via
587
+ // estimateTokens(), keeping promptTokensEvaluated=0 (correct for cloud).
452
588
  completion_tokens: Math.ceil(cloud.output.length / 4),
453
589
  });
454
590
  }
@@ -524,10 +660,24 @@ export async function prismInferHandler(args) {
524
660
  callLocal: callOllamaGenerate,
525
661
  callCloud: callSynaluxInference,
526
662
  ollamaUrl: PRISM_LOCAL_LLM_URL,
663
+ callVerifier: SYNALUX_CONFIGURED ? callSynaluxVerifier : undefined,
527
664
  });
528
665
  debugLog(`[prism_infer] backend=${result.backend} model=${result.model_picked} latency=${result.latency_ms}ms free=${result.ram_free_mb}MB`);
529
666
  // Local accumulator — sole source of the user-facing metrics block.
530
- recordInference(result);
667
+ // T4: pass prompt_text so recordInference computes submittedEst via
668
+ // estimateTokens() — critical for cloud path where prompt_tokens is unset.
669
+ recordInference({ ...result, prompt_text: args.prompt });
670
+ // Best-effort session telemetry — records that inference ran for this
671
+ // conversation. Never affects routing or safety decisions.
672
+ const _convId = args.conversation_id;
673
+ if (_convId && result.backend !== "safety_gate") {
674
+ import("../session/sessionContext.js").then(({ noteInferenceForSession }) => {
675
+ noteInferenceForSession(_convId, {
676
+ backend: result.backend,
677
+ usedCloud: result.used_cloud,
678
+ });
679
+ }).catch(() => { });
680
+ }
531
681
  // Best-effort portal forwarding (independent analytics stream).
532
682
  // safety_gate excluded — logging crisis filter triggers is a HIPAA concern.
533
683
  if (result.backend !== "safety_gate") {
@@ -543,7 +693,7 @@ export async function prismInferHandler(args) {
543
693
  const tokenStr = result.prompt_tokens != null || result.completion_tokens != null
544
694
  ? ` tokens=${result.prompt_tokens ?? "?"}in/${result.completion_tokens ?? "?"}out`
545
695
  : "";
546
- const header = `[prism_infer] backend=${result.backend}` +
696
+ const headerBase = `[prism_infer] backend=${result.backend}` +
547
697
  ` model=${result.model_picked ?? "n/a"}` +
548
698
  ` plan=${result.plan ?? "unknown"}` +
549
699
  ` free_ram=${result.ram_free_mb}MB` +
@@ -553,6 +703,11 @@ export async function prismInferHandler(args) {
553
703
  (result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
554
704
  (result.verification ? ` verify=${result.verification.action}` : "") +
555
705
  (result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
706
+ // Append periodic session-level stats to the header line.
707
+ // compact=true is threshold-gated (PRISM_METRICS_EVERY, default every 5 calls)
708
+ // so it doesn't appear on every response — only as a rolling summary.
709
+ const metricsLine = formatInferenceMetrics(true);
710
+ const header = metricsLine ? `${headerBase}\n${metricsLine}` : headerBase;
556
711
  return {
557
712
  content: [
558
713
  { 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";
@@ -99,6 +99,10 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
99
99
  type: "boolean",
100
100
  description: "Set to true to disable automatic CRDT merging and fail strictly on version conflict (original OCC behavior). Default: false.",
101
101
  },
102
+ conversation_id: {
103
+ type: "string",
104
+ description: "Optional. Session key for this conversation (same id used in session_load_context). When provided, the server verifies that session_load_context was called for this conversation before accepting the write.",
105
+ },
102
106
  },
103
107
  required: ["project"],
104
108
  },
@@ -141,6 +145,10 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
141
145
  type: "string",
142
146
  description: "Brief 2-5 word noun phrase describing what this tool call is about.",
143
147
  },
148
+ conversation_id: {
149
+ type: "string",
150
+ description: "Optional. Session key for this conversation (same id used in session_save_ledger). When provided, marks the session as context-loaded server-side so project-scoped tools can verify working context without relying on hook-based enforcement. Required on non-Claude hosts.",
151
+ },
144
152
  },
145
153
  required: ["project", "toolAction", "toolSummary"],
146
154
  },
@@ -509,6 +517,8 @@ export function isSessionSaveHandoffArgs(args) {
509
517
  return false;
510
518
  if (a.disable_merge !== undefined && typeof a.disable_merge !== "boolean")
511
519
  return false;
520
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
521
+ return false;
512
522
  return true;
513
523
  }
514
524
  // ─── v0.4.0: Type guard for semantic search ──────────────────
@@ -572,6 +582,8 @@ export function isSessionLoadContextArgs(args) {
572
582
  return false;
573
583
  if (a.toolSummary !== undefined && typeof a.toolSummary !== "string")
574
584
  return false;
585
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
586
+ return false;
575
587
  return true;
576
588
  }
577
589
  // ─── v2.0: Time Travel Tool Definitions ──────────────────────
@@ -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
  }
@@ -48,10 +48,15 @@ async function ensureTable() {
48
48
  `);
49
49
  _tableReady = true;
50
50
  }
51
- /** Reset DB connection (for tests). */
51
+ /** Reset DB connection and in-memory buffer (for tests). */
52
52
  export function _resetDb() {
53
53
  _db = null;
54
54
  _tableReady = false;
55
+ BUFFER.length = 0;
56
+ if (flushTimer) {
57
+ clearTimeout(flushTimer);
58
+ flushTimer = null;
59
+ }
55
60
  }
56
61
  // ─── In-Memory Buffer ────────────────────────────────────────
57
62
  const BUFFER = [];
@@ -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");