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.
- package/README.md +3 -1
- package/dist/boundaries/__tests__/boundaries.test.js +58 -0
- package/dist/boundaries/boundaries.js +48 -0
- package/dist/scm/types.js +1 -4
- package/dist/session/__tests__/sessionContext.test.js +133 -0
- package/dist/session/sessionContext.js +165 -0
- 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 +125 -28
- package/dist/tools/ledgerHandlers.js +158 -35
- package/dist/tools/prismInferHandler.js +177 -22
- package/dist/tools/sessionDriftHandler.js +1 -1
- package/dist/tools/sessionMemoryDefinitions.js +12 -0
- package/dist/tools/skillRouting.js +22 -9
- package/dist/utils/analytics.js +6 -1
- package/dist/utils/entitlements.js +9 -0
- package/dist/utils/inferenceMetrics.js +89 -21
- package/dist/utils/layer1.js +110 -0
- 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",
|
|
@@ -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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 &&
|
|
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
|
|
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`);
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
}
|
package/dist/utils/analytics.js
CHANGED
|
@@ -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
|
-
*
|
|
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");
|