prism-mcp-server 20.2.5 → 20.2.7

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.
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Session state tracking — connection-scoped, in-process.
2
+ * Session state tracking — in-process hot path with a durable local receipt.
3
3
  *
4
4
  * This is NOT business logic — it's MCP connection lifecycle state.
5
5
  * Business logic (skill routing, budget tranching, content resolution)
6
6
  * lives in the synalux portal at /api/v1/prism/skills.
7
7
  *
8
- * What stays here (connection-scoped, cannot be portal-side):
8
+ * What stays here (host lifecycle state, cannot be portal-side):
9
9
  * markContextLoaded / requireContextLoaded — write-gate for session tools
10
10
  * noteInferenceForSession — telemetry counter
11
11
  * drift timer — connection-scoped GATE 5 enforcement
@@ -14,9 +14,12 @@
14
14
  * Skill routing, budget tranching, content loading, phantom detection,
15
15
  * prompt-keyword matching, user-local skill loading, context-discovery.
16
16
  */
17
+ import { createHash } from "node:crypto";
17
18
  import { BOUNDARIES_VERSION as CURRENT_BOUNDARIES_VERSION } from "../boundaries/boundaries.js";
19
+ import * as configStorage from "../storage/configStorage.js";
18
20
  const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 h — conversation-scoped
19
21
  const MAX_SESSIONS = 10_000;
22
+ const RECEIPT_CLOCK_SKEW_MS = 60_000;
20
23
  /**
21
24
  * Connection-scoped fallback: remember the last conversation_id seen via
22
25
  * markContextLoaded so that tools which don't carry conversation_id
@@ -81,6 +84,62 @@ export function markContextLoaded(conversationId, project, boundariesVersion) {
81
84
  s.boundariesVersion = boundariesVersion;
82
85
  lastSeenConversationId = conversationId;
83
86
  }
87
+ function contextNotLoadedError(project) {
88
+ const projectNote = project
89
+ ? " the requested project was not loaded for this conversation."
90
+ : "";
91
+ return {
92
+ blocked: true,
93
+ error: "context_not_loaded:" + projectNote + " Call session_bootstrap(conversation_id) or " +
94
+ "session_load_context(project, conversation_id) " +
95
+ "before this action. This project-scoped tool needs confirmed working context " +
96
+ "to act correctly. (Enforced server-side — applies to every host.)",
97
+ };
98
+ }
99
+ function contextExpiredError() {
100
+ return {
101
+ blocked: true,
102
+ error: "context_not_loaded: session expired (6 h TTL). Call " +
103
+ "session_bootstrap(conversation_id) or session_load_context(project, conversation_id) again. " +
104
+ "(Enforced server-side — applies to every host.)",
105
+ };
106
+ }
107
+ function hashReceiptScope(kind, value) {
108
+ return createHash("sha256").update(`prism-session-${kind}\0${value}`).digest("hex");
109
+ }
110
+ async function persistContextReceipt(conversationId, project, boundariesVersion, loadedAt) {
111
+ const now = Date.now();
112
+ const receipt = {
113
+ conversationHash: hashReceiptScope("conversation", conversationId),
114
+ projectHash: hashReceiptScope("project", project),
115
+ project,
116
+ boundariesVersion,
117
+ loadedAt,
118
+ lastSeen: now,
119
+ };
120
+ await configStorage.saveSessionContextReceipt(receipt, now - SESSION_TTL_MS);
121
+ }
122
+ async function persistContextReceiptBestEffort(conversationId, project, boundariesVersion, loadedAt) {
123
+ try {
124
+ await persistContextReceipt(conversationId, project, boundariesVersion, loadedAt);
125
+ }
126
+ catch (error) {
127
+ // Keep the established same-process path available on read-only or damaged
128
+ // config stores, but make degraded restart recovery visible to operators.
129
+ console.error(`[sessionContext] Durable context receipt unavailable for project "${project}": ` +
130
+ `${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ /**
134
+ * Registers successful context materialization and persists an opaque receipt.
135
+ * The conversation_id never leaves this process unhashed.
136
+ */
137
+ export async function registerContextLoaded(conversationId, project, boundariesVersion) {
138
+ markContextLoaded(conversationId, project, boundariesVersion);
139
+ noteDriftSessionStart(conversationId);
140
+ const s = sessions.get(conversationId);
141
+ await persistContextReceiptBestEffort(conversationId, project, boundariesVersion, s?.driftSessionStart ?? Date.now());
142
+ }
84
143
  /**
85
144
  * Soft gate for handlers that need project context to be CORRECT (not safe).
86
145
  *
@@ -108,21 +167,10 @@ export function requireContextLoaded(conversationId) {
108
167
  // even if no write has triggered eviction yet. Evict immediately on detection.
109
168
  if (s && (Date.now() - s.lastSeen) > SESSION_TTL_MS) {
110
169
  sessions.delete(conversationId);
111
- return {
112
- blocked: true,
113
- error: "context_not_loaded: session expired (6 h TTL). Call " +
114
- "session_bootstrap(conversation_id) or session_load_context(project, conversation_id) again. " +
115
- "(Enforced server-side — applies to every host.)",
116
- };
170
+ return contextExpiredError();
117
171
  }
118
172
  if (!s || !s.contextLoaded) {
119
- return {
120
- blocked: true,
121
- error: "context_not_loaded: call session_bootstrap(conversation_id) or " +
122
- "session_load_context(project, conversation_id) " +
123
- "before this action. This project-scoped tool needs confirmed working context " +
124
- "to act correctly. (Enforced server-side — applies to every host.)",
125
- };
173
+ return contextNotLoadedError();
126
174
  }
127
175
  // Touch on valid read — maintains LRU order.
128
176
  touch(conversationId, s);
@@ -139,6 +187,56 @@ export function requireContextLoaded(conversationId) {
139
187
  }
140
188
  return null;
141
189
  }
190
+ /**
191
+ * Project-scoped durable gate used by ledger and handoff writes.
192
+ *
193
+ * The in-memory state remains the hot path. If another MCP process loaded the
194
+ * requested project, or this process restarted, an unexpired hashed receipt
195
+ * restores only that exact project. Unknown, malformed, expired, and
196
+ * cross-project lookups remain fail-closed.
197
+ */
198
+ export async function requireContextLoadedForProject(conversationId, project) {
199
+ if (conversationId === undefined)
200
+ return null;
201
+ if (!conversationId || !project.trim())
202
+ return contextNotLoadedError(project || undefined);
203
+ const memoryGate = requireContextLoaded(conversationId);
204
+ const memoryState = sessions.get(conversationId);
205
+ if (memoryState?.contextLoaded && memoryState.project === project &&
206
+ !(memoryGate && memoryGate.blocked)) {
207
+ await persistContextReceiptBestEffort(conversationId, project, memoryState.boundariesVersion ?? CURRENT_BOUNDARIES_VERSION, memoryState.driftSessionStart ?? memoryState.lastSeen);
208
+ return memoryGate;
209
+ }
210
+ let receipt = null;
211
+ try {
212
+ receipt = await configStorage.getSessionContextReceipt(hashReceiptScope("conversation", conversationId), hashReceiptScope("project", project));
213
+ }
214
+ catch (error) {
215
+ console.error(`[sessionContext] Durable context receipt lookup failed for project "${project}": ` +
216
+ `${error instanceof Error ? error.message : String(error)}`);
217
+ return memoryGate && memoryGate.blocked ? memoryGate : contextNotLoadedError(project);
218
+ }
219
+ if (!receipt)
220
+ return memoryGate?.blocked ? memoryGate : contextNotLoadedError(project);
221
+ const now = Date.now();
222
+ const invalidReceipt = receipt.project !== project ||
223
+ !Number.isFinite(receipt.loadedAt) ||
224
+ !Number.isFinite(receipt.lastSeen) ||
225
+ receipt.loadedAt <= 0 ||
226
+ receipt.loadedAt > receipt.lastSeen ||
227
+ receipt.lastSeen > now + RECEIPT_CLOCK_SKEW_MS;
228
+ if (invalidReceipt)
229
+ return contextNotLoadedError(project);
230
+ if ((now - receipt.lastSeen) > SESSION_TTL_MS)
231
+ return contextExpiredError();
232
+ markContextLoaded(conversationId, project, receipt.boundariesVersion);
233
+ const restored = sessions.get(conversationId);
234
+ if (restored)
235
+ restored.driftSessionStart = receipt.loadedAt;
236
+ const restoredGate = requireContextLoaded(conversationId);
237
+ await persistContextReceiptBestEffort(conversationId, project, receipt.boundariesVersion, receipt.loadedAt);
238
+ return restoredGate;
239
+ }
142
240
  /** Best-effort telemetry from prism_infer. Never affects a safety decision. */
143
241
  export function noteInferenceForSession(conversationId, info) {
144
242
  // Only update sessions that already exist — don't create ghost stubs for
@@ -61,6 +61,17 @@ export async function initConfigStorage() {
61
61
  owner TEXT NOT NULL DEFAULT 'prism',
62
62
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
63
63
  )
64
+ `);
65
+ await client.execute(`
66
+ CREATE TABLE IF NOT EXISTS session_context_receipts (
67
+ conversation_hash TEXT NOT NULL,
68
+ project_hash TEXT NOT NULL,
69
+ project TEXT NOT NULL,
70
+ boundaries_version TEXT NOT NULL,
71
+ loaded_at INTEGER NOT NULL,
72
+ last_seen INTEGER NOT NULL,
73
+ PRIMARY KEY (conversation_hash, project_hash)
74
+ )
64
75
  `);
65
76
  // Preload all rows into the cache so subsequent reads are zero-cost.
66
77
  const rs = await client.execute("SELECT key, value FROM system_settings");
@@ -192,6 +203,95 @@ export function getSettingSync(key, defaultValue = "") {
192
203
  return defaultValue;
193
204
  return settingsCache[key] ?? defaultValue;
194
205
  }
206
+ const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
207
+ function validateSessionContextReceipt(receipt) {
208
+ if (!SHA256_HEX_RE.test(receipt.conversationHash) ||
209
+ !SHA256_HEX_RE.test(receipt.projectHash) ||
210
+ !receipt.project.trim() ||
211
+ !receipt.boundariesVersion.trim() ||
212
+ !Number.isFinite(receipt.loadedAt) ||
213
+ !Number.isFinite(receipt.lastSeen)) {
214
+ throw new Error("Invalid session context receipt");
215
+ }
216
+ }
217
+ /**
218
+ * Persists only hashes of the caller-supplied identifiers. The project name is
219
+ * retained so recovery can verify that a hash lookup did not cross scopes.
220
+ * Expired rows are pruned in the same transaction to keep the local table
221
+ * bounded by active sessions rather than process lifetime.
222
+ */
223
+ export async function saveSessionContextReceipt(receipt, expiresBefore) {
224
+ validateSessionContextReceipt(receipt);
225
+ if (!Number.isFinite(expiresBefore))
226
+ throw new Error("Invalid session receipt expiry");
227
+ await initConfigStorage();
228
+ const client = getClient();
229
+ await client.batch([
230
+ {
231
+ sql: "DELETE FROM session_context_receipts WHERE last_seen < ?",
232
+ args: [expiresBefore],
233
+ },
234
+ {
235
+ sql: `
236
+ INSERT INTO session_context_receipts (
237
+ conversation_hash,
238
+ project_hash,
239
+ project,
240
+ boundaries_version,
241
+ loaded_at,
242
+ last_seen
243
+ )
244
+ VALUES (?, ?, ?, ?, ?, ?)
245
+ ON CONFLICT(conversation_hash, project_hash) DO UPDATE SET
246
+ project = excluded.project,
247
+ boundaries_version = excluded.boundaries_version,
248
+ loaded_at = excluded.loaded_at,
249
+ last_seen = excluded.last_seen
250
+ `,
251
+ args: [
252
+ receipt.conversationHash,
253
+ receipt.projectHash,
254
+ receipt.project,
255
+ receipt.boundariesVersion,
256
+ receipt.loadedAt,
257
+ receipt.lastSeen,
258
+ ],
259
+ },
260
+ ], "write");
261
+ }
262
+ export async function getSessionContextReceipt(conversationHash, projectHash) {
263
+ if (!SHA256_HEX_RE.test(conversationHash) || !SHA256_HEX_RE.test(projectHash)) {
264
+ return null;
265
+ }
266
+ await initConfigStorage();
267
+ const rs = await getClient().execute({
268
+ sql: `
269
+ SELECT project, boundaries_version, loaded_at, last_seen
270
+ FROM session_context_receipts
271
+ WHERE conversation_hash = ? AND project_hash = ?
272
+ LIMIT 1
273
+ `,
274
+ args: [conversationHash, projectHash],
275
+ });
276
+ if (rs.rows.length === 0)
277
+ return null;
278
+ const row = rs.rows[0];
279
+ const receipt = {
280
+ conversationHash,
281
+ projectHash,
282
+ project: String(row.project ?? ""),
283
+ boundariesVersion: String(row.boundaries_version ?? ""),
284
+ loadedAt: Number(row.loaded_at),
285
+ lastSeen: Number(row.last_seen),
286
+ };
287
+ try {
288
+ validateSessionContextReceipt(receipt);
289
+ return receipt;
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
195
295
  export async function getSetting(key, defaultValue = "") {
196
296
  await initConfigStorage();
197
297
  // Serve from cache when warm (the common case after startup).
@@ -63,7 +63,7 @@ export async function knowledgeSearchHandler(args) {
63
63
  }
64
64
  // Phase 1: destructure enable_trace (defaults to false for backward compat)
65
65
  const { project, query, category, limit = 10, enable_trace = false, activation } = args;
66
- debugLog(`[knowledge_search] Searching: project=${project || "all"}, query="${query || ""}", category=${category || "any"}, limit=${limit}`);
66
+ debugLog(`[knowledge_search] Searching: project=${project || "all"}, query_chars=${query?.length || 0}, category=${category || "any"}, limit=${limit}`);
67
67
  // Phase 1: Capture total start time for latency measurement
68
68
  const totalStart = performance.now();
69
69
  const storage = await getStorage();
@@ -89,7 +89,8 @@ export async function knowledgeSearchHandler(args) {
89
89
  });
90
90
  const storageMs = performance.now() - storageStart;
91
91
  const totalMs = performance.now() - totalStart;
92
- if (!data) {
92
+ const resultCount = Array.isArray(data?.results) ? data.results.length : 0;
93
+ if (!data || resultCount === 0) {
93
94
  // Phase 1: Use contentBlocks array instead of inline object
94
95
  // so we can conditionally push the trace block at content[1]
95
96
  const contentBlocks = [{
@@ -135,14 +136,14 @@ export async function knowledgeSearchHandler(args) {
135
136
  // Phase 1: Wrap in contentBlocks array for optional trace attachment
136
137
  const contentBlocks = [{
137
138
  type: "text",
138
- text: `🧠 Found ${data.count} knowledge entries:\n\n${JSON.stringify(data.results || data, null, 2)}`,
139
+ text: `🧠 Found ${resultCount} knowledge entries:\n\n${JSON.stringify(data.results, null, 2)}`,
139
140
  }];
140
141
  // Phase 1: Attach MemoryTrace with strategy="keyword" and timing data
141
142
  if (enable_trace) {
142
143
  const trace = createMemoryTrace({
143
144
  strategy: "keyword",
144
145
  query: query || "",
145
- resultCount: data.count,
146
+ resultCount,
146
147
  topScore: null, // keyword search doesn't produce similarity scores
147
148
  threshold: null, // keyword search has no threshold concept
148
149
  embeddingMs: 0, // no embedding needed for keyword search
@@ -57,7 +57,7 @@ export async function braveWebSearchCodeModeHandler(args) {
57
57
  };
58
58
  }
59
59
  // 1. Fetch raw data
60
- debugLog(`Fetching web search for code mode: "${query}"`);
60
+ debugLog(`Fetching web search for code mode: query_chars=${query.length}`);
61
61
  const rawDataStr = await performWebSearchRaw(query, count, offset);
62
62
  const beforeSizeKB = (Buffer.byteLength(rawDataStr, 'utf8') / 1024).toFixed(1);
63
63
  // 2. Run code mode sandbox
@@ -94,7 +94,7 @@ export async function braveLocalSearchCodeModeHandler(args) {
94
94
  isError: true,
95
95
  };
96
96
  }
97
- debugLog(`Fetching local search for code mode: "${query}"`);
97
+ debugLog(`Fetching local search for code mode: query_chars=${query.length}`);
98
98
  const rawDataStr = await performLocalSearchRaw(query, count);
99
99
  const beforeSizeKB = (Buffer.byteLength(rawDataStr, "utf8") / 1024).toFixed(1);
100
100
  debugLog("Executing local search code mode sandbox...");
@@ -275,8 +275,8 @@ export async function sessionSaveLedgerHandler(args) {
275
275
  // silently wrong write. Does NOT gate safety — prism_infer handles that.
276
276
  let _saveLedgerGateWarning;
277
277
  {
278
- const { requireContextLoaded } = await import("../session/sessionContext.js");
279
- const gate = requireContextLoaded(args.conversation_id);
278
+ const { requireContextLoadedForProject } = await import("../session/sessionContext.js");
279
+ const gate = await requireContextLoadedForProject(args.conversation_id, args.project);
280
280
  if (gate !== null && gate.blocked) {
281
281
  return { content: [{ type: "text", text: gate.error }], isError: true };
282
282
  }
@@ -452,8 +452,8 @@ export async function sessionSaveHandoffHandler(args, server) {
452
452
  }
453
453
  let _saveHandoffGateWarning;
454
454
  {
455
- const { requireContextLoaded } = await import("../session/sessionContext.js");
456
- const gate = requireContextLoaded(args.conversation_id);
455
+ const { requireContextLoadedForProject } = await import("../session/sessionContext.js");
456
+ const gate = await requireContextLoadedForProject(args.conversation_id, args.project);
457
457
  if (gate !== null && gate.blocked) {
458
458
  return { content: [{ type: "text", text: gate.error }], isError: true };
459
459
  }
@@ -885,10 +885,9 @@ export async function sessionLoadContextHandler(args, options = {}) {
885
885
  }
886
886
  }
887
887
  if (convId) {
888
- const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
888
+ const { registerContextLoaded } = await import("../session/sessionContext.js");
889
889
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
890
- markContextLoaded(convId, project, BOUNDARIES_VERSION);
891
- noteDriftSessionStart(convId);
890
+ await registerContextLoaded(convId, project, BOUNDARIES_VERSION);
892
891
  }
893
892
  const freshText = `No session context found for project "${project}" at level ${level}.\n` +
894
893
  `This project has no previous session history. Starting fresh.` +
@@ -1163,10 +1162,9 @@ export async function sessionLoadContextHandler(args, options = {}) {
1163
1162
  }
1164
1163
  nativeContext += `\n**Session Version:** ${version === null || version === undefined ? "None" : compact(version, 40)}\n`;
1165
1164
  if (convId) {
1166
- const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
1165
+ const { registerContextLoaded } = await import("../session/sessionContext.js");
1167
1166
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1168
- markContextLoaded(convId, project, BOUNDARIES_VERSION);
1169
- noteDriftSessionStart(convId);
1167
+ await registerContextLoaded(convId, project, BOUNDARIES_VERSION);
1170
1168
  }
1171
1169
  return {
1172
1170
  content: [{
@@ -1407,10 +1405,9 @@ export async function sessionLoadContextHandler(args, options = {}) {
1407
1405
  responseText += `\n\n[ℹ️ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
1408
1406
  }
1409
1407
  if (convId) {
1410
- const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
1408
+ const { registerContextLoaded } = await import("../session/sessionContext.js");
1411
1409
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1412
- markContextLoaded(convId, project, BOUNDARIES_VERSION);
1413
- noteDriftSessionStart(convId);
1410
+ await registerContextLoaded(convId, project, BOUNDARIES_VERSION);
1414
1411
  }
1415
1412
  return {
1416
1413
  content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
@@ -1419,10 +1416,9 @@ export async function sessionLoadContextHandler(args, options = {}) {
1419
1416
  }
1420
1417
  let responseText = criticalPrefix + lowerPriority + historySection;
1421
1418
  if (convId) {
1422
- const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
1419
+ const { registerContextLoaded } = await import("../session/sessionContext.js");
1423
1420
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1424
- markContextLoaded(convId, project, BOUNDARIES_VERSION);
1425
- noteDriftSessionStart(convId);
1421
+ await registerContextLoaded(convId, project, BOUNDARIES_VERSION);
1426
1422
  }
1427
1423
  return {
1428
1424
  content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
@@ -12,7 +12,7 @@
12
12
  * 4. On local fail, if cloud_fallback=true:
13
13
  * - exchange synalux_sk_ → JWT (cached)
14
14
  * - POST synalux portal /api/v1/prism/inference
15
- * - portal runs its own cascade (9B/27B/Claude by tier)
15
+ * - portal serves Gemini 3.6 Flash according to the user's tier
16
16
  * 5. Return { output, backend, model_picked, ram_free_mb, latency_ms, used_cloud }
17
17
  *
18
18
  * `prism_infer` is a thin client. It never calls Anthropic / OpenRouter
@@ -28,6 +28,7 @@ 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
+ import { applyDeterministicCodingRepairs, buildCodingRepairPrompt, passesCodingQualityGate, } from "../utils/codingQualityPolicy.js";
31
32
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
32
33
  import { callLayer1 as defaultCallLayer1, keywordBackstop } from "../utils/layer1.js";
33
34
  import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics, estimateTokens } from "../utils/inferenceMetrics.js";
@@ -61,6 +62,7 @@ const MEMORY_HISTORY_LIMITS = {
61
62
  };
62
63
  const FAST_TASK_COMPLEXITY_MAX = 3;
63
64
  const BALANCED_TASK_COMPLEXITY_MAX = 6;
65
+ const MAX_CODING_REPAIR_ATTEMPTS = 2;
64
66
  // ─── Tool Definition ────────────────────────────────────────────
65
67
  export const PRISM_INFER_TOOL = {
66
68
  name: "prism_infer",
@@ -68,7 +70,7 @@ export const PRISM_INFER_TOOL = {
68
70
  "Owns model selection across 27B / 9B / 4B / 2B using an explicit `model_ceiling` or " +
69
71
  "the caller's `task_complexity`, then validates loaded memory size, model context, " +
70
72
  "entitlements, installed models, and free RAM at call time. " +
71
- "Falls through to the synalux portal cloud cascade (9B 27B → Claude Opus 4.7) " +
73
+ "Falls through to the Synalux portal Gemini 3.6 Flash cloud fallback " +
72
74
  "only when local is unviable AND `cloud_fallback=true`. " +
73
75
  "When `project` is provided, loads the dashboard-configured quick/standard/deep handoff and bounded history " +
74
76
  "as untrusted historical context for a memory-aware local worker. " +
@@ -448,8 +450,8 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs, opts) {
448
450
  return { ok: false, reason: "jwt_exchange_failed" };
449
451
  const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/inference`;
450
452
  // reserved=true tells the portal this prompt was refused by local Layer-1
451
- // as reserved clinical content: it must be served by Claude or refused —
452
- // never by a small local model or OpenRouter (plan v2 §5.1).
453
+ // as reserved clinical content: it must be served by the portal's
454
+ // reserved-capable cloud backend or refused never by a local model.
453
455
  const reqBody = JSON.stringify({ prompt, max_tokens: maxTokens, ...(opts?.reserved ? { reserved: true } : {}) });
454
456
  try {
455
457
  let res = await fetch(url, {
@@ -874,10 +876,82 @@ export async function runInfer(args, deps) {
874
876
  result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, args.system, maxTokens, temperature, timeout, false);
875
877
  }
876
878
  if (result.ok) {
877
- const { stripped, thinkOnly } = stripThink(result.text);
878
- const output = stripped;
879
+ let { stripped, thinkOnly } = stripThink(result.text);
880
+ let output = stripped;
879
881
  // Quality gate — all modes. Route uses mode-aware empty floor (length===0).
880
- const gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
882
+ let gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
883
+ if (gate.pass && mode === "code") {
884
+ gate = passesCodingQualityGate(args.prompt, output);
885
+ }
886
+ // High-precision coding failures get bounded same-tier repair
887
+ // attempts before cloud escalation. Multiple attempts matter
888
+ // when syntax repair exposes a second structural defect.
889
+ for (let repairAttempt = 0; repairAttempt < MAX_CODING_REPAIR_ATTEMPTS; repairAttempt++) {
890
+ const codingGateFailure = !gate.pass &&
891
+ mode === "code" &&
892
+ (gate.reason?.startsWith("code_") === true ||
893
+ gate.reason?.startsWith("python_") === true);
894
+ if (!codingGateFailure)
895
+ break;
896
+ const failedReason = gate.reason ?? "code_quality";
897
+ const deterministicRepair = applyDeterministicCodingRepairs(output, failedReason);
898
+ if (deterministicRepair.changes.length > 0) {
899
+ output = deterministicRepair.output;
900
+ gate = passesQualityGate(output, false, result.doneReason, mode);
901
+ if (gate.pass) {
902
+ gate = passesCodingQualityGate(args.prompt, output);
903
+ }
904
+ attempts.push({
905
+ tier: tier.tag,
906
+ reason: `code_repair_deterministic:${deterministicRepair.changes.join(",")}`,
907
+ });
908
+ if (gate.pass)
909
+ break;
910
+ }
911
+ const repair = buildCodingRepairPrompt(args.prompt, output, failedReason);
912
+ const repairSystem = args.system
913
+ ? `${args.system}\n\n${repair.system}`
914
+ : repair.system;
915
+ const repairPromptTokens = estimateTokens(repair.prompt) +
916
+ estimateTokens(repairSystem) +
917
+ CTX_TEMPLATE_MARGIN;
918
+ if (repairPromptTokens <= tier.ctxTokens) {
919
+ attempts.push({ tier: tier.tag, reason: `code_repair:${failedReason}` });
920
+ const repaired = await deps.callLocal(deps.ollamaUrl, ollamaName, repair.prompt, repairSystem, maxTokens, 0, timeout, false);
921
+ if (repaired.ok) {
922
+ const repairedStripped = stripThink(repaired.text);
923
+ const repairedGenericGate = passesQualityGate(repairedStripped.stripped, repairedStripped.thinkOnly, repaired.doneReason, mode);
924
+ const repairedGate = repairedGenericGate.pass
925
+ ? passesCodingQualityGate(args.prompt, repairedStripped.stripped)
926
+ : repairedGenericGate;
927
+ result = repaired;
928
+ stripped = repairedStripped.stripped;
929
+ thinkOnly = repairedStripped.thinkOnly;
930
+ output = stripped;
931
+ gate = repairedGate;
932
+ if (!gate.pass) {
933
+ attempts.push({
934
+ tier: tier.tag,
935
+ reason: `code_repair_failed:${gate.reason ?? "quality_gate"}`,
936
+ });
937
+ }
938
+ }
939
+ else {
940
+ attempts.push({
941
+ tier: tier.tag,
942
+ reason: `code_repair_error:${repaired.reason}`,
943
+ });
944
+ break;
945
+ }
946
+ }
947
+ else {
948
+ attempts.push({
949
+ tier: tier.tag,
950
+ reason: "code_repair_skipped:ctx_insufficient",
951
+ });
952
+ break;
953
+ }
954
+ }
881
955
  if (!gate.pass && allowCloud) {
882
956
  debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
883
957
  attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });