prism-mcp-server 15.5.2 → 15.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Persistent memory + tool-calling intelligence for AI agents.** *(formerly Prism MCP)*
6
6
 
7
- A Model Context Protocol server that gives Claude, Cursor, and other AI tools a Mind Palace — long-term memory that survives across sessions, with semantic search, cognitive routing, a visual dashboard, and the `prism-coder:1b7` / `prism-coder:8b` / `prism-coder:14b` / `prism-coder:32b` LLM fleet for offline tool-calling. **[→ prism-mcp.com](https://prism-mcp.com)**
7
+ A Model Context Protocol server that gives Claude, Cursor, and other AI tools a Mind Palace — long-term memory that survives across sessions, with semantic search, cognitive routing, a visual dashboard, and the `prism-coder:1b7` / `prism-coder:8b` / `prism-coder:14b` / `prism-coder:32b` LLM fleet for offline tool-calling.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/prism-mcp-server?color=cb0000&label=npm%20%E2%80%94%20prism-mcp-server)](https://www.npmjs.com/package/prism-mcp-server)
10
10
  [![VS Marketplace](https://img.shields.io/visual-studio-marketplace/v/synalux-ai.synalux?label=VS%20Code&color=007ACC)](https://marketplace.visualstudio.com/items?itemName=synalux-ai.synalux)
11
- [![Website](https://img.shields.io/badge/website-prism--mcp.com-6B4FBB)](https://prism-mcp.com)
11
+ [![Website](https://img.shields.io/badge/website-synalux.ai%2Fprism--mcp-6B4FBB)](https://synalux.ai/prism-mcp)
12
12
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-00ADD8)](https://github.com/modelcontextprotocol/servers)
13
13
  [![Smithery](https://img.shields.io/badge/Smithery-listed-6B4FBB)](https://smithery.ai/server/@dcostenco/prism-mcp)
14
14
  [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
@@ -315,9 +315,21 @@ All on-device models are **free for every tier** — no subscription needed for
315
315
 
316
316
  ## Companions
317
317
 
318
- ### 🌐 Website
318
+ ### 🌐 Website & Docs
319
319
 
320
- **[prism-mcp.com](https://prism-mcp.com)** — full documentation, dashboard, subscription plans, and model downloads.
320
+ **[synalux.ai/prism-mcp](https://synalux.ai/prism-mcp)** — full documentation, dashboard, subscription plans, and model downloads.
321
+
322
+ ### 💻 Web IDE — Synalux Coder
323
+
324
+ Use Prism Coder directly in your browser — no install required. Local-first IDE with the prism-coder agent built in. Connects to GitHub repos, Synalux Mail, Drive, and Source for cross-product workflows.
325
+
326
+ **[synalux.ai/coder](https://synalux.ai/coder)** · also reachable at **[synalux.ai/prism-ide](https://synalux.ai/prism-ide)**
327
+
328
+ | Feature | Detail |
329
+ |---|---|
330
+ | Agent | prism-coder:7b offline · Claude Sonnet 4 (Standard+) · Claude Opus 4 (Enterprise) |
331
+ | Integrations | GitHub repos, Synalux Mail, Drive, Source — same OAuth, no separate accounts |
332
+ | Compliance | Audit log on every turn · PHI redaction · air-gapped offline mode (HIPAA) |
321
333
 
322
334
  ### 🧩 VS Code Extension — Synalux
323
335
 
package/dist/server.js CHANGED
@@ -89,6 +89,8 @@ MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL,
89
89
  SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL,
90
90
  // ─── v2.2.0: Health Check tool definition ───
91
91
  SESSION_HEALTH_CHECK_TOOL,
92
+ // ─── Hygiene: embedding backfill (orphaned but handler-wired) ───
93
+ SESSION_BACKFILL_EMBEDDINGS_TOOL,
92
94
  // ─── Phase 2: GDPR Memory Deletion tool definition ───
93
95
  SESSION_FORGET_MEMORY_TOOL,
94
96
  // ─── Phase 2: GDPR Export tool definition ───
@@ -199,6 +201,7 @@ function buildSessionMemoryTools(autoloadList) {
199
201
  SESSION_VIEW_IMAGE_TOOL, // session_view_image — retrieve image from vault (v2.0)
200
202
  // ─── v2.2.0: Health Check tool ───
201
203
  SESSION_HEALTH_CHECK_TOOL, // session_health_check — brain integrity checker (v2.2.0)
204
+ SESSION_BACKFILL_EMBEDDINGS_TOOL, // session_backfill_embeddings — repair NULL embeddings (handler+route already wired)
202
205
  // ─── Phase 2: GDPR Memory Deletion tool ───
203
206
  SESSION_FORGET_MEMORY_TOOL, // session_forget_memory — GDPR-compliant memory deletion (Phase 2)
204
207
  // ─── v3.1: TTL Retention tool ───
@@ -1062,6 +1065,73 @@ export function createSandboxServer() {
1062
1065
  * responses to stdout. Log messages go to stderr.
1063
1066
  */
1064
1067
  export async function startServer() {
1068
+ // Stale-dist guard. Catches the failure mode where src/ commits land but
1069
+ // `npm run build` is skipped, so Claude Desktop runs an outdated
1070
+ // dist/server.js (silent — tool fixes invisible in the running binary).
1071
+ // Read-only probe; safe to run before acquireLock(). No-ops in npm
1072
+ // installs where src/ isn't shipped alongside dist/.
1073
+ try {
1074
+ const { statSync, readdirSync, existsSync, readFileSync } = await import("fs");
1075
+ const { dirname, join, basename } = await import("path");
1076
+ const { fileURLToPath } = await import("url");
1077
+ // Derive layout from package.json so we don't hardcode "src" / "server.js"
1078
+ // / "node_modules". Falls back to sane defaults only if package.json is
1079
+ // missing (e.g. in unusual install layouts).
1080
+ const here = dirname(fileURLToPath(import.meta.url));
1081
+ const repoRoot = join(here, "..");
1082
+ let distEntry = "server.js";
1083
+ let srcSubdir = "src";
1084
+ const skipDirPrefixes = ["."]; // dotfile dirs (.git, .cache, …)
1085
+ const skipDirNames = new Set(["node_modules"]); // npm convention
1086
+ try {
1087
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
1088
+ if (typeof pkg.main === "string" && pkg.main.length > 0) {
1089
+ distEntry = basename(pkg.main);
1090
+ }
1091
+ }
1092
+ catch { /* keep fallback */ }
1093
+ try {
1094
+ const tsconfig = JSON.parse(readFileSync(join(repoRoot, "tsconfig.json"), "utf8"));
1095
+ const rootDir = tsconfig?.compilerOptions?.rootDir;
1096
+ if (typeof rootDir === "string" && rootDir.length > 0) {
1097
+ srcSubdir = rootDir.replace(/^\.\//, "");
1098
+ }
1099
+ }
1100
+ catch { /* keep fallback */ }
1101
+ const distPath = join(here, distEntry);
1102
+ const srcDir = join(repoRoot, srcSubdir);
1103
+ if (existsSync(distPath) && existsSync(srcDir)) {
1104
+ const distMtime = statSync(distPath).mtimeMs;
1105
+ const walk = (d) => {
1106
+ let newest = 0;
1107
+ for (const e of readdirSync(d, { withFileTypes: true })) {
1108
+ if (e.isDirectory()) {
1109
+ if (skipDirNames.has(e.name))
1110
+ continue;
1111
+ if (skipDirPrefixes.some(p => e.name.startsWith(p)))
1112
+ continue;
1113
+ newest = Math.max(newest, walk(join(d, e.name)));
1114
+ }
1115
+ else if (e.isFile()) {
1116
+ newest = Math.max(newest, statSync(join(d, e.name)).mtimeMs);
1117
+ }
1118
+ }
1119
+ return newest;
1120
+ };
1121
+ const srcMtime = walk(srcDir);
1122
+ if (srcMtime > distMtime) {
1123
+ const msPerDay = 24 * 60 * 60 * 1000;
1124
+ const lagDays = Math.round((srcMtime - distMtime) / msPerDay);
1125
+ const bar = "═".repeat(72);
1126
+ console.error(`\n${bar}\n[Prism] ⚠️ STALE DIST — ${srcSubdir}/ is ${lagDays}d newer than ${distEntry}\n` +
1127
+ `[Prism] Running binary may be missing fixes/tools.\n` +
1128
+ `[Prism] Fix: cd ${repoRoot} && npm run build, then restart Claude Desktop.\n${bar}\n`);
1129
+ }
1130
+ }
1131
+ }
1132
+ catch {
1133
+ // Never block server boot on the freshness probe.
1134
+ }
1065
1135
  // MUST BE FIRST: Kill any zombie processes and acquire the singleton PID lock
1066
1136
  // before touching SQLite. This prevents lock contention on prism-config.db.
1067
1137
  acquireLock();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Portal wire contracts — Zod schemas for every action payload that
3
+ * prism-mcp-server sends to or receives from the Synalux portal.
4
+ *
5
+ * WHY THIS FILE EXISTS:
6
+ * The 2026-05-24 incident: `knowledge_search` sent `queryText` but
7
+ * the portal expected `query`. Both sides had passing unit tests
8
+ * because each test was written to match its own implementation,
9
+ * not the shared wire contract. This file is the single source of
10
+ * truth for that contract. A field rename here is a compile error
11
+ * in synalux.ts AND a schema-validation failure in route.ts —
12
+ * forcing both sides to update together.
13
+ *
14
+ * ADDING A NEW ACTION:
15
+ * 1. Add RequestSchema + ResponseSchema below.
16
+ * 2. Import and validate in synalux.ts (outgoing) + route.ts (incoming).
17
+ * 3. Add a schema-contract test in synalux-portal-contract.test.ts.
18
+ */
19
+ import { z } from "zod";
20
+ // ─── knowledge_search ────────────────────────────────────────────
21
+ export const KnowledgeSearchRequestSchema = z.object({
22
+ action: z.literal("knowledge_search"),
23
+ project: z.string().optional(),
24
+ keywords: z.array(z.string()).default([]),
25
+ category: z.string().optional(),
26
+ /** Free-text filter applied via Postgres textSearch on summary.
27
+ * WIRE NAME: `query` — NOT `queryText` (incident 2026-05-24). */
28
+ query: z.string().optional(),
29
+ limit: z.number().int().min(1).max(50).default(10),
30
+ role: z.string().optional(),
31
+ /** 'user' returns only the caller's entries; 'workspace' broadens to all
32
+ * workspace_members rows after server-side membership verification.
33
+ * Optional with no default — the portal applies its own default (currently
34
+ * 'user') so this schema doesn't impose a policy on the wire format. */
35
+ scope: z.enum(["user", "workspace"]).optional(),
36
+ });
37
+ export const KnowledgeSearchResponseSchema = z.object({
38
+ status: z.literal("success"),
39
+ action: z.literal("knowledge_search"),
40
+ count: z.number(),
41
+ results: z.array(z.record(z.string(), z.unknown())),
42
+ });
@@ -34,6 +34,17 @@
34
34
  import { SupabaseStorage } from "./supabase.js";
35
35
  import { debugLog } from "../utils/logger.js";
36
36
  import { PRISM_SYNALUX_BASE_URL, PRISM_SYNALUX_API_KEY } from "../config.js";
37
+ import { KnowledgeSearchRequestSchema } from "./portalContracts.js";
38
+ function resolveKnowledgeScope(callerScope) {
39
+ if (callerScope === "user" || callerScope === "workspace") {
40
+ return callerScope;
41
+ }
42
+ const envScope = process.env.PRISM_KNOWLEDGE_SCOPE;
43
+ if (envScope === "user" || envScope === "workspace") {
44
+ return envScope;
45
+ }
46
+ return undefined;
47
+ }
37
48
  /** Refresh JWT this many ms before expiry to avoid edge-case 401s. */
38
49
  const JWT_REFRESH_LEEWAY_MS = 60_000;
39
50
  export class SynaluxStorage extends SupabaseStorage {
@@ -258,15 +269,19 @@ export class SynaluxStorage extends SupabaseStorage {
258
269
  // filters. Falls back to plain text search when only queryText is
259
270
  // supplied.
260
271
  async searchKnowledge(params) {
261
- const result = await this.portalPost("/api/v1/prism/memory", {
272
+ const wireBody = KnowledgeSearchRequestSchema.parse({
262
273
  action: "knowledge_search",
263
274
  project: params.project ?? undefined,
264
275
  keywords: params.keywords ?? [],
265
276
  category: params.category ?? undefined,
266
- queryText: params.queryText ?? undefined,
277
+ query: params.queryText ?? undefined,
267
278
  limit: params.limit ?? 10,
268
279
  role: params.role ?? undefined,
280
+ // Scope precedence: explicit caller param > PRISM_KNOWLEDGE_SCOPE env
281
+ // > undefined (portal decides). No hardcoded default here.
282
+ scope: resolveKnowledgeScope(params.scope),
269
283
  });
284
+ const result = await this.portalPost("/api/v1/prism/memory", wireBody);
270
285
  const count = typeof result.count === "number" ? result.count : 0;
271
286
  const results = Array.isArray(result.results) ? result.results : [];
272
287
  return { count, results };
@@ -19,7 +19,6 @@ import { formatRulesBlock, applySentinelBlock } from "./commonHelpers.js";
19
19
  import { debugLog } from "../utils/logger.js";
20
20
  import { recordCognitiveRoute } from "../observability/graphMetrics.js";
21
21
  import { getStorage } from "../storage/index.js";
22
- import { toKeywordArray } from "../utils/keywordExtractor.js";
23
22
  import { getLLMProvider } from "../utils/llm/factory.js";
24
23
  import { getSetting } from "../storage/configStorage.js";
25
24
  // ─── Phase 1: Explainability & Memory Lineage ────────────────
@@ -67,14 +66,21 @@ export async function knowledgeSearchHandler(args) {
67
66
  debugLog(`[knowledge_search] Searching: project=${project || "all"}, query="${query || ""}", category=${category || "any"}, limit=${limit}`);
68
67
  // Phase 1: Capture total start time for latency measurement
69
68
  const totalStart = performance.now();
70
- const searchKeywords = query ? toKeywordArray(query) : [];
71
69
  const storage = await getStorage();
70
+ // NOTE: do NOT auto-derive keywords from the free-text query. The portal
71
+ // applies keywords as a hard .overlaps() AND-filter; deriving them from
72
+ // the query (via toKeywordArray) turns "BFCL" into keywords=['bfcl']
73
+ // which excludes every row whose keywords[] column doesn't contain
74
+ // 'bfcl' — even when the summary clearly matches "BFCL" via textSearch.
75
+ // Free-text search must rely on textSearch alone. Callers who want a
76
+ // keyword overlap filter should pass keywords explicitly via a future
77
+ // input-schema field, not via implicit extraction.
72
78
  // Phase 1: Capture storage-specific start time to isolate DB latency
73
79
  // from keyword extraction and other overhead
74
80
  const storageStart = performance.now();
75
81
  const data = await storage.searchKnowledge({
76
82
  project: project || null,
77
- keywords: searchKeywords,
83
+ keywords: [],
78
84
  category: category || null,
79
85
  queryText: query || null,
80
86
  limit: Math.min(limit, 50),
@@ -207,6 +213,16 @@ export async function knowledgeSearchHandler(args) {
207
213
  catch (graphErr) {
208
214
  debugLog(`[knowledge_search] Graph expansion failed (non-fatal): ${graphErr instanceof Error ? graphErr.message : String(graphErr)}`);
209
215
  }
216
+ // Machine-readable evidence snippets for direct pass-through to prism_infer
217
+ if (data.results && Array.isArray(data.results) && data.results.length > 0) {
218
+ const evidenceSnippets = data.results.map((r, i) => ({
219
+ source: `knowledge_search:${r.id ?? i}`,
220
+ content: (r.content ?? r.summary ?? r.text ?? "").slice(0, 1000),
221
+ })).filter((s) => s.content);
222
+ if (evidenceSnippets.length > 0) {
223
+ contentBlocks.push({ type: "text", text: JSON.stringify({ evidence_snippets: evidenceSnippets }) });
224
+ }
225
+ }
210
226
  return { content: contentBlocks, isError: false };
211
227
  }
212
228
  export async function knowledgeForgetHandler(args) {
@@ -553,6 +569,16 @@ export async function sessionSearchMemoryHandler(args) {
553
569
  });
554
570
  contentBlocks.push(traceToContentBlock(trace));
555
571
  }
572
+ // Machine-readable evidence snippets for direct pass-through to prism_infer
573
+ {
574
+ const evidenceSnippets = results.map((r, i) => ({
575
+ source: `session_search_memory:${r.id ?? i}`,
576
+ content: (r.summary ?? "").slice(0, 1000),
577
+ })).filter((s) => s.content);
578
+ if (evidenceSnippets.length > 0) {
579
+ contentBlocks.push({ type: "text", text: JSON.stringify({ evidence_snippets: evidenceSnippets }) });
580
+ }
581
+ }
556
582
  // ── v6.0 Phase 3: 1-Hop Graph Expansion ──────────────────
557
583
  // After direct hits, traverse outbound links from each result to
558
584
  // find associated memories. Graph-expanded results are BONUS — they
@@ -26,7 +26,7 @@ export { webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, br
26
26
  // This file always exports them — server.ts decides whether to include them in the tool list.
27
27
  //
28
28
  // v0.4.0: Added SESSION_COMPACT_LEDGER_TOOL and SESSION_SEARCH_MEMORY_TOOL
29
- export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
29
+ export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_BACKFILL_EMBEDDINGS_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
30
30
  // 1. Ledger (Core CRUD & State)
31
31
  export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionSaveExperienceHandler, sessionSaveImageHandler, sessionViewImageHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler } from "./ledgerHandlers.js";
32
32
  // 2. Graph (Semantic Search & Weighting)
@@ -24,6 +24,7 @@ import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
24
24
  import { getAvailableMemoryBytes } from "../utils/availableMemory.js";
25
25
  import { PRISM_SYNALUX_BASE_URL, PRISM_LOCAL_LLM_URL, } from "../config.js";
26
26
  import { debugLog } from "../utils/logger.js";
27
+ import { verifyGrounding } from "../utils/groundingVerifier.js";
27
28
  // ─── Tool Definition ────────────────────────────────────────────
28
29
  export const PRISM_INFER_TOOL = {
29
30
  name: "prism_infer",
@@ -69,6 +70,37 @@ export const PRISM_INFER_TOOL = {
69
70
  type: "number",
70
71
  description: "Override per-call timeout. Default scales with model size: 32B=120s, 14B=60s, 8B=30s, 1.7B=15s.",
71
72
  },
73
+ evidence: {
74
+ type: "array",
75
+ description: "Optional evidence snippets the model output must be grounded in. " +
76
+ "When supplied with `verify: true`, every assertive claim in the draft " +
77
+ "(numbers, names, dates, codes, $ amounts) must be ENTAILED by one of " +
78
+ "these snippets or the draft is refused.",
79
+ items: {
80
+ type: "object",
81
+ properties: {
82
+ source: { type: "string", description: "Label for the snippet (e.g. 'tool:knowledge_search#3')." },
83
+ content: { type: "string", description: "The evidence text itself." },
84
+ },
85
+ required: ["source", "content"],
86
+ },
87
+ },
88
+ verify: {
89
+ type: "boolean",
90
+ description: "Enable the L3 grounding verifier. Default: true when `evidence` is provided, " +
91
+ "false otherwise. When enabled, the model's draft is checked by a different model " +
92
+ "(prism-coder:1b7 by default) against the supplied `evidence`. Drafts with " +
93
+ "NEUTRAL or CONTRADICTED claims are refused.",
94
+ },
95
+ verifier_model: {
96
+ type: "string",
97
+ description: "Override the verifier model. Default: prism-coder:1b7.",
98
+ },
99
+ verifier_timeout_ms: {
100
+ type: "number",
101
+ description: "Override the verifier hard timeout. Default 2000 ms.",
102
+ default: 2000,
103
+ },
72
104
  },
73
105
  required: ["prompt"],
74
106
  },
@@ -92,6 +124,23 @@ export function isPrismInferArgs(args) {
92
124
  if (a.model_ceiling !== undefined &&
93
125
  !["32b", "14b", "8b", "1b7"].includes(a.model_ceiling))
94
126
  return false;
127
+ if (a.verify !== undefined && typeof a.verify !== "boolean")
128
+ return false;
129
+ if (a.verifier_model !== undefined && typeof a.verifier_model !== "string")
130
+ return false;
131
+ if (a.verifier_timeout_ms !== undefined && typeof a.verifier_timeout_ms !== "number")
132
+ return false;
133
+ if (a.evidence !== undefined) {
134
+ if (!Array.isArray(a.evidence))
135
+ return false;
136
+ for (const e of a.evidence) {
137
+ if (!e || typeof e !== "object")
138
+ return false;
139
+ const es = e;
140
+ if (typeof es.source !== "string" || typeof es.content !== "string")
141
+ return false;
142
+ }
143
+ }
95
144
  return true;
96
145
  }
97
146
  // ─── Ollama helpers ────────────────────────────────────────────
@@ -269,15 +318,14 @@ export async function runInfer(args, deps) {
269
318
  const timeout = args.timeout_ms ?? DEFAULT_TIMEOUTS[tier.tag] ?? 60_000;
270
319
  const result = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, args.system, maxTokens, temperature, timeout);
271
320
  if (result.ok) {
272
- return {
273
- output: result.text,
321
+ return await applyVerification(result.text, args, deps, {
274
322
  backend: `ollama-${tier.tag.replace("prism-coder:", "")}`,
275
323
  model_picked: tier.tag,
276
324
  ram_free_mb: ramFreeMb,
277
325
  latency_ms: Date.now() - t0,
278
326
  used_cloud: false,
279
327
  attempts,
280
- };
328
+ });
281
329
  }
282
330
  attempts.push({ tier: tier.tag, reason: result.reason });
283
331
  }
@@ -292,15 +340,14 @@ export async function runInfer(args, deps) {
292
340
  const cloudTimeout = args.timeout_ms ?? 90_000;
293
341
  const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
294
342
  if (cloud.ok && cloud.output) {
295
- return {
296
- output: cloud.output,
343
+ return await applyVerification(cloud.output, args, deps, {
297
344
  backend: cloud.backend ?? "synalux",
298
345
  model_picked: null,
299
346
  ram_free_mb: ramFreeMb,
300
347
  latency_ms: Date.now() - t0,
301
348
  used_cloud: true,
302
349
  attempts,
303
- };
350
+ });
304
351
  }
305
352
  attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
306
353
  }
@@ -312,6 +359,36 @@ export async function runInfer(args, deps) {
312
359
  err.attempts = attempts;
313
360
  throw err;
314
361
  }
362
+ /**
363
+ * Wraps a successful inference result with the L3 grounding verifier
364
+ * when the caller opted in via `verify: true`. The verifier substitutes
365
+ * the model's draft with a refusal string if any claim is not entailed
366
+ * by the supplied evidence; we surface that as a non-null `verification`
367
+ * field so callers can route refusals separately from successes.
368
+ */
369
+ async function applyVerification(draft, args, deps, partial) {
370
+ const shouldVerify = args.verify ?? (args.evidence !== undefined && args.evidence.length > 0);
371
+ if (!shouldVerify) {
372
+ return { ...partial, output: draft };
373
+ }
374
+ const verifier = deps.callVerifier ?? verifyGrounding;
375
+ const outcome = await verifier({
376
+ draft,
377
+ evidence: args.evidence ?? [],
378
+ verifierModel: args.verifier_model,
379
+ timeoutMs: args.verifier_timeout_ms,
380
+ ollamaUrl: deps.ollamaUrl,
381
+ });
382
+ return {
383
+ ...partial,
384
+ output: outcome.finalText,
385
+ verification: {
386
+ action: outcome.action,
387
+ verifierChain: outcome.verifierChain,
388
+ refusalClaim: outcome.refusalClaim,
389
+ },
390
+ };
391
+ }
315
392
  /**
316
393
  * MCP-shaped handler. Wraps runInfer with real deps + MCP envelope.
317
394
  */
@@ -334,6 +411,7 @@ export async function prismInferHandler(args) {
334
411
  ` free_ram=${result.ram_free_mb}MB` +
335
412
  ` latency=${result.latency_ms}ms` +
336
413
  ` used_cloud=${result.used_cloud}` +
414
+ (result.verification ? ` verify=${result.verification.action}` : "") +
337
415
  (result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
338
416
  return {
339
417
  content: [
@@ -0,0 +1,203 @@
1
+ /**
2
+ * groundingVerifier — runtime accountability for prism_infer
3
+ * ============================================================
4
+ *
5
+ * When a caller passes `evidence` + `verify: true` to prism_infer, this
6
+ * module checks that every factual claim in the model's draft is
7
+ * entailed by one of the evidence snippets. Sibling of synalux-portal's
8
+ * chat-verifier — same architecture, lighter footprint (no DB audit,
9
+ * stateless MCP), pointed at free-form generation instead of tool-call
10
+ * responses.
11
+ *
12
+ * Cascade role: prism-coder:1b7 is the default verifier on every
13
+ * device (server, iPad). Larger tiers (8B/14B/32B) draft; 1b7 verifies.
14
+ * Different model from the drafter — satisfies the Patronus rule.
15
+ *
16
+ * Failure modes:
17
+ * - Verifier model unreachable / timeout → fail-closed refusal
18
+ * - Verifier returns malformed JSON → fail-closed refusal
19
+ * - NEUTRAL or CONTRADICTED claim → fail-closed refusal that names
20
+ * the failed claim
21
+ *
22
+ * The refusal text always names which claim couldn't be grounded so
23
+ * the calling agent can decide whether to retry with more evidence or
24
+ * fall back to cloud.
25
+ */
26
+ import { PRISM_LOCAL_LLM_URL } from "../config.js";
27
+ // ─── Pre-checks ─────────────────────────────────────────────────────────
28
+ const ASSERTIVE_RX = /\b(?:\d{1,5}|[A-Z]\d{2}\.\d|ICD-?10|CPT|\$\d|\d{4}-\d{2}-\d{2}|[A-Z][a-z]{2,}\s+[A-Z][a-z]{2,})\b/;
29
+ /**
30
+ * Returns true when the draft makes at least one assertion that could be
31
+ * fabricated — numbers, dates, ICD/CPT codes, two-word names, dollar
32
+ * amounts. Conversational replies skip the verifier entirely.
33
+ */
34
+ export function draftHasAssertiveClaims(draft) {
35
+ if (!draft)
36
+ return false;
37
+ return ASSERTIVE_RX.test(draft);
38
+ }
39
+ // ─── Verifier prompt (grammar-constrained JSON) ─────────────────────────
40
+ const VERIFIER_SYSTEM_PROMPT = `You are a strict factual-grounding verifier. Your job is to REJECT ungrounded claims.
41
+ Given EVIDENCE (one or more text snippets) and DRAFT_ANSWER, find every
42
+ factual claim (counts, names, dates, codes, dollar amounts) and assign:
43
+
44
+ ENTAILED — the EXACT value appears verbatim in EVIDENCE text, or is an
45
+ arithmetic identity (e.g. "3" and "three"). STRICT: if you
46
+ must infer, estimate, or extrapolate, it is NOT ENTAILED.
47
+ CONTRADICTED — the claim states a DIFFERENT value than what EVIDENCE says
48
+ for the same fact.
49
+ NEUTRAL — the claim is not addressed in EVIDENCE at all.
50
+
51
+ CRITICAL DEFAULT RULE: when in doubt, use NEUTRAL — never guess ENTAILED.
52
+ Prefer false negatives over false positives. If the evidence does not
53
+ explicitly state the value, it is NEUTRAL.
54
+
55
+ Do NOT report opinions, refusals, or hedges as claims. Conversational
56
+ phrasing ("Hello", "I can help") is not a claim.
57
+
58
+ Output JSON only — no prose, no apology.`;
59
+ const VERIFIER_JSON_SCHEMA = {
60
+ type: "object",
61
+ properties: {
62
+ claims: {
63
+ type: "array",
64
+ items: {
65
+ type: "object",
66
+ properties: {
67
+ text: { type: "string" },
68
+ verdict: { type: "string", enum: ["ENTAILED", "NEUTRAL", "CONTRADICTED"] },
69
+ evidence_span: { type: ["string", "null"] },
70
+ },
71
+ required: ["text", "verdict", "evidence_span"],
72
+ additionalProperties: false,
73
+ },
74
+ },
75
+ },
76
+ required: ["claims"],
77
+ additionalProperties: false,
78
+ };
79
+ // ─── Refusal text ───────────────────────────────────────────────────────
80
+ function refusalText(action, failedClaim) {
81
+ switch (action) {
82
+ case "refused_fabricated":
83
+ return `I can't ground "${failedClaim}" in the evidence provided. ` +
84
+ "If this claim is correct, supply the supporting source as evidence and retry.";
85
+ case "refused_no_evidence":
86
+ return `I can't ground "${failedClaim}" — no evidence was provided this turn. ` +
87
+ "Provide evidence snippets via the `evidence` argument and retry.";
88
+ case "refused_timeout":
89
+ return `I couldn't verify "${failedClaim}" within the allowed time. ` +
90
+ "The verifier model may be cold-loading; try again in a moment.";
91
+ case "served":
92
+ return ""; // unreachable
93
+ }
94
+ }
95
+ export async function verifyGrounding(opts) {
96
+ const verifierModel = opts.verifierModel ?? "prism-coder:1b7";
97
+ const timeoutMs = opts.timeoutMs ?? 2000;
98
+ const ollamaUrl = opts.ollamaUrl ?? PRISM_LOCAL_LLM_URL;
99
+ const fetchImpl = opts.fetchImpl ?? fetch;
100
+ const verifierChain = [];
101
+ // Tier 0 — conversational drafts skip the verifier entirely.
102
+ if (!draftHasAssertiveClaims(opts.draft)) {
103
+ return {
104
+ action: "served",
105
+ finalText: opts.draft,
106
+ claims: [],
107
+ verifierChain,
108
+ };
109
+ }
110
+ // Tier 0a — assertive draft with NO evidence is fail-closed:
111
+ // the model is making claims it cannot back up.
112
+ if (opts.evidence.length === 0) {
113
+ const claim = firstAssertiveSpan(opts.draft);
114
+ return {
115
+ action: "refused_no_evidence",
116
+ finalText: refusalText("refused_no_evidence", claim),
117
+ claims: [{ claim, verdict: "NEUTRAL", evidence_span: null }],
118
+ verifierChain,
119
+ refusalClaim: claim,
120
+ };
121
+ }
122
+ // Tier 2 — NLI verifier call.
123
+ const t0 = Date.now();
124
+ const evidenceText = opts.evidence
125
+ .map((e, i) => `[${i}] ${e.source}\n${e.content}`)
126
+ .join("\n\n");
127
+ const payload = {
128
+ model: verifierModel,
129
+ messages: [
130
+ { role: "system", content: VERIFIER_SYSTEM_PROMPT },
131
+ { role: "user", content: `EVIDENCE:\n${evidenceText}\n\nDRAFT_ANSWER:\n${opts.draft}` },
132
+ ],
133
+ stream: false,
134
+ response_format: {
135
+ type: "json_schema",
136
+ json_schema: { name: "verifier", schema: VERIFIER_JSON_SCHEMA, strict: true },
137
+ },
138
+ temperature: 0,
139
+ };
140
+ let parsedClaims = null;
141
+ try {
142
+ const res = await fetchImpl(`${ollamaUrl}/v1/chat/completions`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify(payload),
146
+ signal: AbortSignal.timeout(timeoutMs),
147
+ });
148
+ if (!res.ok)
149
+ throw new Error(`HTTP ${res.status}`);
150
+ const data = (await res.json());
151
+ const content = data?.choices?.[0]?.message?.content;
152
+ if (typeof content !== "string")
153
+ throw new Error("no content");
154
+ const parsed = JSON.parse(content);
155
+ if (!parsed || !Array.isArray(parsed.claims))
156
+ throw new Error("malformed");
157
+ parsedClaims = parsed.claims.map((c) => ({
158
+ claim: String(c.text ?? ""),
159
+ verdict: ["ENTAILED", "NEUTRAL", "CONTRADICTED"].includes(c.verdict)
160
+ ? c.verdict
161
+ : "NEUTRAL",
162
+ evidence_span: typeof c.evidence_span === "string" ? c.evidence_span : null,
163
+ }));
164
+ }
165
+ catch {
166
+ const latencyMs = Date.now() - t0;
167
+ verifierChain.push({ model: verifierModel, verdict: "NEUTRAL", latencyMs });
168
+ const claim = firstAssertiveSpan(opts.draft);
169
+ return {
170
+ action: "refused_timeout",
171
+ finalText: refusalText("refused_timeout", claim),
172
+ claims: [{ claim, verdict: "NEUTRAL", evidence_span: null }],
173
+ verifierChain,
174
+ refusalClaim: claim,
175
+ };
176
+ }
177
+ const latencyMs = Date.now() - t0;
178
+ const failing = parsedClaims.find(c => c.verdict !== "ENTAILED");
179
+ const rollup = failing ? failing.verdict : "ENTAILED";
180
+ verifierChain.push({ model: verifierModel, verdict: rollup, latencyMs });
181
+ if (failing) {
182
+ return {
183
+ action: "refused_fabricated",
184
+ finalText: refusalText("refused_fabricated", failing.claim),
185
+ claims: parsedClaims,
186
+ verifierChain,
187
+ refusalClaim: failing.claim,
188
+ };
189
+ }
190
+ return {
191
+ action: "served",
192
+ finalText: opts.draft,
193
+ claims: parsedClaims,
194
+ verifierChain,
195
+ };
196
+ }
197
+ // ─── helpers ────────────────────────────────────────────────────────────
198
+ function firstAssertiveSpan(draft) {
199
+ const m = draft.match(ASSERTIVE_RX);
200
+ if (m)
201
+ return m[0];
202
+ return draft.slice(0, 80);
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "15.5.2",
3
+ "version": "15.6.0",
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, 54 Agent Skills, Zero-Search HDC/HRR retrieval, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder:7b / 14b open-weights LLM fleet.",
6
6
  "module": "index.ts",