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.
- package/dist/agent/agentTools.js +453 -0
- package/dist/agent/mcpBridge.js +234 -0
- package/dist/agent/platformUtils.js +470 -0
- package/dist/agent/terminalUI.js +198 -0
- package/dist/auth.js +218 -0
- package/dist/darkfactory/cloudDelegate.js +173 -0
- package/dist/plugins/pluginManager.js +199 -0
- package/dist/prism-cloud.js +110 -0
- package/dist/scm/ciPipeline.js +220 -0
- package/dist/server.js +1 -1
- package/dist/storage/inferMetricsLedger.js +159 -0
- package/dist/sync/encryptedSync.js +172 -0
- package/dist/sync/synaluxProxy.js +177 -0
- package/dist/tools/__tests__/layer1Integration.test.js +34 -0
- package/dist/tools/adaptiveDefinitions.js +148 -0
- package/dist/tools/behavioralVerifierHandler.js +0 -9
- package/dist/tools/interactiveDefinitions.js +87 -0
- package/dist/tools/interactiveHandlers.js +164 -0
- package/dist/tools/ledgerHandlers.js +40 -13
- package/dist/tools/prismInferHandler.js +48 -6
- package/dist/tools/projects.js +214 -0
- package/dist/tools/sessionMemoryDefinitions.js +12 -5
- package/dist/tools/skillRouting.js +57 -8
- package/dist/utils/changelogGenerator.js +158 -0
- package/dist/utils/fallbackClient.js +52 -0
- package/dist/utils/groundingVerifier.js +3 -3
- package/dist/utils/inferenceMetrics.js +38 -1
- package/dist/utils/memoryAttestation.js +163 -0
- package/dist/utils/rbac.js +321 -0
- package/dist/utils/skillBudget.js +58 -0
- package/dist/utils/tavilyApi.js +70 -0
- package/dist/vm/quotaEnforcer.js +192 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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 (
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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 (
|
|
960
|
+
if (skillEntries.some((e) => e.name === name))
|
|
952
961
|
continue;
|
|
953
962
|
const content = v.trim();
|
|
954
|
-
if (content
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
1822
|
-
"
|
|
1823
|
-
"
|
|
1824
|
-
"
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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:
|
|
159
|
+
names: resp.loaded, skills: toResolvedSkills(resp), user_local: DEFAULT_UL,
|
|
111
160
|
isOffline: true,
|
|
112
161
|
routing_version: resp.routing_version,
|
|
113
162
|
};
|