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.
- package/README.md +3 -1
- package/dist/scm/types.js +1 -4
- package/dist/storage/synalux.js +1 -1
- package/dist/tools/__tests__/ingestHandler.test.js +19 -13
- package/dist/tools/__tests__/layer1Integration.test.js +357 -0
- package/dist/tools/__tests__/ledgerHandlers.test.js +31 -28
- package/dist/tools/ingestHandler.js +2 -1
- package/dist/tools/ledgerHandlers.js +82 -15
- package/dist/tools/prismInferHandler.js +164 -22
- package/dist/tools/sessionDriftHandler.js +1 -1
- package/dist/tools/skillRouting.js +22 -9
- package/dist/utils/entitlements.js +9 -0
- package/dist/utils/inferenceMetrics.js +89 -21
- package/dist/utils/layer1.js +110 -0
- package/dist/utils/migration/utils.js +4 -4
- package/dist/utils/projectResolver.js +2 -4
- package/dist/utils/qualityGate.js +19 -3
- package/dist/utils/safetyGate.js +22 -0
- package/dist/vm/competitorImport.js +1 -1
- package/dist/vm/componentMarketplace.js +1 -1
- package/dist/vm/creativeStudio.js +1 -1
- package/dist/vm/ethicsEnforcement.js +2 -2
- package/dist/vm/gameEngine.js +1 -1
- package/dist/vm/projectTemplates.js +1 -1
- package/dist/vm/types.js +1 -2
- package/dist/vm/vmManager.js +1 -1
- package/dist/vm/workspaceLicensing.js +1 -1
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 &&
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
50
|
+
const evaluated = result.prompt_tokens ?? 0;
|
|
31
51
|
const ct = result.completion_tokens ?? 0;
|
|
32
|
-
|
|
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] = {
|
|
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].
|
|
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
|
-
|
|
94
|
+
promptTokensEvaluated,
|
|
95
|
+
promptTokensSubmittedEst,
|
|
56
96
|
totalCompletionTokens,
|
|
57
|
-
totalTokens:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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📊
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|