prism-mcp-server 20.21.26 → 20.22.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.
@@ -19,6 +19,9 @@
19
19
  * directly — all cloud traffic goes via the synalux portal so billing,
20
20
  * tier gating, and HIPAA audit are enforced in one place.
21
21
  */
22
+ import { ownedOmlxGenerate, recoverOwnedOmlx } from "../utils/omlxOwned.js";
23
+ import { OmlxError, createOmlxRuntime, guardedOmlxGenerate, omlxModelTarget, withInferenceResourceLease, withOllamaResourceLease, OMLX_MIN_AVAILABLE_BYTES } from "../utils/omlxProvider.js";
24
+ import { OMLX_LAYER1_MODEL, omlxLayer1Fetch } from "../utils/omlxLayer1.js";
22
25
  import { createHash } from "node:crypto";
23
26
  import { pickLocalModel, fmtGb, MODEL_TIERS, resolveOllamaName } from "../utils/modelPicker.js";
24
27
  import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
@@ -34,7 +37,7 @@ import { passesQualityGate } from "../utils/qualityGate.js";
34
37
  import { passesClinicalQualityGate, clinicalPlanScaffold, formatClinicalSections, } from "../utils/clinicalQualityPolicy.js";
35
38
  import { applyDeterministicCodingRepairs, buildCodingRepairPrompt, passesCodingQualityGate, } from "../utils/codingQualityPolicy.js";
36
39
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
37
- import { callLayer1 as defaultCallLayer1, classifyDeterministicLayer1, keywordBackstop, reservedCategory, MAX_CLASSIFIER_PROMPT_LENGTH, layer1ClassifierContent, secondReadExclusion } from "../utils/layer1.js";
40
+ import { callLayer1 as defaultCallLayer1, classifyDeterministicLayer1, keywordBackstop, windowsOf, reservedCategory, MAX_CLASSIFIER_PROMPT_LENGTH, layer1ClassifierContent, secondReadExclusion } from "../utils/layer1.js";
38
41
  import { getSecondReadPolicy, getAnswerCheckPolicy, getClassifierInputPolicy } from "../utils/inferencePolicy.js";
39
42
  import { pseudonymizeForCheck } from "../utils/pseudonymize.js";
40
43
  import { answerGroundingBytes, answerGroundingContent, parseGroundingVerdict, arithmeticSlips, arithmeticCorrection, ANSWER_GROUNDING_OUTPUT_TOKENS, ANSWER_GROUNDING_THINK, ANSWER_GROUNDING_THINK_TOKENS, ANSWER_GROUNDING_TIMEOUT_MS, ANSWER_GROUNDING_RETRY_TIMEOUT_MS, ANSWER_GROUNDING_FOLLOW_UP_TOKENS } from "../utils/answerGrounding.js";
@@ -182,28 +185,7 @@ export const HISTORY_TURN_WINDOW_OVERLAP = 200;
182
185
  * one intent. */
183
186
  export const DETERMINISTIC_FLOOR_WINDOW_CHARS = 7_200;
184
187
  export const DETERMINISTIC_FLOOR_WINDOW_OVERLAP = 3_800;
185
- export function windowsOf(content, size, overlap) {
186
- if (content.length <= size)
187
- return [content];
188
- const isHigh = (i) => { const c = content.charCodeAt(i); return c >= 0xd800 && c <= 0xdbff; };
189
- const isLow = (i) => { const c = content.charCodeAt(i); return c >= 0xdc00 && c <= 0xdfff; };
190
- const out = [];
191
- const step = size - overlap;
192
- for (let i = 0; i < content.length; i += step) {
193
- // Never cut a surrogate pair: a window that starts on a low or ends
194
- // on a high surrogate is malformed text for the classifier.
195
- let start = i;
196
- if (start > 0 && isLow(start))
197
- start += 1;
198
- let end = Math.min(content.length, start + size);
199
- if (end < content.length && isHigh(end - 1))
200
- end += 1;
201
- out.push(content.slice(start, end));
202
- if (end >= content.length)
203
- break;
204
- }
205
- return out;
206
- }
188
+ export { windowsOf };
207
189
  export function historyTurnWindows(content) {
208
190
  return windowsOf(content, HISTORY_TURN_WINDOW_CHARS, HISTORY_TURN_WINDOW_OVERLAP);
209
191
  }
@@ -616,7 +598,8 @@ export const PRISM_INFER_TOOL = {
616
598
  idempotentHint: false,
617
599
  openWorldHint: true,
618
600
  },
619
- description: "Run an inference on a local prism-coder model (Ollama) to save cloud tokens. " +
601
+ description: "Run local inference (Ollama by default). Explicit local_backend=omlx uses authenticated " +
602
+ "loopback oMLX for text-only code/reviewer drafts, without Ollama/cloud substitution or final sign-off. " +
620
603
  "Owns model selection across 27B / 9B / 4B / 2B using an explicit `model_ceiling` or " +
621
604
  "the caller's `task_complexity`, then validates loaded memory size, model context, " +
622
605
  "entitlements, installed models, and free RAM at call time. " +
@@ -651,12 +634,11 @@ export const PRISM_INFER_TOOL = {
651
634
  },
652
635
  messages: {
653
636
  type: "array",
654
- description: "Prior turns of THIS conversation, oldest first; `prompt` stays the current turn. " +
655
- "Send only accepted turns, as a brief, not a transcript; text only. A paid Synalux " +
656
- "plan feature: the plan sets turn and character caps; free plan or no portal is " +
657
- "refused (multi_turn_not_in_plan); over-cap or malformed history is refused with " +
658
- "the caps named, never trimmed. Turns are safety-screened, counted against the " +
659
- "tier's context, forwarded to the cloud on escalation (32 KB cap), never stored.",
637
+ description: "Prior accepted text turns of THIS conversation, oldest first; prompt is current. " +
638
+ "Brief, not transcript. A paid Synalux plan feature; plan sets turn/character caps. " +
639
+ "Unavailable: multi_turn_not_in_plan. Malformed/over-cap: refused with caps named, " +
640
+ "never trimmed. Safety-screened, charged to context, forwarded on cloud escalation " +
641
+ "(32 KB cap), never stored.",
660
642
  items: {
661
643
  type: "object",
662
644
  properties: {
@@ -680,6 +662,14 @@ export const PRISM_INFER_TOOL = {
680
662
  description: "Sampling temperature; default 0 = deterministic.",
681
663
  default: 0,
682
664
  },
665
+ local_backend: {
666
+ type: "string", enum: ["ollama", "omlx"],
667
+ description: "Default Ollama; explicit oMLX is text-only code mode, no backend substitution.",
668
+ },
669
+ local_model: {
670
+ type: "string",
671
+ description: "Explicit oMLX only: exact advertised model ID; otherwise use trusted local configuration.",
672
+ },
683
673
  model_ceiling: {
684
674
  type: "string",
685
675
  enum: ["27b", "9b", "4b", "2b"],
@@ -694,8 +684,7 @@ export const PRISM_INFER_TOOL = {
694
684
  },
695
685
  project: {
696
686
  type: "string",
697
- description: "Prism project whose dashboard-depth handoff and recent session memory go to the " +
698
- "local worker as historical data.",
687
+ description: "Project whose dashboard-depth handoff/recent session memory is historical worker context.",
699
688
  },
700
689
  context_depth: {
701
690
  type: "string",
@@ -712,13 +701,12 @@ export const PRISM_INFER_TOOL = {
712
701
  },
713
702
  timeout_ms: {
714
703
  type: "number",
715
- description: "Per-call timeout override. Default by tier: 27B 120s, 9B 60s, 4B 20s, 2B 15s.",
704
+ description: "Timeout override. Ollama: 27B 120s, 9B 60s, 4B 20s, 2B 15s. oMLX generation/check budget: 300s.",
716
705
  },
717
706
  evidence: {
718
707
  type: "array",
719
- description: "Snippets the output must be grounded in. With `verify: true`, every assertive " +
720
- "claim (numbers, names, dates, codes, $ amounts) must be ENTAILED by a snippet " +
721
- "or the draft is refused.",
708
+ description: "Evidence snippets. With verify:true every assertive claim must be ENTAILED by " +
709
+ "a snippet or the draft is refused.",
722
710
  items: {
723
711
  type: "object",
724
712
  properties: {
@@ -730,9 +718,8 @@ export const PRISM_INFER_TOOL = {
730
718
  },
731
719
  verify: {
732
720
  type: "boolean",
733
- description: "L3 grounding verifier; default true when `evidence` is given. A second model " +
734
- "(qwen3.5:4b by default) checks the draft against `evidence`; NEUTRAL or " +
735
- "CONTRADICTED claims are refused.",
721
+ description: "L3 grounding; default true with evidence. A second model (default qwen3.5:4b) " +
722
+ "checks the draft; NEUTRAL/CONTRADICTED claims are refused.",
736
723
  },
737
724
  verifier_model: {
738
725
  type: "string",
@@ -755,8 +742,7 @@ export const PRISM_INFER_TOOL = {
755
742
  type: "array",
756
743
  maxItems: MAX_ROUTE_TOOLS,
757
744
  items: { type: "string" },
758
- description: "Tool names advertised to the route model; well-formed calls outside this list " +
759
- "are suppressed in route mode. Default: Prism's seven trained routing tools.",
745
+ description: "Advertised route tools; other calls are suppressed. Default: Prism's seven trained tools.",
760
746
  },
761
747
  route_guard: {
762
748
  type: "string",
@@ -772,8 +758,7 @@ export const PRISM_INFER_TOOL = {
772
758
  },
773
759
  think: {
774
760
  type: "boolean",
775
- description: "<think> reasoning. Default true for chat/code, false for route; better on complex " +
776
- "tasks, adds ~2-5s.",
761
+ description: "Reasoning override. Ollama uses tier/mode defaults; oMLX preserves local configuration when omitted.",
777
762
  },
778
763
  strict_entitlements: {
779
764
  type: "boolean",
@@ -793,6 +778,32 @@ export const PRISM_INFER_TOOL = {
793
778
  },
794
779
  required: ["prompt"],
795
780
  },
781
+ outputSchema: {
782
+ type: "object",
783
+ properties: {
784
+ output: { type: "string" },
785
+ backend: { type: "string" },
786
+ model_picked: { type: ["string", "null"] },
787
+ used_cloud: { type: "boolean" },
788
+ ram_free_mb: { type: "number" },
789
+ latency_ms: { type: "number" },
790
+ attempts: {
791
+ type: "array", items: {
792
+ type: "object", properties: { tier: { type: "string" }, reason: { type: "string" } },
793
+ required: ["tier", "reason"], additionalProperties: false,
794
+ },
795
+ },
796
+ gate_outcome: {
797
+ type: "object", properties: {
798
+ status: { type: "string", enum: ["success", "degraded", "refused"] },
799
+ reason: { type: "string" }, served_anyway: { type: "boolean" },
800
+ },
801
+ required: ["status", "served_anyway"], additionalProperties: false,
802
+ },
803
+ next_step: { type: "string" },
804
+ },
805
+ required: ["output", "backend", "model_picked", "used_cloud", "ram_free_mb", "latency_ms", "attempts"],
806
+ },
796
807
  };
797
808
  /** Why a `messages` value fails the structural (absolute) contract, or null
798
809
  * when it passes. The MCP handler surfaces this text so an over-ceiling or
@@ -850,6 +861,12 @@ export function isPrismInferArgs(args) {
850
861
  }
851
862
  if (a.messages !== undefined && messagesProblem(a.messages) !== null)
852
863
  return false;
864
+ if (a.local_backend !== undefined && a.local_backend !== "ollama" && a.local_backend !== "omlx")
865
+ return false;
866
+ if (a.local_model !== undefined && (a.local_backend !== "omlx" || typeof a.local_model !== "string" || !a.local_model.trim()))
867
+ return false;
868
+ if (a.local_backend === "omlx" && (a.mode !== "code" || (Array.isArray(a.images) && a.images.length > 0)))
869
+ return false;
853
870
  if (a.max_tokens !== undefined && typeof a.max_tokens !== "number")
854
871
  return false;
855
872
  if (a.temperature !== undefined && typeof a.temperature !== "number")
@@ -1027,6 +1044,75 @@ export async function listOllamaLoaded(url = PRISM_LOCAL_LLM_URL) {
1027
1044
  return new Set();
1028
1045
  }
1029
1046
  }
1047
+ /** The existing reversible Ollama unload operation, never a cache deletion. */
1048
+ export async function unloadOllamaModel(url, model, fetchImpl = fetch) {
1049
+ try {
1050
+ const response = await fetchImpl(`${url}/api/generate`, {
1051
+ method: "POST", headers: { "Content-Type": "application/json" },
1052
+ body: JSON.stringify({ model, keep_alive: 0 }),
1053
+ signal: AbortSignal.timeout(3_000), redirect: "error",
1054
+ });
1055
+ if (!response.ok)
1056
+ throw new Error();
1057
+ const acknowledgement = await response.json();
1058
+ if (acknowledgement.done !== true || acknowledgement.error !== undefined
1059
+ || (acknowledgement.done_reason !== undefined && acknowledgement.done_reason !== "unload"))
1060
+ throw new Error();
1061
+ }
1062
+ catch {
1063
+ throw new OmlxError("omlx_ollama_unload_failed");
1064
+ }
1065
+ }
1066
+ // Measured 2026-10-15: the 4b's unload is acknowledged in 38 ms and leaves
1067
+ // /api/ps about 250 ms later. The timeout leaves room for the larger tiers.
1068
+ const OLLAMA_UNLOAD_DRAIN_TIMEOUT_MS = 10_000;
1069
+ const OLLAMA_UNLOAD_POLL_INTERVAL_MS = 100;
1070
+ /** An unload acknowledgment can precede buffer release. Only a fresh empty
1071
+ * loaded set unlocks oMLX; never unload a newcomer while waiting. */
1072
+ export async function waitForOllamaUnloadDrain(listLoaded, initial, timeoutMs = OLLAMA_UNLOAD_DRAIN_TIMEOUT_MS, pollIntervalMs = OLLAMA_UNLOAD_POLL_INTERVAL_MS) {
1073
+ const deadline = Date.now() + timeoutMs;
1074
+ while (Date.now() < deadline) {
1075
+ let timer;
1076
+ let loaded;
1077
+ try {
1078
+ loaded = await Promise.race([
1079
+ Promise.resolve().then(listLoaded),
1080
+ new Promise((_resolve, reject) => {
1081
+ timer = setTimeout(() => reject(new OmlxError("omlx_ollama_state_unavailable")), deadline - Date.now());
1082
+ }),
1083
+ ]);
1084
+ }
1085
+ catch {
1086
+ throw new OmlxError("omlx_ollama_state_unavailable");
1087
+ }
1088
+ finally {
1089
+ if (timer)
1090
+ clearTimeout(timer);
1091
+ }
1092
+ if (Date.now() > deadline)
1093
+ break;
1094
+ if (loaded.size === 0)
1095
+ return;
1096
+ if ([...loaded].some(model => !initial.has(model)))
1097
+ throw new OmlxError("omlx_memory_contention");
1098
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))));
1099
+ }
1100
+ throw new OmlxError("omlx_ollama_unload_incomplete");
1101
+ }
1102
+ async function strictLoaded(url) {
1103
+ try {
1104
+ const response = await fetch(`${url}/api/ps`, { signal: AbortSignal.timeout(3_000), redirect: "error" });
1105
+ if (!response.ok)
1106
+ throw new Error();
1107
+ const data = await response.json();
1108
+ if (!Array.isArray(data.models) || data.models.some(model => typeof model?.name !== "string" || !model.name))
1109
+ throw new Error();
1110
+ return new Set(data.models.map(model => model.name));
1111
+ }
1112
+ catch {
1113
+ throw new OmlxError("omlx_ollama_state_unavailable");
1114
+ }
1115
+ }
1030
1116
  /** Resolve caller-supplied images to raw base64.
1031
1117
  * A filesystem path is read and encoded; anything else is assumed to be
1032
1118
  * base64 already. A path that cannot be read THROWS rather than being passed
@@ -1841,6 +1927,35 @@ const _evictionMutex = (() => {
1841
1927
  };
1842
1928
  })();
1843
1929
  export async function runInfer(args, deps) {
1930
+ const started = Date.now();
1931
+ return withInferenceResourceLease(args.local_backend === "omlx", async () => {
1932
+ let releaseDevice;
1933
+ const runtime = args.local_backend === "omlx" ? deps.omlx : undefined;
1934
+ // Every owned generation, so the device outlives its marker writes.
1935
+ const generations = new Set();
1936
+ const scopedDeps = runtime ? { ...deps, omlx: { ...runtime, detect: async (model) => {
1937
+ const target = await runtime.detect(model);
1938
+ releaseDevice = await runtime.acquire(undefined, { recoveryProviderId: target.url });
1939
+ // Before Layer 1 or anything else: a stale marker of this provider
1940
+ // is cleared on proof, or the call is refused.
1941
+ await runtime.recover?.(target);
1942
+ return target;
1943
+ }, generate: (target, request, signal) => {
1944
+ const generation = runtime.generate(target, request, signal);
1945
+ generations.add(generation.catch(() => undefined));
1946
+ return generation;
1947
+ } } } : deps;
1948
+ try {
1949
+ return { ...await runInferWithResources(args, scopedDeps), latency_ms: Date.now() - started };
1950
+ }
1951
+ finally {
1952
+ // guardedOmlxGenerate may answer first; the lease waits for the jobs.
1953
+ await Promise.all(generations);
1954
+ await releaseDevice?.();
1955
+ }
1956
+ });
1957
+ }
1958
+ async function runInferWithResources(args, deps) {
1844
1959
  const t0 = Date.now();
1845
1960
  const temperature = args.temperature ?? 0;
1846
1961
  // ── L1 Safety — deterministic input interception ────────────
@@ -1867,6 +1982,11 @@ export async function runInfer(args, deps) {
1867
1982
  history_turns: args.messages?.length ?? 0,
1868
1983
  };
1869
1984
  }
1985
+ return args.local_backend === "omlx"
1986
+ ? runInferAfterInputSafety(args, deps, t0, temperature)
1987
+ : withOllamaResourceLease(() => runInferAfterInputSafety(args, deps, t0, temperature));
1988
+ }
1989
+ async function runInferAfterInputSafety(args, deps, t0, temperature) {
1870
1990
  // ── Entitlement enforcement ──────────────────────────────────
1871
1991
  // Resolved per call (§5.5) — getEntitlements dedupes via a 5-min cache.
1872
1992
  // Free users without auth get 4b ceiling, 50 calls/day, 512 max tokens.
@@ -1923,7 +2043,7 @@ export async function runInfer(args, deps) {
1923
2043
  // explicit request still behaves as before and is refused at the point of
1924
2044
  // use with its attempt named.
1925
2045
  const planDefault = ent.features.cloud_fallback && !(args.images?.length);
1926
- let allowCloud = (args.cloud_fallback ?? planDefault) && ent.features.cloud_fallback;
2046
+ let allowCloud = args.local_backend !== "omlx" && (args.cloud_fallback ?? planDefault) && ent.features.cloud_fallback;
1927
2047
  // Verification only for paid plans (free users skip L3 grounding)
1928
2048
  const canVerify = ent.features.grounding_verifier;
1929
2049
  // The portal entitlement is authoritative. A paid plan alone must not
@@ -2076,8 +2196,78 @@ export async function runInfer(args, deps) {
2076
2196
  // Discover which tags Ollama actually has + which are already warm.
2077
2197
  // Already-loaded models don't need RAM headroom — they're reusing
2078
2198
  // memory Ollama allocated previously.
2199
+ let omlxTarget;
2200
+ const omlxRuntime = args.local_backend === "omlx" ? deps.omlx : undefined;
2201
+ if (args.local_backend === "omlx") {
2202
+ if (mode !== "code" || args.images?.length)
2203
+ throw new Error("prism_infer: oMLX requires text-only code mode");
2204
+ if (!omlxRuntime)
2205
+ throw new Error("prism_infer: omlx_runtime_unavailable");
2206
+ // No trusted binding maps an oMLX model to a logical tier, and the
2207
+ // configured Flash model outweighs every Ollama tier. Below the top
2208
+ // ceiling, from the caller or the plan, refuse before any network,
2209
+ // classification or residency change.
2210
+ if (effectiveCeiling !== "27b") {
2211
+ attempts.push({ tier: "omlx", reason: "omlx_model_ceiling" });
2212
+ if (wantReport)
2213
+ return refusedResult("omlx_model_ceiling");
2214
+ throw new Error("prism_infer: omlx_model_ceiling; explicit oMLX needs the top model ceiling");
2215
+ }
2216
+ if (!Number.isInteger(localMaxTokens) || localMaxTokens <= 0
2217
+ || !Number.isFinite(temperature) || temperature < 0
2218
+ || (args.timeout_ms !== undefined && (!Number.isInteger(args.timeout_ms) || args.timeout_ms <= 0 || args.timeout_ms > 900_000))) {
2219
+ throw new Error("prism_infer: invalid bounded oMLX generation parameters");
2220
+ }
2221
+ try {
2222
+ omlxTarget = await omlxRuntime.detect(args.local_model);
2223
+ }
2224
+ catch (error) {
2225
+ const reason = error instanceof OmlxError ? error.reason : "omlx_detection_unavailable";
2226
+ attempts.push({ tier: "omlx", reason });
2227
+ if (wantReport)
2228
+ return refusedResult(reason);
2229
+ throw new Error(`prism_infer: ${reason}; explicit oMLX will not substitute another backend`);
2230
+ }
2231
+ // Fit is conservative in UTF-8 bytes; refuse before changing Ollama state.
2232
+ const inputBytes = Buffer.byteLength(args.prompt + (args.system ?? ""), "utf8")
2233
+ + (args.messages ?? []).reduce((n, turn) => n + Buffer.byteLength(turn.content, "utf8") + 8, 0);
2234
+ if (inputBytes + localMaxTokens + 1_300 > omlxTarget.contextTokens) {
2235
+ throw new Error("prism_infer: omlx_ctx_insufficient");
2236
+ }
2237
+ attempts.push({ tier: "omlx", reason: "authenticated_model_detected" });
2238
+ }
2079
2239
  const installed = await deps.listTags();
2080
2240
  const loaded = await deps.listLoaded();
2241
+ if (omlxTarget && installed === null)
2242
+ throw new Error("prism_infer: omlx_classifier_unavailable");
2243
+ // Ollama is unloaded during an explicit oMLX call (owner, 2026-10-15; this
2244
+ // replaces plan section 4's "never evict another Ollama user's weights").
2245
+ // Under the device lease, before classification: unload every resident
2246
+ // model reversibly (the weights stay on disk and reload on the next Ollama
2247
+ // call) and wait until Ollama reports none. A failed unload, a model that
2248
+ // does not drain, or a newcomer loading meanwhile refuses. After this, a
2249
+ // model that appears is refused per read by guardedOmlxGenerate.
2250
+ if (omlxTarget && omlxRuntime) {
2251
+ let residency = null;
2252
+ try {
2253
+ const initial = new Set(await omlxRuntime.listLoaded());
2254
+ for (const model of initial)
2255
+ await omlxRuntime.unload(model);
2256
+ if (initial.size > 0) {
2257
+ await waitForOllamaUnloadDrain(() => omlxRuntime.listLoaded(), initial);
2258
+ attempts.push({ tier: "omlx", reason: `omlx_ollama_unloaded:${initial.size}` });
2259
+ }
2260
+ }
2261
+ catch (error) {
2262
+ residency = error instanceof OmlxError ? error.reason : "omlx_ollama_state_unavailable";
2263
+ }
2264
+ if (residency) {
2265
+ attempts.push({ tier: "omlx", reason: residency });
2266
+ if (wantReport)
2267
+ return refusedResult(residency);
2268
+ throw new Error(`prism_infer: ${residency}; oMLX generation refused`);
2269
+ }
2270
+ }
2081
2271
  if (installed === null) {
2082
2272
  attempts.push({ tier: "ollama_probe", reason: "unreachable" });
2083
2273
  }
@@ -2129,8 +2319,21 @@ export async function runInfer(args, deps) {
2129
2319
  }
2130
2320
  let secondReadModel = null;
2131
2321
  if (installed) {
2132
- const l1fn = deps.callLayer1 ?? defaultCallLayer1;
2133
- const l1Model = resolveOllamaName("prism-coder:4b", installed);
2322
+ // Explicit oMLX classifies on its own server: every read (the prompt,
2323
+ // history and context windows) goes to the oMLX classifier through the
2324
+ // owned path, inside this call's device lease. Nothing is loaded into
2325
+ // Ollama, and the history cache keys carry the oMLX model's name.
2326
+ // A target that cannot carry the credential fails the read (ERROR), and
2327
+ // explicit oMLX refuses ERROR; it never falls back to Ollama.
2328
+ const baseL1 = deps.callLayer1 ?? defaultCallLayer1;
2329
+ const generationTarget = omlxTarget, runtime = omlxRuntime;
2330
+ const omlxL1Fetch = generationTarget && runtime
2331
+ ? omlxLayer1Fetch(async (request) => guardedOmlxGenerate(runtime, omlxModelTarget(generationTarget, OMLX_LAYER1_MODEL), request, deps.freemem))
2332
+ : undefined;
2333
+ const l1fn = omlxL1Fetch
2334
+ ? (...[prompt, url, model, , images, opts]) => baseL1(prompt, url, model, omlxL1Fetch, images, opts)
2335
+ : baseL1;
2336
+ const l1Model = omlxL1Fetch ? OMLX_LAYER1_MODEL : resolveOllamaName("prism-coder:4b", installed);
2134
2337
  secondReadModel = null;
2135
2338
  // The classifier must be able to SEE what it is classifying. Ollama
2136
2339
  // accepts `images` on a text-only model and silently ignores them
@@ -2156,6 +2359,9 @@ export async function runInfer(args, deps) {
2156
2359
  `Rebuild the classifier with a vision tower to enable image requests.`);
2157
2360
  }
2158
2361
  }
2362
+ // Explicit oMLX: errors are never served (strictErrors), and the rules
2363
+ // only refuse; the classifier reads everything they would clear.
2364
+ const omlxL1Opts = omlxTarget ? { strictErrors: true, readOversizeWhole: true } : undefined;
2159
2365
  // 4th arg is fetchImpl (default), 5th is the images the classifier must see.
2160
2366
  // Single turn: one call, unchanged. With history, three layers: the
2161
2367
  // deterministic floor per turn, every turn read alone (every verdict
@@ -2169,8 +2375,8 @@ export async function runInfer(args, deps) {
2169
2375
  if (!args.messages?.length) {
2170
2376
  // Single turn: one call; the classifier-input policy is passed when there is one.
2171
2377
  l1 = classifierInput
2172
- ? await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { classifierInput })
2173
- : await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages);
2378
+ ? await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { classifierInput, ...omlxL1Opts })
2379
+ : await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, ...(omlxL1Opts ? [omlxL1Opts] : []));
2174
2380
  if (l1 !== "OBVIOUS_NOT_RESERVED")
2175
2381
  l1Layer = "prompt";
2176
2382
  }
@@ -2252,7 +2458,7 @@ export async function runInfer(args, deps) {
2252
2458
  // is switched off here — it would undo the slicing above (words
2253
2459
  // 14k chars apart firing one rule; review round 18).
2254
2460
  if (l1 !== "OBVIOUS_RESERVED") {
2255
- l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false, ...(classifierInput ? { classifierInput } : {}) }), "prompt");
2461
+ l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false, ...(classifierInput ? { classifierInput } : {}), ...omlxL1Opts }), "prompt");
2256
2462
  }
2257
2463
  // 3. Context, raise only: one window per turn and one for the
2258
2464
  // prompt (see contextWindows), cached like any window. Skipped
@@ -2314,6 +2520,12 @@ export async function runInfer(args, deps) {
2314
2520
  attempts.push({ tier: "layer1", reason: "layer1_hedge_second_read_skipped_classifier_error" });
2315
2521
  entMeta.layer1_second_read = "skipped_classifier_error";
2316
2522
  }
2523
+ else if (modelHedgeOnly && omlxL1Fetch) {
2524
+ // On explicit oMLX the 9b already read every window: the hedge
2525
+ // stands and refuses.
2526
+ attempts.push({ tier: "layer1", reason: "layer1_hedge_second_read_skipped_omlx" });
2527
+ entMeta.layer1_second_read = "skipped_omlx";
2528
+ }
2317
2529
  else if (modelHedgeOnly) {
2318
2530
  const second = await layer1HedgeSecondRead({
2319
2531
  l1fn, args, installed, listLoaded: deps.listLoaded, freeBytes: deps.freemem(), ollamaUrl: deps.ollamaUrl,
@@ -2491,11 +2703,31 @@ export async function runInfer(args, deps) {
2491
2703
  throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
2492
2704
  }
2493
2705
  }
2706
+ // Explicit oMLX never serves on the keyword backstop alone: a classifier
2707
+ // that could not answer a read is not permission (plan section 4), and
2708
+ // there is no cloud to escalate to. Ordinary callers keep their policy.
2709
+ if (omlxTarget && l1 === "ERROR") {
2710
+ attempts.push({ tier: "omlx", reason: "omlx_classifier_error" });
2711
+ if (wantReport)
2712
+ return refusedResult("omlx_classifier_error");
2713
+ throw new Error("prism_infer: omlx_classifier_error; explicit oMLX does not serve on the keyword backstop alone");
2714
+ }
2494
2715
  if (l1 === "OBVIOUS_NOT_RESERVED") {
2495
2716
  debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
2496
2717
  }
2497
2718
  }
2498
2719
  // ── end Layer 1 ─────────────────────────────────────────────────────────
2720
+ // Explicit oMLX classified on oMLX, so nothing of this call's is in Ollama.
2721
+ // Anything resident now belongs to another user: guardedOmlxGenerate
2722
+ // refuses generation (omlx_memory_contention) before any request.
2723
+ const omlxDeadline = Date.now() + (args.timeout_ms ?? 300_000);
2724
+ const callLocal = omlxTarget && omlxRuntime
2725
+ ? async (_url, _model, prompt, system, maxTokens, temperature, timeoutMs, think, _images, history) => {
2726
+ const messages = [...(system === undefined ? [] : [{ role: "system", content: system }]),
2727
+ ...(history ?? []), { role: "user", content: prompt }];
2728
+ return guardedOmlxGenerate(omlxRuntime, omlxTarget, { messages, maxTokens, temperature: args.temperature, timeoutMs: Math.min(timeoutMs, omlxDeadline - Date.now()), think }, deps.freemem);
2729
+ }
2730
+ : deps.callLocal;
2499
2731
  // A second read ran the 9b after free RAM and the warm set were read
2500
2732
  // above (and may have loaded it, or found it unloaded since); eviction and
2501
2733
  // the tier RAM gate below must see the machine as it is now. Every other
@@ -2513,19 +2745,20 @@ export async function runInfer(args, deps) {
2513
2745
  // A local answer to a conversation the answer check rejected or did not check: never served as answer text.
2514
2746
  let withheldReason = null;
2515
2747
  if (installed) {
2748
+ const servingTiers = omlxTarget ? [{ tag: omlxTarget.model, weightsGb: 0, minFreeGb: OMLX_MIN_AVAILABLE_BYTES / 1024 ** 3, ctxTokens: omlxTarget.contextTokens }] : MODEL_TIERS;
2516
2749
  // F4 fix: guard ceiling-not-found — Math.max(0,-1) silently targets tier 0 (27b).
2517
2750
  // Instead of defaulting to the largest tier, treat not-found as "no ceiling" (start=0).
2518
2751
  const ceilIdx = effectiveCeiling
2519
2752
  ? MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`))
2520
2753
  : -1;
2521
- const ceilStart = ceilIdx >= 0 ? ceilIdx : 0;
2754
+ const ceilStart = omlxTarget ? 0 : ceilIdx >= 0 ? ceilIdx : 0;
2522
2755
  // Auto-evict: if the ceiling tier is installed but not warm and prism's
2523
2756
  // own smaller tier models are warm, unload them to make room.
2524
2757
  // Operates only on prism tier models — never evicts arbitrary Ollama models
2525
2758
  // the caller doesn't own (F1). Uses an in-process mutex to prevent a
2526
2759
  // concurrent request from evicting a model mid-inference (F3).
2527
- let freeAfterEvict = genFreeBytes;
2528
- if (genLoaded && genLoaded.size > 0) {
2760
+ let freeAfterEvict = omlxTarget ? deps.freemem() : genFreeBytes;
2761
+ if (!omlxTarget && genLoaded && genLoaded.size > 0) {
2529
2762
  const ceilTier = MODEL_TIERS[ceilIdx >= 0 ? ceilIdx : 0];
2530
2763
  const ceilName = ceilTier ? resolveOllamaName(ceilTier.tag, installed) : null;
2531
2764
  const ceilInstalled = ceilName ? installed.has(ceilName) : false;
@@ -2579,11 +2812,7 @@ export async function runInfer(args, deps) {
2579
2812
  const released = await _evictionMutex.acquire();
2580
2813
  try {
2581
2814
  // F2 fix: await each evict call; log failures; don't proceed blind.
2582
- const evictResults = await Promise.allSettled(tierModelsToEvict.map(m => fetch(`${deps.ollamaUrl}/api/generate`, {
2583
- method: "POST",
2584
- body: JSON.stringify({ model: m, keep_alive: 0 }),
2585
- signal: AbortSignal.timeout(3_000),
2586
- })));
2815
+ const evictResults = await Promise.allSettled(tierModelsToEvict.map(m => unloadOllamaModel(deps.ollamaUrl, m)));
2587
2816
  const failed = evictResults.filter(r => r.status === "rejected").length;
2588
2817
  if (failed > 0) {
2589
2818
  debugLog(`[prism_infer] evict: ${failed}/${tierModelsToEvict.length} unload requests failed`);
@@ -2669,14 +2898,14 @@ export async function runInfer(args, deps) {
2669
2898
  // so the tier cannot be chosen on it, and a digit that is silently
2670
2899
  // wrong reads exactly like a digit that is right. The saving was worth
2671
2900
  // less than measured; the failure is worth more.
2672
- for (let i = ceilStart; i < MODEL_TIERS.length; i++) {
2673
- const tier = MODEL_TIERS[i];
2901
+ for (let i = ceilStart; i < servingTiers.length; i++) {
2902
+ const tier = servingTiers[i];
2674
2903
  // Accept the tier whether Ollama reports it as bare (`prism-coder:27b`)
2675
2904
  // or namespaced (`dcostenco/prism-coder:27b`, the form `ollama pull`
2676
2905
  // produces from a HF repo). resolveOllamaName returns the actual
2677
2906
  // name Ollama knows so /api/generate finds the model.
2678
- const ollamaName = resolveOllamaName(tier.tag, installed);
2679
- if (!installed.has(ollamaName)) {
2907
+ const ollamaName = omlxTarget ? omlxTarget.model : resolveOllamaName(tier.tag, installed);
2908
+ if (!omlxTarget && !installed.has(ollamaName)) {
2680
2909
  attempts.push({ tier: tier.tag, reason: "not_pulled" });
2681
2910
  continue;
2682
2911
  }
@@ -2713,7 +2942,7 @@ export async function runInfer(args, deps) {
2713
2942
  // unprobeable means fall back to the table, never unlock.
2714
2943
  let liveCtx = null;
2715
2944
  try {
2716
- liveCtx = await (deps.probeNumCtx ?? probeNumCtx)(deps.ollamaUrl, ollamaName);
2945
+ liveCtx = omlxTarget ? omlxTarget.contextTokens : await (deps.probeNumCtx ?? probeNumCtx)(deps.ollamaUrl, ollamaName);
2717
2946
  }
2718
2947
  catch {
2719
2948
  liveCtx = null;
@@ -2732,12 +2961,16 @@ export async function runInfer(args, deps) {
2732
2961
  // shape) and cached, including negatives.
2733
2962
  const MAX_TEMPLATE_OVERHEAD = 1_300;
2734
2963
  let CTX_TEMPLATE_MARGIN = 64;
2735
- if (promptBodyEst + MAX_TEMPLATE_OVERHEAD > effectiveCtx) {
2964
+ if (!omlxTarget && promptBodyEst + MAX_TEMPLATE_OVERHEAD > effectiveCtx) {
2736
2965
  const probed = await (deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, effectiveSystem != null);
2737
2966
  if (probed != null)
2738
2967
  CTX_TEMPLATE_MARGIN = probed;
2739
2968
  }
2740
- const promptTokensEst = promptBodyEst + CTX_TEMPLATE_MARGIN;
2969
+ const promptTokensEst = omlxTarget
2970
+ ? Buffer.byteLength(args.prompt + (effectiveSystem ?? ""), "utf8")
2971
+ + (args.messages ?? []).reduce((n, turn) => n + Buffer.byteLength(turn.content, "utf8") + 8, 0)
2972
+ + MAX_TEMPLATE_OVERHEAD + localMaxTokens
2973
+ : promptBodyEst + CTX_TEMPLATE_MARGIN;
2741
2974
  if (promptTokensEst > effectiveCtx) {
2742
2975
  attempts.push({
2743
2976
  tier: tier.tag,
@@ -2752,22 +2985,22 @@ export async function runInfer(args, deps) {
2752
2985
  continue;
2753
2986
  }
2754
2987
  anyViable = true;
2755
- const timeout = args.timeout_ms ?? DEFAULT_TIMEOUTS[tier.tag] ?? 60_000;
2756
- const enableThink = resolveThinkingMode(args, mode, tier);
2988
+ const timeout = args.timeout_ms ?? (omlxTarget ? 300_000 : DEFAULT_TIMEOUTS[tier.tag] ?? 60_000);
2989
+ const enableThink = omlxTarget ? args.think : resolveThinkingMode(args, mode, tier);
2757
2990
  // Reasoning must not crowd out the answer: a tier that thinks needs
2758
2991
  // its own floor, or the hard_truncation retry cuts the thinking back
2759
2992
  // off and lands on the configuration this tier is worst in.
2760
2993
  const tierTokens = enableThink && tier.minLocalTokens
2761
2994
  ? Math.min(Math.max(localMaxTokens, tier.minLocalTokens), 8192)
2762
2995
  : localMaxTokens;
2763
- let result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, enableThink, resolvedImages, ...historyArgs(args));
2996
+ let result = await callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, enableThink, resolvedImages, ...historyArgs(args));
2764
2997
  // Think-only retry: model burned all tokens on <think>, empty content.
2765
2998
  // Retry same model with think=false rather than falling to a smaller tier.
2766
2999
  // One-shot: think=false cannot re-trigger think_only (no thinking to burn).
2767
- if (!result.ok && result.reason === "think_only" && enableThink) {
3000
+ if (!omlxTarget && !result.ok && result.reason === "think_only" && enableThink) {
2768
3001
  debugLog(`[prism_infer] ${tier.tag} returned think-only — retrying with think=false`);
2769
3002
  recordThinkOnlyRetry();
2770
- result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, false, resolvedImages, ...historyArgs(args));
3003
+ result = await callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, false, resolvedImages, ...historyArgs(args));
2771
3004
  }
2772
3005
  if (result.ok) {
2773
3006
  // Did ollama silently drop part of the prompt?
@@ -2834,7 +3067,7 @@ export async function runInfer(args, deps) {
2834
3067
  // collapses (adversarial review 2026-09-16), and the prompt
2835
3068
  // alone would never reach the floor.
2836
3069
  const inputChars = args.prompt.length + historyChars(args);
2837
- const looksTruncated = halfCtx != null
3070
+ const looksTruncated = !omlxTarget && halfCtx != null
2838
3071
  && result.promptTokens != null
2839
3072
  && Math.abs(result.promptTokens - halfCtx) <= 8
2840
3073
  && inputChars >= halfCtx;
@@ -2879,10 +3112,10 @@ export async function runInfer(args, deps) {
2879
3112
  // there); this is chat/code on a small budget. One shot only —
2880
3113
  // think=false has no reasoning left to cut, so a second retry would
2881
3114
  // be pure latency.
2882
- if (!gate.pass && gate.reason === "hard_truncation" && enableThink) {
3115
+ if (!omlxTarget && !gate.pass && gate.reason === "hard_truncation" && enableThink) {
2883
3116
  debugLog(`[prism_infer] ${tier.tag} truncated mid-answer — retrying with think=false`);
2884
3117
  attempts.push({ tier: tier.tag, reason: "hard_truncation_retry" });
2885
- const retried = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, false, resolvedImages, ...historyArgs(args));
3118
+ const retried = await callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, false, resolvedImages, ...historyArgs(args));
2886
3119
  if (retried.ok) {
2887
3120
  const retriedStrip = stripThink(retried.text);
2888
3121
  const retriedGate = passesQualityGate(retriedStrip.stripped, retriedStrip.thinkOnly, retried.doneReason, mode, gateOptions);
@@ -2944,7 +3177,7 @@ export async function runInfer(args, deps) {
2944
3177
  // Same images and history as the first call: a repair
2945
3178
  // of a follow-up without its context "repairs" against
2946
3179
  // nothing (adversarial review 2026-09-16).
2947
- const repaired = await deps.callLocal(deps.ollamaUrl, ollamaName, repair.prompt, repairSystem, maxTokens, 0, timeout, false, resolvedImages, ...historyArgs(args));
3180
+ const repaired = await callLocal(deps.ollamaUrl, ollamaName, repair.prompt, repairSystem, maxTokens, 0, timeout, omlxTarget ? args.think : false, resolvedImages, ...historyArgs(args));
2948
3181
  if (repaired.ok) {
2949
3182
  const repairedStripped = stripThink(repaired.text);
2950
3183
  const repairedGenericGate = passesQualityGate(repairedStripped.stripped, repairedStripped.thinkOnly, repaired.doneReason, mode);
@@ -3011,9 +3244,9 @@ export async function runInfer(args, deps) {
3011
3244
  // overhead, the reasoning budget and the one follow-up turn: a
3012
3245
  // request Ollama truncates would let the check pass an answer
3013
3246
  // against a conversation it only partly read.
3014
- const runtimeCtx = await (deps.probeLoadedContext ?? probeLoadedContext)(deps.ollamaUrl, ollamaName).catch(() => null);
3247
+ const runtimeCtx = omlxTarget ? omlxTarget.contextTokens : await (deps.probeLoadedContext ?? probeLoadedContext)(deps.ollamaUrl, ollamaName).catch(() => null);
3015
3248
  const checkCtx = runtimeCtx ?? effectiveCtx;
3016
- const overhead = (await Promise.resolve((deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, true)).catch(() => null)) ?? MAX_TEMPLATE_OVERHEAD;
3249
+ const overhead = omlxTarget ? MAX_TEMPLATE_OVERHEAD : (await Promise.resolve((deps.probeTemplateOverhead ?? probeTemplateOverhead)(deps.ollamaUrl, ollamaName, true)).catch(() => null)) ?? MAX_TEMPLATE_OVERHEAD;
3017
3250
  const need = answerGroundingBytes(policy, args.messages ?? [], args.prompt, output)
3018
3251
  + estimateImageTokens(resolvedImages?.length ?? 0) + overhead
3019
3252
  + (ANSWER_GROUNDING_THINK ? ANSWER_GROUNDING_THINK_TOKENS : ANSWER_GROUNDING_OUTPUT_TOKENS)
@@ -3023,6 +3256,21 @@ export async function runInfer(args, deps) {
3023
3256
  : (await (deps.groundAnswer ?? groundAnswer)({
3024
3257
  policy, ollamaUrl: deps.ollamaUrl, model: ollamaName, messages: args.messages ?? [], prompt: args.prompt,
3025
3258
  answer: output, images: resolvedImages?.length ? resolvedImages : undefined, numCtx: checkCtx,
3259
+ ...(omlxTarget && omlxRuntime ? { fetchImpl: (async (_url, init) => {
3260
+ const body = JSON.parse(String(init?.body));
3261
+ const checked = await guardedOmlxGenerate(omlxRuntime, omlxTarget, {
3262
+ // The check's own sampling, not the caller's: temperature 0 and
3263
+ // the thinking setting of each attempt.
3264
+ messages: body.messages, maxTokens: body.options.num_predict,
3265
+ temperature: typeof body.options?.temperature === "number" ? body.options.temperature : 0,
3266
+ timeoutMs: Math.min(args.timeout_ms ?? 300_000, omlxDeadline - Date.now()),
3267
+ think: typeof body.think === "boolean" ? body.think : false,
3268
+ }, deps.freemem);
3269
+ return new Response(JSON.stringify(checked.ok ? {
3270
+ done: true, done_reason: checked.doneReason,
3271
+ message: { content: checked.text, thinking: checked.thinking }, prompt_eval_count: checked.promptTokens,
3272
+ } : { error: checked.reason }), { status: checked.ok ? 200 : 503 });
3273
+ }) } : {}),
3026
3274
  }).catch(() => ({ verdict: "ERROR" }))).verdict;
3027
3275
  }
3028
3276
  attempts.push({ tier: tier.tag, reason: `answer_check:local_${local.toLowerCase()}` });
@@ -3085,7 +3333,7 @@ export async function runInfer(args, deps) {
3085
3333
  debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
3086
3334
  }
3087
3335
  return await applyVerification(output, gatedArgs, deps, {
3088
- backend: `ollama-${tier.tag.replace("prism-coder:", "")}`,
3336
+ backend: omlxTarget ? "omlx" : `ollama-${tier.tag.replace("prism-coder:", "")}`,
3089
3337
  model_picked: tier.tag,
3090
3338
  ram_free_mb: ramFreeMb,
3091
3339
  latency_ms: Date.now() - t0,
@@ -3295,6 +3543,13 @@ async function applyVerification(draft, args, deps, partial) {
3295
3543
  */
3296
3544
  export function productionInferDeps() {
3297
3545
  return {
3546
+ omlx: createOmlxRuntime({
3547
+ listLoaded: () => strictLoaded(PRISM_LOCAL_LLM_URL),
3548
+ unload: model => unloadOllamaModel(PRISM_LOCAL_LLM_URL, model),
3549
+ // Every explicit oMLX call is owned through the provider's control routes.
3550
+ generate: (target, request, signal) => ownedOmlxGenerate(target, request, signal),
3551
+ recover: target => recoverOwnedOmlx(target),
3552
+ }, PRISM_LOCAL_LLM_URL),
3298
3553
  freemem: () => getAvailableMemoryBytes(),
3299
3554
  listTags: () => listOllamaTags(PRISM_LOCAL_LLM_URL),
3300
3555
  listLoaded: () => listOllamaLoaded(PRISM_LOCAL_LLM_URL),
@@ -3389,7 +3644,39 @@ export function inferResponseHeader(result, memory) {
3389
3644
  (memory ? ` memory=${memory.project}:${memory.depth}` : "") +
3390
3645
  (result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : ""));
3391
3646
  }
3392
- export async function prismInferHandler(args) {
3647
+ const STRUCTURED_INFER_FIELDS = [
3648
+ "output", "backend", "model_picked", "ram_free_mb", "latency_ms", "used_cloud",
3649
+ "next_step", "plan", "history_turns", "layer1_second_read", "refusal_layer",
3650
+ "prompt_tokens", "completion_tokens", "quality_gate_failed", "entitlements_source",
3651
+ ];
3652
+ /** Public machine contract. Never spread runtime objects, authentication,
3653
+ * verifier internals, or an unchecked route draft into the MCP envelope. */
3654
+ export function inferStructuredResult(result) {
3655
+ const structured = {};
3656
+ for (const field of STRUCTURED_INFER_FIELDS) {
3657
+ if (result[field] !== undefined)
3658
+ structured[field] = result[field];
3659
+ }
3660
+ structured.attempts = result.attempts.map(({ tier, reason }) => ({ tier, reason }));
3661
+ if (result.gate_outcome) {
3662
+ const { status, reason, served_anyway } = result.gate_outcome;
3663
+ structured.gate_outcome = { status, served_anyway, ...(reason === undefined ? {} : { reason }) };
3664
+ }
3665
+ if (result.multi_turn) {
3666
+ const { enabled, max_turns, max_chars } = result.multi_turn;
3667
+ structured.multi_turn = { enabled, max_turns, max_chars };
3668
+ }
3669
+ if (result.verification) {
3670
+ const { action, refusalClaim } = result.verification;
3671
+ structured.verification = { action, ...(refusalClaim === undefined ? {} : { refusalClaim }) };
3672
+ }
3673
+ if (result.route_guard) {
3674
+ const { action, source, reason } = result.route_guard;
3675
+ structured.route_guard = { action, source, ...(reason === undefined ? {} : { reason }) };
3676
+ }
3677
+ return structured;
3678
+ }
3679
+ export async function prismInferHandler(args, deps) {
3393
3680
  if (!isPrismInferArgs(args)) {
3394
3681
  const raw = typeof args === "object" && args !== null ? args : {};
3395
3682
  const mp = raw.messages !== undefined ? messagesProblem(raw.messages) : null;
@@ -3402,7 +3689,7 @@ export async function prismInferHandler(args) {
3402
3689
  }
3403
3690
  try {
3404
3691
  const prepared = await prepareMemoryAwareInferArgs(args);
3405
- const result = await runInfer(prepared.args, productionInferDeps());
3692
+ const result = await runInfer(prepared.args, deps ?? productionInferDeps());
3406
3693
  debugLog(`[prism_infer] backend=${result.backend} model=${result.model_picked} latency=${result.latency_ms}ms free=${result.ram_free_mb}MB`);
3407
3694
  // Local accumulator — sole source of the user-facing metrics block.
3408
3695
  // T4: pass prompt_text so recordInference computes submittedEst via
@@ -3447,6 +3734,7 @@ export async function prismInferHandler(args) {
3447
3734
  // for the host to show the user.
3448
3735
  ...(result.next_step ? [{ type: "text", text: `Next step: ${result.next_step}` }] : []),
3449
3736
  ],
3737
+ structuredContent: inferStructuredResult(result),
3450
3738
  };
3451
3739
  }
3452
3740
  catch (err) {