prism-mcp-server 20.0.6 → 20.0.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.
@@ -904,14 +904,14 @@ export async function sessionLoadContextHandler(args) {
904
904
  // If the active role has a skill document stored, append it so the
905
905
  // agent loads its rules/conventions automatically at session start.
906
906
  let skillBlock = "";
907
- let skillLoaded = false;
908
907
  const loadedSkills = [];
908
+ const skillEntries = [];
909
909
  if (effectiveRole) {
910
910
  const skillContent = await getSetting(`skill:${effectiveRole}`, "");
911
911
  if (skillContent && skillContent.trim()) {
912
- skillBlock = `\n\n[📜 ROLE SKILL: ${effectiveRole}]\n${skillContent.trim()}`;
913
- skillLoaded = true;
914
- loadedSkills.push(effectiveRole);
912
+ // protected: the role skill is deliberate per-agent configuration and
913
+ // was unconditionally injected before budgeting existed.
914
+ skillEntries.push({ name: effectiveRole, content: skillContent, protected: true, category: "role", priority: -1 });
915
915
  debugLog(`[session_load_context] Injecting skill for role="${effectiveRole}" (${skillContent.length} chars)`);
916
916
  }
917
917
  }
@@ -928,14 +928,23 @@ export async function sessionLoadContextHandler(args) {
928
928
  } });
929
929
  const skillResolution = await resolveSkills(project, prompt, effectiveRole);
930
930
  // Client-renders-content: portal returns names, we load content from local DB
931
+ const resolvedMeta = new Map((skillResolution.skills || []).map((s) => [s.name, s]));
932
+ // Legacy last-good caches carry names without metadata; OFFLINE_FALLBACK
933
+ // membership is the floor of last resort so core rules stay inlined.
934
+ const { OFFLINE_FALLBACK } = await import("./skillRouting.js");
935
+ const fallbackFloor = new Set(OFFLINE_FALLBACK.universal.map((e) => (typeof e === "string" ? e : e.name)));
931
936
  for (const name of skillResolution.names || []) {
932
- if (loadedSkills.includes(name))
937
+ if (skillEntries.some((e) => e.name === name))
933
938
  continue;
934
939
  const content = await getSetting(`skill:${name}`, "");
935
940
  if (content?.trim()) {
936
- skillBlock += `\n\n[📜 SKILL: ${name}]\n${content.trim()}`;
937
- loadedSkills.push(name);
938
- skillLoaded = true;
941
+ const meta = resolvedMeta.get(name);
942
+ skillEntries.push({
943
+ name, content,
944
+ protected: meta?.protected ?? (skillResolution.isOffline && fallbackFloor.has(name)),
945
+ category: meta?.category ?? "universal",
946
+ priority: meta?.priority ?? 999,
947
+ });
939
948
  }
940
949
  }
941
950
  // Offline fallback: load ALL local skill: content (no tier gating).
@@ -948,16 +957,34 @@ export async function sessionLoadContextHandler(args) {
948
957
  if (!k.startsWith("skill:") || !v)
949
958
  continue;
950
959
  const name = k.replace("skill:", "");
951
- if (loadedSkills.includes(name))
960
+ if (skillEntries.some((e) => e.name === name))
952
961
  continue;
953
962
  const content = v.trim();
954
- if (content && content.trim()) {
955
- skillBlock += '\n\n[📜 SKILL: ' + name + ']\n' + content.trim();
956
- loadedSkills.push(name);
957
- skillLoaded = true;
963
+ if (content) {
964
+ // Offline can't know protected flags; OFFLINE_FALLBACK names are the
965
+ // best available floor — mark those protected so they always inline.
966
+ skillEntries.push({ name, content, protected: fallbackFloor.has(name), category: "offline", priority: 999 });
958
967
  }
959
968
  }
960
969
  }
970
+ // ─── Skill delivery budget (local-first plan v2 Phase 1) ────
971
+ // Paid-tier resolution returns 30+ skills (~114KB measured). Unbudgeted
972
+ // inlining exceeds host tool-result caps and the whole response gets
973
+ // file-diverted — the agent receives NOTHING. Budgeted assembly: protected
974
+ // always full, prompt-matched next, tail by priority, overflow by name.
975
+ // 60% of the response budget: skills must not saturate the whole allowance
976
+ // or T5 truncation zeroes out briefing/history — the memory this tool
977
+ // exists to deliver. The protected floor may still exceed this tranche
978
+ // (always inlined); the reserved 40% keeps history alive whenever the
979
+ // caller's budget covers the floor at all.
980
+ const skillBudgetChars = maxTokens && maxTokens > 0 ? Math.floor(maxTokens * 3.5 * 0.6) : Number.POSITIVE_INFINITY;
981
+ const { assembleSkillBlock } = await import("../utils/skillBudget.js");
982
+ const budgeted = assembleSkillBlock(skillEntries, skillBudgetChars);
983
+ skillBlock = budgeted.block;
984
+ loadedSkills.push(...budgeted.inlined);
985
+ if (budgeted.overflow.length > 0) {
986
+ debugLog(`[session_load_context] skill budget: inlined ${budgeted.inlined.length}, overflow ${budgeted.overflow.length} (${skillBudgetChars} chars)`);
987
+ }
961
988
  // ─── Agent Greeting Block ────────────────────────────────────
962
989
  // Shows agent identity (name + role) and skill status after briefing.
963
990
  let greetingBlock = "";
@@ -31,6 +31,7 @@ import { passesQualityGate } from "../utils/qualityGate.js";
31
31
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
32
32
  import { callLayer1 as defaultCallLayer1, keywordBackstop } from "../utils/layer1.js";
33
33
  import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
34
+ import { appendInferMetric } from "../storage/inferMetricsLedger.js";
34
35
  // ─── Tool Definition ────────────────────────────────────────────
35
36
  export const PRISM_INFER_TOOL = {
36
37
  name: "prism_infer",
@@ -259,13 +260,42 @@ async function callOllamaGenerate(url, model, prompt, system, maxTokens, tempera
259
260
  return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "timeout" : "network" };
260
261
  }
261
262
  }
262
- async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
263
+ // ─── Cloud fallback via synalux portal ─────────────────────────
264
+ /**
265
+ * Typed refusal for reserved clinical content (plan v2 §5.1) — callers can
266
+ * distinguish "refused for safety" from infrastructure failure via
267
+ * `refusal_reason` instead of parsing the message. Also ledgered so refusals
268
+ * are visible in delegation metrics (backend='refused').
269
+ */
270
+ export class ReservedRefusalError extends Error {
271
+ attempts;
272
+ refusal_reason = "layer1_reserved";
273
+ constructor(verdict, attempts) {
274
+ super(`prism_infer: Layer 1 verdict=${verdict}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
275
+ this.attempts = attempts;
276
+ this.name = "ReservedRefusalError";
277
+ }
278
+ }
279
+ function makeReservedRefusal(verdict, attempts) {
280
+ // Ledger the refusal (fire-and-forget). No prompt content is persisted —
281
+ // same HIPAA posture as the safety_gate exclusion.
282
+ appendInferMetric({
283
+ backend: "refused", model: null, used_cloud: false,
284
+ refusal_reason: "layer1_reserved",
285
+ });
286
+ return new ReservedRefusalError(verdict, attempts);
287
+ }
288
+ async function callSynaluxInference(prompt, maxTokens, timeoutMs, opts) {
263
289
  if (!PRISM_SYNALUX_BASE_URL)
264
290
  return { ok: false, reason: "no_synalux_base_url" };
265
291
  const jwt = await getSynaluxJwt();
266
292
  if (!jwt)
267
293
  return { ok: false, reason: "jwt_exchange_failed" };
268
294
  const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/inference`;
295
+ // reserved=true tells the portal this prompt was refused by local Layer-1
296
+ // as reserved clinical content: it must be served by Claude or refused —
297
+ // never by a small local model or OpenRouter (plan v2 §5.1).
298
+ const reqBody = JSON.stringify({ prompt, max_tokens: maxTokens, ...(opts?.reserved ? { reserved: true } : {}) });
269
299
  try {
270
300
  let res = await fetch(url, {
271
301
  method: "POST",
@@ -273,7 +303,7 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
273
303
  "Authorization": `Bearer ${jwt}`,
274
304
  "Content-Type": "application/json",
275
305
  },
276
- body: JSON.stringify({ prompt, max_tokens: maxTokens }),
306
+ body: reqBody,
277
307
  signal: AbortSignal.timeout(timeoutMs),
278
308
  redirect: "error",
279
309
  });
@@ -289,7 +319,7 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
289
319
  "Authorization": `Bearer ${fresh}`,
290
320
  "Content-Type": "application/json",
291
321
  },
292
- body: JSON.stringify({ prompt, max_tokens: maxTokens }),
322
+ body: reqBody,
293
323
  signal: AbortSignal.timeout(timeoutMs),
294
324
  redirect: "error",
295
325
  });
@@ -433,8 +463,18 @@ export async function runInfer(args, deps) {
433
463
  attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
434
464
  if (allowCloud) {
435
465
  const cloudTimeout = args.timeout_ms ?? 90_000;
436
- const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
466
+ const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout, { reserved: true });
437
467
  if (cloud.ok && cloud.output) {
468
+ // Defense in depth (§5.1): the escalation target for reserved
469
+ // content must be STRONGER than the local model that refused
470
+ // it. An old/unpatched portal that ignores the reserved flag
471
+ // can answer from a small local tier or OpenRouter — never
472
+ // serve that; refuse instead.
473
+ const weakBackend = /^(ollama-|openrouter-)/.test(cloud.backend ?? "");
474
+ if (weakBackend) {
475
+ attempts.push({ tier: "synalux", reason: `reserved_weak_backend:${cloud.backend}` });
476
+ throw makeReservedRefusal(l1, attempts);
477
+ }
438
478
  return await applyVerification(cloud.output, gatedArgs, deps, {
439
479
  backend: cloud.backend ?? "synalux",
440
480
  model_picked: null,
@@ -448,7 +488,7 @@ export async function runInfer(args, deps) {
448
488
  }
449
489
  attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
450
490
  }
451
- throw new Error(`prism_infer: Layer 1 verdict=${l1}, reserved content refused. attempts=${JSON.stringify(attempts)}`);
491
+ throw makeReservedRefusal(l1, attempts);
452
492
  }
453
493
  if (l1 === "ERROR") {
454
494
  debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
@@ -708,7 +748,9 @@ export async function prismInferHandler(args) {
708
748
  // Local accumulator — sole source of the user-facing metrics block.
709
749
  // T4: pass prompt_text so recordInference computes submittedEst via
710
750
  // estimateTokens() — critical for cloud path where prompt_tokens is unset.
711
- recordInference({ ...result, prompt_text: args.prompt });
751
+ // mode lives on args, not the result — pass it explicitly or the
752
+ // ledger's mode column is silently NULL forever.
753
+ recordInference({ ...result, prompt_text: args.prompt, mode: args.mode ?? "route" });
712
754
  // Best-effort session telemetry — records that inference ran for this
713
755
  // conversation. Never affects routing or safety decisions.
714
756
  const _convId = args.conversation_id;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Prism Projects — MCP Tool Handlers (Phase 4)
3
+ * ===============================================
4
+ *
5
+ * CRUD operations for projects and team membership.
6
+ * Tier limits enforced via getCloudLimits().
7
+ *
8
+ * Tools:
9
+ * - project_create: Create a new project
10
+ * - project_list: List all projects for current user
11
+ * - project_update: Update project name/description/status
12
+ * - project_delete: Archive/delete a project
13
+ * - project_assign_member: Add a member to a project
14
+ * - project_remove_member: Remove a member from a project
15
+ */
16
+ import { randomUUID } from 'crypto';
17
+ import { getStorage } from '../storage/index.js';
18
+ import { getCloudLimits } from '../prism-cloud.js';
19
+ import { PRISM_USER_ID } from '../config.js';
20
+ const VALID_STATUSES = ['draft', 'active', 'on_hold', 'completed', 'archived'];
21
+ const VALID_ROLES = ['owner', 'editor', 'viewer'];
22
+ // Tier limits for projects
23
+ const PROJECT_LIMITS = {
24
+ free: { maxProjects: 1, maxMembers: 1 },
25
+ standard: { maxProjects: 9, maxMembers: 5 },
26
+ advanced: { maxProjects: 999, maxMembers: 25 },
27
+ enterprise: { maxProjects: 999999, maxMembers: 999999 },
28
+ };
29
+ function makeResult(text, isError = false) {
30
+ return { content: [{ type: 'text', text }], isError };
31
+ }
32
+ // ─── project_create ──────────────────────────────────────────
33
+ export async function projectCreateHandler(args) {
34
+ const { name, description, status, team_id } = args;
35
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
36
+ return makeResult('❌ Missing required field: name', true);
37
+ }
38
+ if (status && !VALID_STATUSES.includes(status)) {
39
+ return makeResult(`❌ Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`, true);
40
+ }
41
+ // Check tier limits
42
+ const limits = getCloudLimits();
43
+ const tierLimits = PROJECT_LIMITS[limits.tier] || PROJECT_LIMITS.free;
44
+ const storage = await getStorage();
45
+ const db = storage.db;
46
+ if (!db) {
47
+ return makeResult('❌ Project management requires SQLite storage.', true);
48
+ }
49
+ // Count existing projects
50
+ const countResult = await db.execute({
51
+ sql: 'SELECT COUNT(*) as cnt FROM prism_projects WHERE created_by = ?',
52
+ args: [PRISM_USER_ID],
53
+ });
54
+ const currentCount = Number(countResult.rows[0]?.cnt || 0);
55
+ if (currentCount >= tierLimits.maxProjects) {
56
+ return makeResult(`❌ Project limit reached (${currentCount}/${tierLimits.maxProjects}). ` +
57
+ `Upgrade your plan to create more projects.`, true);
58
+ }
59
+ const id = randomUUID();
60
+ await db.execute({
61
+ sql: `INSERT INTO prism_projects (id, name, description, status, created_by, team_id)
62
+ VALUES (?, ?, ?, ?, ?, ?)`,
63
+ args: [id, name.trim(), description || '', status || 'draft', PRISM_USER_ID, team_id || null],
64
+ });
65
+ // Auto-assign creator as owner
66
+ await db.execute({
67
+ sql: `INSERT INTO prism_project_members (project_id, user_id, role)
68
+ VALUES (?, ?, 'owner')`,
69
+ args: [id, PRISM_USER_ID],
70
+ });
71
+ return makeResult(`✅ Project created: "${name.trim()}" (${id})\n` +
72
+ ` Status: ${status || 'draft'}\n` +
73
+ ` Projects used: ${currentCount + 1}/${tierLimits.maxProjects}`);
74
+ }
75
+ // ─── project_list ────────────────────────────────────────────
76
+ export async function projectListHandler(args) {
77
+ const storage = await getStorage();
78
+ const db = storage.db;
79
+ if (!db) {
80
+ return makeResult('❌ Project management requires SQLite storage.', true);
81
+ }
82
+ let sql = `SELECT p.*, m.role as my_role
83
+ FROM prism_projects p
84
+ LEFT JOIN prism_project_members m ON p.id = m.project_id AND m.user_id = ?
85
+ WHERE (p.created_by = ? OR m.user_id = ?)`;
86
+ const sqlArgs = [PRISM_USER_ID, PRISM_USER_ID, PRISM_USER_ID];
87
+ if (args.status) {
88
+ sql += ` AND p.status = ?`;
89
+ sqlArgs.push(args.status);
90
+ }
91
+ if (args.team_id) {
92
+ sql += ` AND p.team_id = ?`;
93
+ sqlArgs.push(args.team_id);
94
+ }
95
+ sql += ` ORDER BY p.updated_at DESC`;
96
+ const result = await db.execute({ sql, args: sqlArgs });
97
+ const projects = result.rows;
98
+ if (projects.length === 0) {
99
+ return makeResult('📁 No projects found. Use `project_create` to create one.');
100
+ }
101
+ const lines = projects.map((p) => {
102
+ const statusIcon = p.status === 'active' ? '🟢' : p.status === 'draft' ? '📝' : p.status === 'completed' ? '✅' : p.status === 'archived' ? '📦' : '⏸️';
103
+ return `${statusIcon} **${p.name}** (${p.id.substring(0, 8)})\n Status: ${p.status} | Role: ${p.my_role || 'owner'} | Updated: ${p.updated_at}`;
104
+ });
105
+ const limits = getCloudLimits();
106
+ const tierLimits = PROJECT_LIMITS[limits.tier] || PROJECT_LIMITS.free;
107
+ return makeResult(`📁 **Projects** (${projects.length}/${tierLimits.maxProjects})\n\n` +
108
+ lines.join('\n\n'));
109
+ }
110
+ // ─── project_update ──────────────────────────────────────────
111
+ export async function projectUpdateHandler(args) {
112
+ const { project_id, name, description, status } = args;
113
+ if (!project_id) {
114
+ return makeResult('❌ Missing required field: project_id', true);
115
+ }
116
+ if (status && !VALID_STATUSES.includes(status)) {
117
+ return makeResult(`❌ Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`, true);
118
+ }
119
+ const storage = await getStorage();
120
+ const db = storage.db;
121
+ if (!db)
122
+ return makeResult('❌ Project management requires SQLite storage.', true);
123
+ const sets = ["updated_at = datetime('now')"];
124
+ const sqlArgs = [];
125
+ if (name) {
126
+ sets.push('name = ?');
127
+ sqlArgs.push(name.trim());
128
+ }
129
+ if (description !== undefined) {
130
+ sets.push('description = ?');
131
+ sqlArgs.push(description);
132
+ }
133
+ if (status) {
134
+ sets.push('status = ?');
135
+ sqlArgs.push(status);
136
+ }
137
+ sqlArgs.push(project_id);
138
+ await db.execute({
139
+ sql: `UPDATE prism_projects SET ${sets.join(', ')} WHERE id = ?`,
140
+ args: sqlArgs,
141
+ });
142
+ return makeResult(`✅ Project ${project_id.substring(0, 8)} updated.`);
143
+ }
144
+ // ─── project_delete ──────────────────────────────────────────
145
+ export async function projectDeleteHandler(args) {
146
+ const { project_id, hard } = args;
147
+ if (!project_id) {
148
+ return makeResult('❌ Missing required field: project_id', true);
149
+ }
150
+ const storage = await getStorage();
151
+ const db = storage.db;
152
+ if (!db)
153
+ return makeResult('❌ Project management requires SQLite storage.', true);
154
+ if (hard) {
155
+ await db.execute({ sql: 'DELETE FROM prism_projects WHERE id = ?', args: [project_id] });
156
+ return makeResult(`🗑️ Project ${project_id.substring(0, 8)} permanently deleted.`);
157
+ }
158
+ else {
159
+ await db.execute({
160
+ sql: `UPDATE prism_projects SET status = 'archived', updated_at = datetime('now') WHERE id = ?`,
161
+ args: [project_id],
162
+ });
163
+ return makeResult(`📦 Project ${project_id.substring(0, 8)} archived.`);
164
+ }
165
+ }
166
+ // ─── project_assign_member ───────────────────────────────────
167
+ export async function projectAssignMemberHandler(args) {
168
+ const { project_id, user_id, role } = args;
169
+ if (!project_id || !user_id) {
170
+ return makeResult('❌ Missing required fields: project_id, user_id', true);
171
+ }
172
+ const effectiveRole = role || 'viewer';
173
+ if (!VALID_ROLES.includes(effectiveRole)) {
174
+ return makeResult(`❌ Invalid role. Must be one of: ${VALID_ROLES.join(', ')}`, true);
175
+ }
176
+ // Check tier limits
177
+ const limits = getCloudLimits();
178
+ const tierLimits = PROJECT_LIMITS[limits.tier] || PROJECT_LIMITS.free;
179
+ const storage = await getStorage();
180
+ const db = storage.db;
181
+ if (!db)
182
+ return makeResult('❌ Project management requires SQLite storage.', true);
183
+ const countResult = await db.execute({
184
+ sql: 'SELECT COUNT(*) as cnt FROM prism_project_members WHERE project_id = ?',
185
+ args: [project_id],
186
+ });
187
+ const currentMembers = Number(countResult.rows[0]?.cnt || 0);
188
+ if (currentMembers >= tierLimits.maxMembers) {
189
+ return makeResult(`❌ Member limit reached (${currentMembers}/${tierLimits.maxMembers}). ` +
190
+ `Upgrade your plan to add more members.`, true);
191
+ }
192
+ await db.execute({
193
+ sql: `INSERT OR REPLACE INTO prism_project_members (project_id, user_id, role)
194
+ VALUES (?, ?, ?)`,
195
+ args: [project_id, user_id, effectiveRole],
196
+ });
197
+ return makeResult(`✅ ${user_id} assigned as ${effectiveRole} to project ${project_id.substring(0, 8)}.`);
198
+ }
199
+ // ─── project_remove_member ───────────────────────────────────
200
+ export async function projectRemoveMemberHandler(args) {
201
+ const { project_id, user_id } = args;
202
+ if (!project_id || !user_id) {
203
+ return makeResult('❌ Missing required fields: project_id, user_id', true);
204
+ }
205
+ const storage = await getStorage();
206
+ const db = storage.db;
207
+ if (!db)
208
+ return makeResult('❌ Project management requires SQLite storage.', true);
209
+ await db.execute({
210
+ sql: 'DELETE FROM prism_project_members WHERE project_id = ? AND user_id = ?',
211
+ args: [project_id, user_id],
212
+ });
213
+ return makeResult(`✅ ${user_id} removed from project ${project_id.substring(0, 8)}.`);
214
+ }
@@ -1818,12 +1818,19 @@ export function isVerifyBehaviorArgs(a) {
1818
1818
  // ─── v19.2: Inference Metrics Tool ──────────────────────────
1819
1819
  export const INFERENCE_METRICS_TOOL = {
1820
1820
  name: "inference_metrics",
1821
- description: "Returns the current session's local-model inference metrics — call count, " +
1822
- "local vs cloud split, token totals, per-model breakdown, and average latency. " +
1823
- "Read-only, no arguments. Reflects prism_infer delegation usage only, not the " +
1824
- "host model's (Claude's) own token spend (use /cost for that).",
1821
+ description: "Returns local-model inference metrics — call count, local vs cloud split, " +
1822
+ "token totals, per-model breakdown, and average latency. Reflects prism_infer " +
1823
+ "delegation usage only, not the host model's (Claude's) own token spend " +
1824
+ "(use /cost for that). period: 'session' (default, in-memory since server " +
1825
+ "start) or 'all' (persisted ledger across restarts).",
1825
1826
  inputSchema: {
1826
1827
  type: "object",
1827
- properties: {},
1828
+ properties: {
1829
+ period: {
1830
+ type: "string",
1831
+ enum: ["session", "all"],
1832
+ description: "Metrics window: 'session' (this server process) or 'all' (durable ledger).",
1833
+ },
1834
+ },
1828
1835
  },
1829
1836
  };
@@ -4,9 +4,9 @@
4
4
  * Cache: keyed on (project,prompt,role), 5-min live / 30s failure.
5
5
  * Offline: last-good from local DB, or empty with warning.
6
6
  */
7
+ import { getSynaluxJwt, invalidateSynaluxJwt } from '../utils/synaluxJwt.js';
7
8
  // -- Constants ----------------------------------------------------------------
8
9
  const SYNALUX_BASE = process.env.SYNALUX_BASE_URL || 'https://synalux.ai';
9
- const SKILLS_TOKEN = process.env.PRISM_SKILLS_TOKEN || '';
10
10
  const LIVE_TTL = 5 * 60 * 1000;
11
11
  const FAIL_TTL = 30_000;
12
12
  const DEFAULT_UL = { enabled: false, key_prefix: 'user_skill:' };
@@ -20,6 +20,24 @@ export const OFFLINE_FALLBACK = {
20
20
  projects: {},
21
21
  user_local: DEFAULT_UL,
22
22
  };
23
+ /**
24
+ * Map a portal response to ResolvedSkill[]. Uses the portal's per-skill
25
+ * metadata when present; for older portals that send names only, falls back
26
+ * to neutral defaults (protected:false) — the budgeting floor then relies on
27
+ * the caller's own knowledge (e.g. OFFLINE_FALLBACK). NEVER fabricate
28
+ * protected:true here: an over-broad floor would defeat budgeting entirely.
29
+ */
30
+ function toResolvedSkills(resp) {
31
+ if (resp.skills && resp.skills.length > 0) {
32
+ return resp.skills.map((s) => ({
33
+ name: s.name, priority: s.priority, protected: s.protected,
34
+ category: s.category ?? 'universal',
35
+ }));
36
+ }
37
+ return resp.loaded.map((name, i) => ({
38
+ name, priority: i, protected: false, category: 'universal',
39
+ }));
40
+ }
23
41
  const cache = new Map();
24
42
  const inflightMap = new Map();
25
43
  function cacheKey(project, prompt) {
@@ -43,12 +61,45 @@ async function callPortal(project, prompt, role) {
43
61
  'Content-Type': 'application/json',
44
62
  'Accept': 'application/json',
45
63
  };
46
- if (SKILLS_TOKEN)
47
- headers['Authorization'] = `Bearer ${SKILLS_TOKEN}`;
48
- const res = await fetch(`${SYNALUX_BASE}/api/v1/prism/resolve`, {
64
+ // Auth precedence: static PRISM_SKILLS_TOKEN (legacy/CI) → JWT exchanged
65
+ // from the synalux API key. The JWT path uses the same per-user identity
66
+ // as inference, so skills and inference resolve the SAME tier — without
67
+ // it, machines with only PRISM_SYNALUX_API_KEY silently resolve tier=free
68
+ // and never receive unprotected/prompt-routed skills.
69
+ const staticToken = process.env.PRISM_SKILLS_TOKEN || '';
70
+ let usedJwt = false;
71
+ if (staticToken) {
72
+ headers['Authorization'] = `Bearer ${staticToken}`;
73
+ }
74
+ else {
75
+ // Bound the exchange so a hanging JWT endpoint cannot stall
76
+ // session_load_context startup: after 4s proceed unauthenticated
77
+ // (free-tier resolve) — the exchange keeps running and its cached
78
+ // result authenticates the next call.
79
+ const jwt = await Promise.race([
80
+ getSynaluxJwt(),
81
+ new Promise((r) => setTimeout(r, 4_000, null)),
82
+ ]);
83
+ if (jwt) {
84
+ headers['Authorization'] = `Bearer ${jwt}`;
85
+ usedJwt = true;
86
+ }
87
+ }
88
+ const doFetch = () => fetch(`${SYNALUX_BASE}/api/v1/prism/resolve`, {
49
89
  method: 'POST', headers, body: JSON.stringify(body),
50
90
  signal: AbortSignal.timeout(5_000),
91
+ redirect: 'error', // never follow a redirect with a credential attached
51
92
  });
93
+ let res = await doFetch();
94
+ if (res.status === 401 && usedJwt) {
95
+ // Expired/rotated JWT — invalidate and retry once with a fresh one.
96
+ invalidateSynaluxJwt();
97
+ const fresh = await getSynaluxJwt();
98
+ if (fresh) {
99
+ headers['Authorization'] = `Bearer ${fresh}`;
100
+ res = await doFetch();
101
+ }
102
+ }
52
103
  if (!res.ok)
53
104
  throw new Error(`HTTP ${res.status}`);
54
105
  return (await res.json());
@@ -92,9 +143,7 @@ export async function resolveSkills(project, prompt, role) {
92
143
  if (cached) {
93
144
  return {
94
145
  names: cached.resp.loaded,
95
- skills: cached.resp.loaded.map((name, i) => ({
96
- name, priority: i, protected: false, category: 'universal',
97
- })),
146
+ skills: toResolvedSkills(cached.resp),
98
147
  user_local: DEFAULT_UL,
99
148
  isOffline: !cached.live,
100
149
  routing_version: cached.resp.routing_version,
@@ -107,7 +156,7 @@ export async function resolveSkills(project, prompt, role) {
107
156
  if (stored) {
108
157
  const resp = JSON.parse(stored);
109
158
  return {
110
- names: resp.loaded, skills: [], user_local: DEFAULT_UL,
159
+ names: resp.loaded, skills: toResolvedSkills(resp), user_local: DEFAULT_UL,
111
160
  isOffline: true,
112
161
  routing_version: resp.routing_version,
113
162
  };