prism-mcp-server 20.0.5 → 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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * v12.3: Role-Based Access Control (RBAC) Engine
3
+ *
4
+ * Enforces project-level and partition-level access control.
5
+ * Roles: admin, editor, viewer (extensible via custom roles).
6
+ * Permissions are checked per-project, per-memory-partition.
7
+ *
8
+ * Storage: Local SQLite table `rbac_roles` + `rbac_assignments` (auto-created).
9
+ */
10
+ import { debugLog } from "./logger.js";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ // ─── Built-in Role Definitions ───────────────────────────────
14
+ const BUILTIN_ROLES = {
15
+ admin: {
16
+ name: "admin",
17
+ displayName: "Administrator",
18
+ permissions: { read: true, write: true, delete: true, admin: true },
19
+ isBuiltin: true,
20
+ createdAt: "2026-01-01T00:00:00Z",
21
+ },
22
+ editor: {
23
+ name: "editor",
24
+ displayName: "Editor",
25
+ permissions: { read: true, write: true, delete: false, admin: false },
26
+ isBuiltin: true,
27
+ createdAt: "2026-01-01T00:00:00Z",
28
+ },
29
+ viewer: {
30
+ name: "viewer",
31
+ displayName: "Viewer",
32
+ permissions: { read: true, write: false, delete: false, admin: false },
33
+ isBuiltin: true,
34
+ createdAt: "2026-01-01T00:00:00Z",
35
+ },
36
+ };
37
+ // ─── In-Memory State ─────────────────────────────────────────
38
+ const customRoles = new Map();
39
+ const assignments = new Map(); // keyed by `userId:project`
40
+ // ─── Role Management ─────────────────────────────────────────
41
+ export function getRole(name) {
42
+ return BUILTIN_ROLES[name] || customRoles.get(name);
43
+ }
44
+ export function listRoles() {
45
+ return [
46
+ ...Object.values(BUILTIN_ROLES),
47
+ ...Array.from(customRoles.values()),
48
+ ];
49
+ }
50
+ export function createCustomRole(name, displayName, permissions) {
51
+ if (BUILTIN_ROLES[name]) {
52
+ throw new Error(`Cannot override built-in role: ${name}`);
53
+ }
54
+ const role = {
55
+ name,
56
+ displayName,
57
+ permissions,
58
+ isBuiltin: false,
59
+ createdAt: new Date().toISOString(),
60
+ };
61
+ customRoles.set(name, role);
62
+ debugLog(`RBAC: Created custom role '${name}' with permissions: ${JSON.stringify(permissions)}`);
63
+ return role;
64
+ }
65
+ export function deleteCustomRole(name) {
66
+ if (BUILTIN_ROLES[name]) {
67
+ throw new Error(`Cannot delete built-in role: ${name}`);
68
+ }
69
+ return customRoles.delete(name);
70
+ }
71
+ // ─── Assignment Management ───────────────────────────────────
72
+ function assignmentKey(userId, project) {
73
+ return `${userId}:${project}`;
74
+ }
75
+ export function assignRole(userId, role, project, assignedBy, partition, expiresAt) {
76
+ const roleDef = getRole(role);
77
+ if (!roleDef) {
78
+ throw new Error(`Unknown role: ${role}. Available: ${listRoles().map(r => r.name).join(", ")}`);
79
+ }
80
+ const assignment = {
81
+ id: `ra_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
82
+ userId,
83
+ role,
84
+ project,
85
+ partition,
86
+ assignedBy,
87
+ assignedAt: new Date().toISOString(),
88
+ expiresAt,
89
+ };
90
+ const key = assignmentKey(userId, project);
91
+ const existing = assignments.get(key) || [];
92
+ // Remove existing assignment for same partition (upsert)
93
+ const filtered = existing.filter(a => a.partition !== partition || a.role !== role);
94
+ filtered.push(assignment);
95
+ assignments.set(key, filtered);
96
+ debugLog(`RBAC: Assigned role '${role}' to user '${userId}' on project '${project}'${partition ? ` partition '${partition}'` : ""}`);
97
+ return assignment;
98
+ }
99
+ export function revokeRole(userId, role, project, partition) {
100
+ const key = assignmentKey(userId, project);
101
+ const existing = assignments.get(key) || [];
102
+ const filtered = existing.filter(a => !(a.role === role && a.partition === partition));
103
+ if (filtered.length === existing.length)
104
+ return false;
105
+ assignments.set(key, filtered);
106
+ debugLog(`RBAC: Revoked role '${role}' from user '${userId}' on project '${project}'`);
107
+ return true;
108
+ }
109
+ export function getUserAssignments(userId, project) {
110
+ if (project) {
111
+ return assignments.get(assignmentKey(userId, project)) || [];
112
+ }
113
+ // All assignments for this user across projects
114
+ const all = [];
115
+ for (const [key, assigns] of assignments) {
116
+ if (key.startsWith(`${userId}:`)) {
117
+ all.push(...assigns);
118
+ }
119
+ }
120
+ return all;
121
+ }
122
+ export function getProjectMembers(project) {
123
+ const members = [];
124
+ for (const [key, assigns] of assignments) {
125
+ if (key.endsWith(`:${project}`)) {
126
+ members.push(...assigns);
127
+ }
128
+ }
129
+ return members;
130
+ }
131
+ // ─── Access Control Checks ───────────────────────────────────
132
+ export function checkAccess(userId, project, permission, partition) {
133
+ const userAssignments = getUserAssignments(userId, project);
134
+ if (userAssignments.length === 0) {
135
+ return {
136
+ allowed: false,
137
+ role: "none",
138
+ permission,
139
+ project,
140
+ reason: `User '${userId}' has no role on project '${project}'`,
141
+ };
142
+ }
143
+ // Check for expired assignments
144
+ const now = new Date();
145
+ const validAssignments = userAssignments.filter(a => {
146
+ if (!a.expiresAt)
147
+ return true;
148
+ return new Date(a.expiresAt) > now;
149
+ });
150
+ if (validAssignments.length === 0) {
151
+ return {
152
+ allowed: false,
153
+ role: "expired",
154
+ permission,
155
+ project,
156
+ reason: `All role assignments for '${userId}' on '${project}' have expired`,
157
+ };
158
+ }
159
+ // If partition is specified, check partition-specific assignments first
160
+ if (partition) {
161
+ const partitionAssigns = validAssignments.filter(a => a.partition === partition);
162
+ if (partitionAssigns.length > 0) {
163
+ // Use highest-privilege partition assignment
164
+ for (const assign of partitionAssigns) {
165
+ const role = getRole(assign.role);
166
+ if (role && role.permissions[permission]) {
167
+ return {
168
+ allowed: true,
169
+ role: assign.role,
170
+ permission,
171
+ project,
172
+ reason: `Granted via partition-level role '${assign.role}'`,
173
+ };
174
+ }
175
+ }
176
+ }
177
+ }
178
+ // Check project-level assignments (no partition filter)
179
+ const projectAssigns = validAssignments.filter(a => !a.partition);
180
+ for (const assign of projectAssigns) {
181
+ const role = getRole(assign.role);
182
+ if (role && role.permissions[permission]) {
183
+ return {
184
+ allowed: true,
185
+ role: assign.role,
186
+ permission,
187
+ project,
188
+ reason: `Granted via project-level role '${assign.role}'`,
189
+ };
190
+ }
191
+ }
192
+ // No matching permission found
193
+ const highestRole = validAssignments[0]?.role || "none";
194
+ return {
195
+ allowed: false,
196
+ role: highestRole,
197
+ permission,
198
+ project,
199
+ reason: `Role '${highestRole}' does not have '${permission}' permission on '${project}'`,
200
+ };
201
+ }
202
+ /**
203
+ * Middleware-style check: throws if access denied.
204
+ */
205
+ export function requireAccess(userId, project, permission, partition) {
206
+ const result = checkAccess(userId, project, permission, partition);
207
+ if (!result.allowed) {
208
+ throw new Error(`Access denied: ${result.reason}`);
209
+ }
210
+ }
211
+ // ─── SQLite Persistence (lazy-init) ──────────────────────────
212
+ let dbInitialized = false;
213
+ function getRbacDbPath() {
214
+ return join(process.env.PRISM_DATA_DIR || join(homedir(), ".prism"), "rbac.db");
215
+ }
216
+ export async function persistToDb() {
217
+ try {
218
+ // @ts-ignore — dynamic import of optional dependency
219
+ const Database = (await import("better-sqlite3")).default;
220
+ const db = new Database(getRbacDbPath());
221
+ db.exec(`
222
+ CREATE TABLE IF NOT EXISTS rbac_custom_roles (
223
+ name TEXT PRIMARY KEY,
224
+ display_name TEXT NOT NULL,
225
+ perm_read INTEGER DEFAULT 0,
226
+ perm_write INTEGER DEFAULT 0,
227
+ perm_delete INTEGER DEFAULT 0,
228
+ perm_admin INTEGER DEFAULT 0,
229
+ created_at TEXT NOT NULL
230
+ );
231
+ CREATE TABLE IF NOT EXISTS rbac_assignments (
232
+ id TEXT PRIMARY KEY,
233
+ user_id TEXT NOT NULL,
234
+ role TEXT NOT NULL,
235
+ project TEXT NOT NULL,
236
+ partition TEXT,
237
+ assigned_by TEXT NOT NULL,
238
+ assigned_at TEXT NOT NULL,
239
+ expires_at TEXT,
240
+ UNIQUE(user_id, role, project, partition)
241
+ );
242
+ `);
243
+ // Persist custom roles
244
+ const upsertRole = db.prepare(`
245
+ INSERT OR REPLACE INTO rbac_custom_roles
246
+ (name, display_name, perm_read, perm_write, perm_delete, perm_admin, created_at)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?)
248
+ `);
249
+ for (const role of customRoles.values()) {
250
+ upsertRole.run(role.name, role.displayName, role.permissions.read ? 1 : 0, role.permissions.write ? 1 : 0, role.permissions.delete ? 1 : 0, role.permissions.admin ? 1 : 0, role.createdAt);
251
+ }
252
+ // Persist assignments
253
+ const upsertAssign = db.prepare(`
254
+ INSERT OR REPLACE INTO rbac_assignments
255
+ (id, user_id, role, project, partition, assigned_by, assigned_at, expires_at)
256
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
257
+ `);
258
+ for (const assigns of assignments.values()) {
259
+ for (const a of assigns) {
260
+ upsertAssign.run(a.id, a.userId, a.role, a.project, a.partition || null, a.assignedBy, a.assignedAt, a.expiresAt || null);
261
+ }
262
+ }
263
+ db.close();
264
+ dbInitialized = true;
265
+ debugLog("RBAC: Persisted state to SQLite");
266
+ }
267
+ catch (err) {
268
+ debugLog(`RBAC: Persistence failed (non-fatal): ${err}`);
269
+ }
270
+ }
271
+ export async function loadFromDb() {
272
+ try {
273
+ // @ts-ignore
274
+ const Database = (await import("better-sqlite3")).default;
275
+ const dbPath = getRbacDbPath();
276
+ // Check if DB exists before trying to open
277
+ const { existsSync } = await import("node:fs");
278
+ if (!existsSync(dbPath))
279
+ return;
280
+ const db = new Database(dbPath);
281
+ // Load custom roles
282
+ const roles = db.prepare("SELECT * FROM rbac_custom_roles").all();
283
+ for (const r of roles) {
284
+ customRoles.set(r.name, {
285
+ name: r.name,
286
+ displayName: r.display_name,
287
+ permissions: {
288
+ read: !!r.perm_read,
289
+ write: !!r.perm_write,
290
+ delete: !!r.perm_delete,
291
+ admin: !!r.perm_admin,
292
+ },
293
+ isBuiltin: false,
294
+ createdAt: r.created_at,
295
+ });
296
+ }
297
+ // Load assignments
298
+ const assigns = db.prepare("SELECT * FROM rbac_assignments").all();
299
+ for (const a of assigns) {
300
+ const key = assignmentKey(a.user_id, a.project);
301
+ const existing = assignments.get(key) || [];
302
+ existing.push({
303
+ id: a.id,
304
+ userId: a.user_id,
305
+ role: a.role,
306
+ project: a.project,
307
+ partition: a.partition || undefined,
308
+ assignedBy: a.assigned_by,
309
+ assignedAt: a.assigned_at,
310
+ expiresAt: a.expires_at || undefined,
311
+ });
312
+ assignments.set(key, existing);
313
+ }
314
+ db.close();
315
+ debugLog(`RBAC: Loaded ${roles.length} custom roles, ${assigns.length} assignments from DB`);
316
+ }
317
+ catch (err) {
318
+ debugLog(`RBAC: Load failed (non-fatal, using defaults): ${err}`);
319
+ }
320
+ }
321
+ debugLog("v12.3: RBAC engine loaded");
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Skill-delivery budgeting — makes the skill block honor the caller's
3
+ * max_tokens budget instead of inlining every resolved skill.
4
+ *
5
+ * Why: paid-tier resolution returns 30+ skills (~114KB measured). Unbudgeted
6
+ * inlining exceeds host tool-result caps, and hosts divert the WHOLE response
7
+ * to a file — the agent receives none of it. A budgeted block that fits is
8
+ * strictly more context than an unbudgeted one that gets diverted.
9
+ *
10
+ * Policy (in fill order):
11
+ * 1. protected skills — ALWAYS inlined in full, even over budget. This is
12
+ * the documented floor (2026-06-13 incident: silent truncation stripped
13
+ * the agent's core behavioral rules; "protected" exists to prevent that).
14
+ * 2. prompt-category skills — they matched THIS prompt's keywords; they are
15
+ * usually the reason the caller passed `prompt` at all.
16
+ * 3. everything else (unprotected universal, project, role) by ascending
17
+ * priority — while budget remains.
18
+ * Skills that do not fit are NEVER silently dropped: they are listed in an
19
+ * overflow manifest so the agent can read them on demand or re-load with a
20
+ * higher max_tokens.
21
+ */
22
+ function fillOrder(a, b) {
23
+ const rank = (e) => e.protected ? 0 : e.category === "prompt" ? 1 : e.category === "role" ? 2 : 3;
24
+ return rank(a) - rank(b) || a.priority - b.priority;
25
+ }
26
+ function render(e) {
27
+ const label = e.category === "role" ? "ROLE SKILL" : "SKILL";
28
+ return `\n\n[📜 ${label}: ${e.name}]\n${e.content.trim()}`;
29
+ }
30
+ /**
31
+ * Assemble the skill block within `budgetChars`. `budgetChars` ≤ 0 or
32
+ * non-finite means unbudgeted (legacy behavior: inline everything).
33
+ */
34
+ export function assembleSkillBlock(entries, budgetChars) {
35
+ const ordered = [...entries].sort(fillOrder);
36
+ const unbudgeted = !Number.isFinite(budgetChars) || budgetChars <= 0;
37
+ let block = "";
38
+ const inlined = [];
39
+ const overflow = [];
40
+ for (const e of ordered) {
41
+ const piece = render(e);
42
+ // Protected always inline; others only while they fit.
43
+ if (unbudgeted || e.protected || block.length + piece.length <= budgetChars) {
44
+ block += piece;
45
+ inlined.push(e.name);
46
+ }
47
+ else {
48
+ overflow.push(e.name);
49
+ }
50
+ }
51
+ if (overflow.length > 0) {
52
+ block +=
53
+ `\n\n[📦 SKILLS NOT INLINED — max_tokens budget reached]\n` +
54
+ `${overflow.join(", ")}\n` +
55
+ `To inline them, re-call session_load_context with a higher max_tokens.`;
56
+ }
57
+ return { block, inlined, overflow };
58
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tavily API Client
3
+ *
4
+ * This module provides Tavily Search and Extract helpers for the Web Scholar
5
+ * pipeline. It serves as an additive alternative to Brave Search + Firecrawl
6
+ * when TAVILY_API_KEY is set.
7
+ *
8
+ * 1. performTavilySearch — Web search returning URLs (mirrors Brave web search)
9
+ * 2. performTavilyExtract — URL content extraction returning markdown (mirrors Firecrawl scrape)
10
+ */
11
+ import { tavily } from "@tavily/core";
12
+ function getClient(apiKey) {
13
+ return tavily({ apiKey });
14
+ }
15
+ /**
16
+ * Searches the web via Tavily and returns an array of result objects.
17
+ */
18
+ export async function performTavilySearch(apiKey, query, maxResults = 10) {
19
+ try {
20
+ const client = getClient(apiKey);
21
+ const response = await client.search(query, {
22
+ maxResults,
23
+ searchDepth: "advanced",
24
+ topic: "general",
25
+ });
26
+ return (response.results || []).map((r) => ({
27
+ title: r.title || "",
28
+ url: r.url || "",
29
+ content: r.content || "",
30
+ score: r.score ?? 0,
31
+ }));
32
+ }
33
+ catch (error) {
34
+ console.error(`[Tavily Search] Error performing search for query "${query}":`, error);
35
+ return [];
36
+ }
37
+ }
38
+ /**
39
+ * Extracts article content from URLs via Tavily Extract.
40
+ * Returns markdown content for each successfully extracted URL.
41
+ */
42
+ export async function performTavilyExtract(apiKey, urls) {
43
+ if (urls.length === 0)
44
+ return [];
45
+ const client = getClient(apiKey);
46
+ const allResults = [];
47
+ // Tavily extract accepts up to 20 URLs at once
48
+ for (let i = 0; i < urls.length; i += 20) {
49
+ const batch = urls.slice(i, i + 20);
50
+ try {
51
+ const response = await client.extract(batch, {
52
+ extractDepth: "basic",
53
+ });
54
+ // Optionally log failed URLs from this batch
55
+ if (response.failedResults && response.failedResults.length > 0) {
56
+ console.warn(`[Tavily Extract] Failed to extract ${response.failedResults.length} URLs in this batch.`, response.failedResults);
57
+ }
58
+ const mapped = (response.results || []).map((r) => ({
59
+ url: r.url || "",
60
+ rawContent: r.rawContent || "",
61
+ }));
62
+ allResults.push(...mapped);
63
+ }
64
+ catch (error) {
65
+ // Log the error but continue to the next batch to prevent total data loss
66
+ console.error(`[Tavily Extract] Error extracting batch ${i} to ${Math.min(i + 20, urls.length)}:`, error);
67
+ }
68
+ }
69
+ return allResults;
70
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * v12.5: Tier-Gated VM Quota Enforcer
3
+ *
4
+ * Enforces subscription-based VM limits.
5
+ * Each Synalux tier has maximum VM counts, CPU, RAM, and storage quotas.
6
+ */
7
+ import { debugLog } from "../utils/logger.js";
8
+ // ─── Tier Quotas ─────────────────────────────────────────────
9
+ const TIER_QUOTAS = {
10
+ free: {
11
+ maxVMs: 1,
12
+ maxCpuCores: 2,
13
+ maxRamGb: 4,
14
+ maxStorageGb: 20,
15
+ maxConcurrentRuns: 1,
16
+ allowedPlatforms: ["linux"],
17
+ },
18
+ standard: {
19
+ maxVMs: 5,
20
+ maxCpuCores: 8,
21
+ maxRamGb: 16,
22
+ maxStorageGb: 100,
23
+ maxConcurrentRuns: 2,
24
+ allowedPlatforms: ["linux", "macos", "windows"],
25
+ },
26
+ advanced: {
27
+ maxVMs: 20,
28
+ maxCpuCores: 32,
29
+ maxRamGb: 64,
30
+ maxStorageGb: 500,
31
+ maxConcurrentRuns: 5,
32
+ allowedPlatforms: ["linux", "macos", "windows", "ios", "android", "visionos"],
33
+ },
34
+ enterprise: {
35
+ maxVMs: -1, // unlimited
36
+ maxCpuCores: -1,
37
+ maxRamGb: -1,
38
+ maxStorageGb: -1,
39
+ maxConcurrentRuns: -1,
40
+ allowedPlatforms: ["*"],
41
+ },
42
+ };
43
+ // ─── State ───────────────────────────────────────────────────
44
+ let currentTier = "free";
45
+ let currentUsage = {
46
+ activeVMs: 0,
47
+ totalCpuCores: 0,
48
+ totalRamGb: 0,
49
+ totalStorageGb: 0,
50
+ concurrentRuns: 0,
51
+ };
52
+ export function setTier(tier) {
53
+ currentTier = tier;
54
+ debugLog(`VM Quota: Tier set to '${tier}'`);
55
+ }
56
+ export function getTier() {
57
+ return currentTier;
58
+ }
59
+ export function getQuota(tier) {
60
+ return { ...TIER_QUOTAS[tier || currentTier] };
61
+ }
62
+ export function getUsage() {
63
+ return { ...currentUsage };
64
+ }
65
+ export function updateUsage(usage) {
66
+ currentUsage = { ...currentUsage, ...usage };
67
+ }
68
+ // ─── Quota Checks ────────────────────────────────────────────
69
+ function isUnlimited(value) {
70
+ return value === -1;
71
+ }
72
+ /**
73
+ * Check if creating a new VM is allowed.
74
+ */
75
+ export function checkVMCreation(cpuCores = 1, ramGb = 2, storageGb = 10, platform = "linux") {
76
+ const quota = TIER_QUOTAS[currentTier];
77
+ // Check platform
78
+ if (!quota.allowedPlatforms.includes("*") && !quota.allowedPlatforms.includes(platform)) {
79
+ return {
80
+ allowed: false,
81
+ resource: "platform",
82
+ requested: 0,
83
+ available: 0,
84
+ limit: 0,
85
+ tier: currentTier,
86
+ reason: `Platform '${platform}' not available on ${currentTier} tier. Available: ${quota.allowedPlatforms.join(", ")}`,
87
+ };
88
+ }
89
+ // Check VM count
90
+ if (!isUnlimited(quota.maxVMs) && currentUsage.activeVMs >= quota.maxVMs) {
91
+ return {
92
+ allowed: false,
93
+ resource: "vms",
94
+ requested: 1,
95
+ available: quota.maxVMs - currentUsage.activeVMs,
96
+ limit: quota.maxVMs,
97
+ tier: currentTier,
98
+ reason: `VM limit reached (${currentUsage.activeVMs}/${quota.maxVMs})`,
99
+ };
100
+ }
101
+ // Check CPU
102
+ if (!isUnlimited(quota.maxCpuCores) && currentUsage.totalCpuCores + cpuCores > quota.maxCpuCores) {
103
+ return {
104
+ allowed: false,
105
+ resource: "cpu",
106
+ requested: cpuCores,
107
+ available: quota.maxCpuCores - currentUsage.totalCpuCores,
108
+ limit: quota.maxCpuCores,
109
+ tier: currentTier,
110
+ reason: `CPU quota exceeded (${currentUsage.totalCpuCores + cpuCores}/${quota.maxCpuCores} cores)`,
111
+ };
112
+ }
113
+ // Check RAM
114
+ if (!isUnlimited(quota.maxRamGb) && currentUsage.totalRamGb + ramGb > quota.maxRamGb) {
115
+ return {
116
+ allowed: false,
117
+ resource: "ram",
118
+ requested: ramGb,
119
+ available: quota.maxRamGb - currentUsage.totalRamGb,
120
+ limit: quota.maxRamGb,
121
+ tier: currentTier,
122
+ reason: `RAM quota exceeded (${currentUsage.totalRamGb + ramGb}/${quota.maxRamGb} GB)`,
123
+ };
124
+ }
125
+ // Check storage
126
+ if (!isUnlimited(quota.maxStorageGb) && currentUsage.totalStorageGb + storageGb > quota.maxStorageGb) {
127
+ return {
128
+ allowed: false,
129
+ resource: "storage",
130
+ requested: storageGb,
131
+ available: quota.maxStorageGb - currentUsage.totalStorageGb,
132
+ limit: quota.maxStorageGb,
133
+ tier: currentTier,
134
+ reason: `Storage quota exceeded (${currentUsage.totalStorageGb + storageGb}/${quota.maxStorageGb} GB)`,
135
+ };
136
+ }
137
+ return {
138
+ allowed: true,
139
+ resource: "all",
140
+ requested: 0,
141
+ available: 0,
142
+ limit: 0,
143
+ tier: currentTier,
144
+ reason: "All quota checks passed",
145
+ };
146
+ }
147
+ /**
148
+ * Check if a concurrent run is allowed.
149
+ */
150
+ export function checkConcurrentRun() {
151
+ const quota = TIER_QUOTAS[currentTier];
152
+ if (!isUnlimited(quota.maxConcurrentRuns) && currentUsage.concurrentRuns >= quota.maxConcurrentRuns) {
153
+ return {
154
+ allowed: false,
155
+ resource: "concurrent_runs",
156
+ requested: 1,
157
+ available: quota.maxConcurrentRuns - currentUsage.concurrentRuns,
158
+ limit: quota.maxConcurrentRuns,
159
+ tier: currentTier,
160
+ reason: `Concurrent run limit reached (${currentUsage.concurrentRuns}/${quota.maxConcurrentRuns})`,
161
+ };
162
+ }
163
+ return {
164
+ allowed: true,
165
+ resource: "concurrent_runs",
166
+ requested: 1,
167
+ available: isUnlimited(quota.maxConcurrentRuns) ? -1 : quota.maxConcurrentRuns - currentUsage.concurrentRuns,
168
+ limit: quota.maxConcurrentRuns,
169
+ tier: currentTier,
170
+ reason: "Concurrent run allowed",
171
+ };
172
+ }
173
+ /**
174
+ * Get a summary of quota usage vs limits.
175
+ */
176
+ export function getQuotaSummary() {
177
+ const limits = TIER_QUOTAS[currentTier];
178
+ const pct = (used, max) => max === -1 ? 0 : Math.round((used / max) * 100);
179
+ return {
180
+ tier: currentTier,
181
+ usage: { ...currentUsage },
182
+ limits: { ...limits },
183
+ percentUsed: {
184
+ vms: pct(currentUsage.activeVMs, limits.maxVMs),
185
+ cpu: pct(currentUsage.totalCpuCores, limits.maxCpuCores),
186
+ ram: pct(currentUsage.totalRamGb, limits.maxRamGb),
187
+ storage: pct(currentUsage.totalStorageGb, limits.maxStorageGb),
188
+ runs: pct(currentUsage.concurrentRuns, limits.maxConcurrentRuns),
189
+ },
190
+ };
191
+ }
192
+ debugLog("v12.5: VM quota enforcer loaded");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.0.5",
3
+ "version": "20.0.7",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",