prism-mcp-server 20.1.0 → 20.2.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
@@ -18,6 +18,16 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
18
18
 
19
19
  ---
20
20
 
21
+ ## What's New in v20.2.0
22
+
23
+ ### One Command Connects Every Supported Host
24
+ Install Prism globally and run `prism connect`. It detects Claude Code, Claude
25
+ Desktop on macOS, Windows, and Linux (beta), Cursor, Gemini CLI, and Codex, then safely registers the
26
+ server from the installed package. Existing custom entries are untouched;
27
+ `--dry-run` previews changes and `--refresh` updates only Prism-managed entries.
28
+
29
+ ---
30
+
21
31
  ## What's New in v20.1.0
22
32
 
23
33
  ### Every Inference Outcome Is Now Observable
@@ -97,20 +107,31 @@ External contributions now require signing the [Individual CLA](./CLA.md). The C
97
107
 
98
108
  ## Quickstart
99
109
 
100
- The free tier needs no account, no API key, and no cloud. Add the server to your MCP client:
101
-
102
- ```json
103
- {
104
- "mcpServers": {
105
- "prism": {
106
- "command": "npx",
107
- "args": ["-y", "prism-mcp-server"]
108
- }
109
- }
110
- }
110
+ The free tier needs no account, no API key, and no cloud. Install Prism, then
111
+ register it with every supported MCP host already installed on your machine:
112
+
113
+ ```bash
114
+ npm install --global prism-mcp-server
115
+ prism connect
111
116
  ```
112
117
 
113
- Open Claude Desktop or Cursor and your agent now has memory backed by a local SQLite database (`~/.prism-mcp/data.db`).
118
+ `prism connect` detects Claude Code, Claude Desktop (macOS/Windows/Linux), Cursor,
119
+ Gemini CLI, and Codex.
120
+ Use `prism connect --all` to target all five, `--host <name>` for one host, or
121
+ `--dry-run` to preview the files that would change. Existing `prism` and
122
+ `prism-mcp` entries are never overwritten by default. `--refresh` updates only
123
+ an entry previously created by Prism; custom entries remain untouched.
124
+ Close the target MCP hosts before a non-dry-run registration so they cannot
125
+ edit their configuration at the same time.
126
+
127
+ Codex registration preserves `~/.codex/config.toml` and appends only a marked
128
+ Prism-managed block. `CODEX_HOME` is respected when set and must already exist,
129
+ matching Codex's own contract. Restart Codex CLI, the
130
+ IDE extension, or the ChatGPT desktop app after connecting.
131
+
132
+ Restart the connected host and your agent now has memory backed by a local
133
+ SQLite database (`~/.prism-mcp/data.db`). See [IDE setup](docs/IDE_SETUP.md)
134
+ for manual configuration and host-specific paths.
114
135
 
115
136
  **Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
116
137
 
package/dist/cli.js CHANGED
@@ -8,11 +8,73 @@ import { getSetting } from './storage/configStorage.js';
8
8
  import { PRISM_USER_ID, SERVER_CONFIG } from './config.js';
9
9
  import { getCurrentGitState } from './utils/git.js';
10
10
  import { sessionLoadContextHandler, sessionSaveLedgerHandler, sessionSaveHandoffHandler } from './tools/ledgerHandlers.js';
11
+ import { connectHosts, normalizeHostName } from './connect.js';
11
12
  const program = new Command();
12
13
  program
13
14
  .name('prism')
14
15
  .description('Prism — The Mind Palace for AI Agents')
15
16
  .version(SERVER_CONFIG.version);
17
+ // ─── prism connect ────────────────────────────────────────────
18
+ // Registers this installed package with supported MCP hosts. The
19
+ // merge is additive: an existing `prism` or `prism-mcp` entry is
20
+ // reported and left byte-for-byte untouched.
21
+ program
22
+ .command('connect')
23
+ .description('Register Prism with installed MCP hosts (close hosts before writing)')
24
+ .option('--host <name>', 'Target one host: claude-code, claude-desktop, cursor, gemini, or codex')
25
+ .option('--all', 'Target all supported hosts instead of auto-detecting installed hosts')
26
+ .option('--dry-run', 'Preview configuration changes without writing files')
27
+ .option('--refresh', 'Refresh only entries previously created by Prism; custom entries stay untouched')
28
+ .action((options) => {
29
+ try {
30
+ if (!options.dryRun) {
31
+ console.log('Close target MCP hosts before registration so they cannot edit configuration concurrently.');
32
+ }
33
+ const hosts = options.host ? [normalizeHostName(options.host)] : undefined;
34
+ const summary = connectHosts({
35
+ all: !!options.all,
36
+ dryRun: !!options.dryRun,
37
+ refresh: !!options.refresh,
38
+ hosts,
39
+ });
40
+ if (summary.results.length === 0) {
41
+ console.error('No supported MCP hosts detected. Use --host <name> or --all to target one explicitly.');
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ for (const result of summary.results) {
46
+ if (result.status === 'registered') {
47
+ console.log(`✓ ${result.label}: registered (${result.path})`);
48
+ }
49
+ else if (result.status === 'would-register') {
50
+ console.log(`• ${result.label}: would register (${result.path})`);
51
+ }
52
+ else if (result.status === 'refreshed') {
53
+ console.log(`✓ ${result.label}: Prism-managed entry refreshed (${result.path})`);
54
+ }
55
+ else if (result.status === 'would-refresh') {
56
+ console.log(`• ${result.label}: would refresh Prism-managed entry (${result.path})`);
57
+ }
58
+ else if (result.status === 'existing') {
59
+ console.log(`− ${result.label}: already registered — untouched (${result.path})`);
60
+ }
61
+ else {
62
+ console.error(`✗ ${result.label}: ${result.message || 'registration failed'} (${result.path})`);
63
+ process.exitCode = 1;
64
+ }
65
+ }
66
+ if (options.dryRun) {
67
+ console.log('\nDry run complete — no files changed.');
68
+ }
69
+ if (!summary.usedApiKey) {
70
+ console.log('PRISM_SYNALUX_API_KEY is not set; new registrations use local/free mode.');
71
+ }
72
+ }
73
+ catch (err) {
74
+ console.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`);
75
+ process.exitCode = 1;
76
+ }
77
+ });
16
78
  // ─── prism load <project> ─────────────────────────────────────
17
79
  // Loads session context using the same storage layer as the MCP
18
80
  // session_load_context tool. Works with both SQLite and Supabase.
@@ -0,0 +1,560 @@
1
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
+ export const CONNECT_HOSTS = [
8
+ "claude-code",
9
+ "claude-desktop",
10
+ "cursor",
11
+ "gemini",
12
+ "codex",
13
+ ];
14
+ const HOST_ALIASES = {
15
+ "claude-code": "claude-code",
16
+ claude: "claude-code",
17
+ code: "claude-code",
18
+ "claude-desktop": "claude-desktop",
19
+ desktop: "claude-desktop",
20
+ cursor: "cursor",
21
+ gemini: "gemini",
22
+ "gemini-cli": "gemini",
23
+ codex: "codex",
24
+ "codex-cli": "codex",
25
+ };
26
+ const CODEX_MANAGED_START = "# >>> prism connect managed: prism-mcp";
27
+ const CODEX_MANAGED_END = "# <<< prism connect managed: prism-mcp";
28
+ export function normalizeHostName(value) {
29
+ const normalized = value.trim().toLowerCase();
30
+ const host = HOST_ALIASES[normalized];
31
+ if (!host) {
32
+ throw new Error(`Unsupported host "${value}". Choose one of: ${CONNECT_HOSTS.join(", ")}`);
33
+ }
34
+ return host;
35
+ }
36
+ export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
37
+ const manifestPath = fileURLToPath(new URL("../package.json", moduleUrl));
38
+ let manifest;
39
+ try {
40
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
41
+ }
42
+ catch (error) {
43
+ throw new Error(`Cannot read Prism package manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ if (!isJsonObject(manifest) || typeof manifest.main !== "string" || !manifest.main.trim()) {
46
+ throw new Error(`Prism package manifest has no valid "main" entry: ${manifestPath}`);
47
+ }
48
+ const serverPath = resolve(dirname(manifestPath), manifest.main);
49
+ if (!existsSync(serverPath)) {
50
+ throw new Error(`Prism server entrypoint was not found: ${serverPath}. Run npm run build first.`);
51
+ }
52
+ return realpathSync(serverPath);
53
+ }
54
+ export function connectHosts(options = {}) {
55
+ if (options.all && options.hosts?.length) {
56
+ throw new Error("Use either --all or --host, not both.");
57
+ }
58
+ const homeDir = options.homeDir ?? homedir();
59
+ const platform = options.platform ?? process.platform;
60
+ const env = options.env ?? process.env;
61
+ const serverPath = options.serverPath ?? resolveInstalledServerPath();
62
+ const nodePath = options.nodePath ?? process.execPath;
63
+ const definitions = getHostDefinitions(homeDir, platform, env, options.homeDir === undefined);
64
+ const pathEnv = options.pathEnv ?? env.PATH ?? "";
65
+ let selected;
66
+ if (options.hosts?.length) {
67
+ const requested = new Set(options.hosts);
68
+ selected = definitions.filter((definition) => requested.has(definition.name));
69
+ const unavailable = options.hosts.filter((host) => !selected.some((definition) => definition.name === host));
70
+ if (unavailable.length > 0) {
71
+ throw new Error(`${unavailable.join(", ")} is not supported on ${platform}`);
72
+ }
73
+ }
74
+ else if (options.all) {
75
+ selected = definitions;
76
+ }
77
+ else {
78
+ selected = definitions.filter((definition) => isHostDetected(definition, platform, pathEnv));
79
+ }
80
+ const entry = buildMcpEntry(nodePath, serverPath, env);
81
+ const results = selected.map((definition) => registerHost(definition, entry, !!options.dryRun, !!options.refresh, options.beforeCommit));
82
+ return {
83
+ results,
84
+ usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
85
+ serverPath,
86
+ };
87
+ }
88
+ function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
89
+ const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
90
+ const cursorDetectionPaths = [join(homeDir, ".cursor")];
91
+ const desktopDetectionPaths = claudeDesktop ? [dirname(claudeDesktop)] : [];
92
+ const configuredCodexHome = includeSystemPaths && env.CODEX_HOME?.trim()
93
+ ? resolve(env.CODEX_HOME.trim())
94
+ : undefined;
95
+ const codexHome = configuredCodexHome ?? join(homeDir, ".codex");
96
+ const codexDetectionPaths = [codexHome];
97
+ let codexConfigurationError;
98
+ if (configuredCodexHome) {
99
+ try {
100
+ if (!statSync(configuredCodexHome).isDirectory()) {
101
+ codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome}`;
102
+ }
103
+ }
104
+ catch (error) {
105
+ codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome} (${error instanceof Error ? error.message : String(error)})`;
106
+ }
107
+ }
108
+ if (platform === "darwin") {
109
+ desktopDetectionPaths.push(join(homeDir, "Applications", "Claude.app"));
110
+ cursorDetectionPaths.push(join(homeDir, "Applications", "Cursor.app"));
111
+ codexDetectionPaths.push(join(homeDir, "Applications", "ChatGPT.app"));
112
+ if (includeSystemPaths) {
113
+ desktopDetectionPaths.push("/Applications/Claude.app");
114
+ cursorDetectionPaths.push("/Applications/Cursor.app");
115
+ codexDetectionPaths.push("/Applications/ChatGPT.app");
116
+ }
117
+ }
118
+ else if (platform === "win32") {
119
+ const localAppData = env.LOCALAPPDATA || join(homeDir, "AppData", "Local");
120
+ desktopDetectionPaths.push(join(localAppData, "Programs", "Claude", "Claude.exe"), join(localAppData, "AnthropicClaude", "Claude.exe"));
121
+ cursorDetectionPaths.push(join(localAppData, "Programs", "cursor", "Cursor.exe"), join(localAppData, "Cursor", "Cursor.exe"));
122
+ codexDetectionPaths.push(join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe"), join(localAppData, "Microsoft", "WindowsApps", "ChatGPT.exe"));
123
+ }
124
+ const definitions = [
125
+ {
126
+ name: "claude-code",
127
+ label: "Claude Code",
128
+ format: "json",
129
+ configPath: join(homeDir, ".claude.json"),
130
+ detectionPaths: [join(homeDir, ".claude.json"), join(homeDir, ".claude")],
131
+ executables: ["claude"],
132
+ },
133
+ ];
134
+ if (claudeDesktop) {
135
+ definitions.push({
136
+ name: "claude-desktop",
137
+ label: "Claude Desktop",
138
+ format: "json",
139
+ configPath: claudeDesktop,
140
+ detectionPaths: desktopDetectionPaths,
141
+ executables: ["claude-desktop"],
142
+ });
143
+ }
144
+ definitions.push({
145
+ name: "cursor",
146
+ label: "Cursor",
147
+ format: "json",
148
+ configPath: join(homeDir, ".cursor", "mcp.json"),
149
+ detectionPaths: cursorDetectionPaths,
150
+ executables: ["cursor"],
151
+ }, {
152
+ name: "gemini",
153
+ label: "Gemini CLI",
154
+ format: "json",
155
+ configPath: join(homeDir, ".gemini", "settings.json"),
156
+ detectionPaths: [join(homeDir, ".gemini")],
157
+ executables: ["gemini"],
158
+ }, {
159
+ name: "codex",
160
+ label: "Codex",
161
+ format: "codex-toml",
162
+ configPath: join(codexHome, "config.toml"),
163
+ detectionPaths: codexDetectionPaths,
164
+ executables: ["codex"],
165
+ configurationError: codexConfigurationError,
166
+ });
167
+ return definitions;
168
+ }
169
+ function claudeDesktopPath(homeDir, platform, env) {
170
+ if (platform === "darwin") {
171
+ return join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
172
+ }
173
+ if (platform === "win32") {
174
+ const appData = env.APPDATA || join(homeDir, "AppData", "Roaming");
175
+ return join(appData, "Claude", "claude_desktop_config.json");
176
+ }
177
+ if (platform === "linux") {
178
+ const configHome = env.XDG_CONFIG_HOME || join(homeDir, ".config");
179
+ return join(configHome, "Claude", "claude_desktop_config.json");
180
+ }
181
+ return undefined;
182
+ }
183
+ function isHostDetected(definition, platform, pathEnv) {
184
+ return definition.configurationError !== undefined
185
+ || definition.detectionPaths.some(existsSync)
186
+ || definition.executables.some((executable) => executableExists(executable, platform, pathEnv));
187
+ }
188
+ function executableExists(name, platform, pathEnv) {
189
+ if (!pathEnv)
190
+ return false;
191
+ const separator = platform === "win32" ? ";" : ":";
192
+ const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
193
+ for (const directory of pathEnv.split(separator).filter(Boolean)) {
194
+ for (const extension of extensions) {
195
+ const candidate = join(directory, `${name}${extension}`);
196
+ try {
197
+ if (!statSync(candidate).isFile())
198
+ continue;
199
+ if (platform !== "win32")
200
+ accessSync(candidate, constants.X_OK);
201
+ return true;
202
+ }
203
+ catch {
204
+ // Keep searching PATH entries when a candidate is absent or not executable.
205
+ }
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+ function buildMcpEntry(nodePath, serverPath, env) {
211
+ const serverEnv = {
212
+ PRISM_INSTANCE: "prism-mcp",
213
+ PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
214
+ PRISM_STORAGE: "auto",
215
+ };
216
+ if (env.PRISM_SYNALUX_API_KEY) {
217
+ serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
218
+ }
219
+ return {
220
+ command: nodePath,
221
+ args: [serverPath],
222
+ env: serverEnv,
223
+ };
224
+ }
225
+ function registerHost(definition, entry, dryRun, refresh, beforeCommit) {
226
+ if (definition.configurationError) {
227
+ return result(definition, "error", definition.configurationError);
228
+ }
229
+ if (definition.format === "codex-toml") {
230
+ return registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit);
231
+ }
232
+ return registerJsonHost(definition, entry, dryRun, refresh, beforeCommit);
233
+ }
234
+ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
235
+ const { configPath } = definition;
236
+ let config = {};
237
+ let originalText;
238
+ let writePath = configPath;
239
+ let symlinkPath;
240
+ try {
241
+ const pathInfo = lstatSync(configPath);
242
+ if (pathInfo.isSymbolicLink()) {
243
+ try {
244
+ writePath = realpathSync(configPath);
245
+ symlinkPath = configPath;
246
+ }
247
+ catch (error) {
248
+ return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
249
+ }
250
+ }
251
+ try {
252
+ originalText = readFileSync(writePath, "utf8");
253
+ const parsed = JSON.parse(originalText);
254
+ if (!isJsonObject(parsed)) {
255
+ throw new Error("top-level JSON value must be an object");
256
+ }
257
+ config = parsed;
258
+ }
259
+ catch (error) {
260
+ return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
261
+ }
262
+ }
263
+ catch (error) {
264
+ if (!isErrno(error, "ENOENT")) {
265
+ return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
266
+ }
267
+ }
268
+ const currentServers = config.mcpServers;
269
+ if (currentServers !== undefined && !isJsonObject(currentServers)) {
270
+ return result(definition, "error", '"mcpServers" must be a JSON object');
271
+ }
272
+ const mcpServers = (currentServers ?? {});
273
+ const existingKey = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp")
274
+ ? "prism-mcp"
275
+ : Object.prototype.hasOwnProperty.call(mcpServers, "prism")
276
+ ? "prism"
277
+ : undefined;
278
+ if (existingKey) {
279
+ const existingEntry = mcpServers[existingKey];
280
+ if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
281
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched");
282
+ }
283
+ const refreshedEntry = refreshManagedEntry(existingEntry, entry);
284
+ if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
285
+ return result(definition, "existing", "Prism-managed entry is already current");
286
+ }
287
+ if (dryRun) {
288
+ return result(definition, "would-refresh");
289
+ }
290
+ mcpServers[existingKey] = refreshedEntry;
291
+ config.mcpServers = mcpServers;
292
+ try {
293
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
294
+ return result(definition, "refreshed");
295
+ }
296
+ catch (error) {
297
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
298
+ }
299
+ }
300
+ if (dryRun) {
301
+ return result(definition, "would-register");
302
+ }
303
+ mcpServers["prism-mcp"] = entry;
304
+ config.mcpServers = mcpServers;
305
+ try {
306
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
307
+ return result(definition, "registered");
308
+ }
309
+ catch (error) {
310
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
311
+ }
312
+ }
313
+ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit) {
314
+ const { configPath } = definition;
315
+ let config = {};
316
+ let originalText;
317
+ let writePath = configPath;
318
+ let symlinkPath;
319
+ try {
320
+ const pathInfo = lstatSync(configPath);
321
+ if (pathInfo.isSymbolicLink()) {
322
+ try {
323
+ writePath = realpathSync(configPath);
324
+ symlinkPath = configPath;
325
+ if (!statSync(writePath).isFile()) {
326
+ throw new Error("config symlink target is not a regular file");
327
+ }
328
+ }
329
+ catch (error) {
330
+ return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
331
+ }
332
+ }
333
+ try {
334
+ originalText = readFileSync(writePath, "utf8");
335
+ const parsed = parseToml(originalText);
336
+ if (!isJsonObject(parsed)) {
337
+ throw new Error("top-level TOML value must be a table");
338
+ }
339
+ config = parsed;
340
+ }
341
+ catch (error) {
342
+ return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
343
+ }
344
+ }
345
+ catch (error) {
346
+ if (!isErrno(error, "ENOENT")) {
347
+ return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
348
+ }
349
+ }
350
+ let managedBlock;
351
+ try {
352
+ managedBlock = locateCodexManagedBlock(originalText ?? "");
353
+ }
354
+ catch (error) {
355
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
356
+ }
357
+ const currentServers = config.mcp_servers;
358
+ if (currentServers !== undefined && !isJsonObject(currentServers)) {
359
+ return result(definition, "error", '"mcp_servers" must be a TOML table');
360
+ }
361
+ const mcpServers = (currentServers ?? {});
362
+ const hasCanonical = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp");
363
+ const hasLegacy = Object.prototype.hasOwnProperty.call(mcpServers, "prism");
364
+ if (hasCanonical && hasLegacy) {
365
+ return result(definition, "error", "Codex config contains both prism and prism-mcp entries; resolve the duplicate before retrying");
366
+ }
367
+ if (hasCanonical || hasLegacy) {
368
+ const existingKey = hasCanonical ? "prism-mcp" : "prism";
369
+ const existingEntry = mcpServers[existingKey];
370
+ if (!refresh || existingKey !== "prism-mcp") {
371
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched");
372
+ }
373
+ if (!managedBlock) {
374
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched");
375
+ }
376
+ if (!isManagedPrismEntry(existingEntry)) {
377
+ return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
378
+ }
379
+ const refreshedEntry = refreshManagedEntry(existingEntry, entry);
380
+ if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
381
+ return result(definition, "existing", "Prism-managed entry is already current");
382
+ }
383
+ let nextText;
384
+ try {
385
+ nextText = `${originalText.slice(0, managedBlock.start)}${serializeCodexManagedBlock(refreshedEntry, originalText)}${originalText.slice(managedBlock.end)}`;
386
+ validateCodexCandidate(nextText);
387
+ }
388
+ catch (error) {
389
+ return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
390
+ }
391
+ if (dryRun) {
392
+ return result(definition, "would-refresh");
393
+ }
394
+ try {
395
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
396
+ return result(definition, "refreshed");
397
+ }
398
+ catch (error) {
399
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
400
+ }
401
+ }
402
+ if (managedBlock) {
403
+ return result(definition, "error", "Codex config contains a Prism-managed marker without its MCP entry");
404
+ }
405
+ let nextText;
406
+ try {
407
+ const currentText = originalText ?? "";
408
+ const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
409
+ const separator = currentText.length === 0
410
+ ? ""
411
+ : currentText.endsWith("\n") || currentText.endsWith("\r")
412
+ ? newline
413
+ : `${newline}${newline}`;
414
+ nextText = `${currentText}${separator}${serializeCodexManagedBlock(entry, currentText)}`;
415
+ validateCodexCandidate(nextText);
416
+ }
417
+ catch (error) {
418
+ return result(definition, "error", `could not safely extend Codex config without rewriting existing TOML: ${error instanceof Error ? error.message : String(error)}. Convert an inline mcp_servers value to standard TOML tables, then retry`);
419
+ }
420
+ if (dryRun) {
421
+ return result(definition, "would-register");
422
+ }
423
+ try {
424
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
425
+ return result(definition, "registered");
426
+ }
427
+ catch (error) {
428
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
429
+ }
430
+ }
431
+ function serializeCodexManagedBlock(entry, existingText) {
432
+ const newline = existingText.includes("\r\n") ? "\r\n" : "\n";
433
+ const serialized = stringifyToml({ mcp_servers: { "prism-mcp": entry } }).trimEnd();
434
+ return `${CODEX_MANAGED_START}\n${serialized}\n${CODEX_MANAGED_END}\n`.replaceAll("\n", newline);
435
+ }
436
+ function validateCodexCandidate(text) {
437
+ const parsed = parseToml(text);
438
+ if (!isJsonObject(parsed) || !isJsonObject(parsed.mcp_servers)) {
439
+ throw new Error("generated mcp_servers table is unavailable");
440
+ }
441
+ const entry = parsed.mcp_servers["prism-mcp"];
442
+ if (!isManagedPrismEntry(entry)) {
443
+ throw new Error("generated prism-mcp entry failed ownership validation");
444
+ }
445
+ }
446
+ function locateCodexManagedBlock(text) {
447
+ const starts = findExactLineRanges(text, CODEX_MANAGED_START);
448
+ const ends = findExactLineRanges(text, CODEX_MANAGED_END);
449
+ if (starts.length === 0 && ends.length === 0)
450
+ return undefined;
451
+ if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start) {
452
+ throw new Error("Codex config has malformed or duplicate Prism-managed markers; no changes made");
453
+ }
454
+ return { start: starts[0].start, end: ends[0].end };
455
+ }
456
+ function findExactLineRanges(text, expected) {
457
+ const ranges = [];
458
+ let start = 0;
459
+ while (start <= text.length) {
460
+ const newline = text.indexOf("\n", start);
461
+ const lineEnd = newline === -1 ? text.length : newline;
462
+ const contentEnd = lineEnd > start && text[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
463
+ if (text.slice(start, contentEnd) === expected) {
464
+ ranges.push({ start, end: newline === -1 ? text.length : newline + 1 });
465
+ }
466
+ if (newline === -1)
467
+ break;
468
+ start = newline + 1;
469
+ }
470
+ return ranges;
471
+ }
472
+ function writeTextAtomically(filePath, contents, expectedText, beforeCommit, symlinkPath) {
473
+ const directory = dirname(filePath);
474
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
475
+ const mode = existsSync(filePath) ? statSync(filePath).mode & 0o777 : 0o600;
476
+ const tempPath = join(directory, `.${basename(filePath)}.prism-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
477
+ try {
478
+ writeFileSync(tempPath, contents, {
479
+ encoding: "utf8",
480
+ flag: "wx",
481
+ mode,
482
+ });
483
+ beforeCommit?.(filePath);
484
+ if (symlinkPath) {
485
+ let currentTarget;
486
+ try {
487
+ currentTarget = realpathSync(symlinkPath);
488
+ if (currentTarget !== filePath || !statSync(currentTarget).isFile()) {
489
+ throw new Error("target changed");
490
+ }
491
+ }
492
+ catch (error) {
493
+ throw new Error(`Config symlink changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
494
+ }
495
+ }
496
+ if (expectedText === undefined) {
497
+ if (existsSync(filePath)) {
498
+ throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
499
+ }
500
+ }
501
+ else {
502
+ let currentText;
503
+ try {
504
+ currentText = readFileSync(filePath, "utf8");
505
+ }
506
+ catch (error) {
507
+ throw new Error(`Config changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
508
+ }
509
+ if (currentText !== expectedText) {
510
+ throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
511
+ }
512
+ }
513
+ // This compare-before-rename catches observable host edits and the rename
514
+ // itself is atomic. No portable filesystem API can make the comparison and
515
+ // replacement one operation against an uncooperative host, which is why the
516
+ // CLI also tells users to close target hosts before writing.
517
+ renameSync(tempPath, filePath);
518
+ }
519
+ finally {
520
+ if (existsSync(tempPath))
521
+ unlinkSync(tempPath);
522
+ }
523
+ }
524
+ function result(definition, status, message) {
525
+ return {
526
+ host: definition.name,
527
+ label: definition.label,
528
+ path: definition.configPath,
529
+ status,
530
+ message,
531
+ };
532
+ }
533
+ function isJsonObject(value) {
534
+ return typeof value === "object" && value !== null && !Array.isArray(value);
535
+ }
536
+ function isManagedPrismEntry(value) {
537
+ return isJsonObject(value)
538
+ && isJsonObject(value.env)
539
+ && value.env.PRISM_INSTANCE === "prism-mcp";
540
+ }
541
+ function refreshManagedEntry(existing, desired) {
542
+ const existingEnv = isJsonObject(existing.env) ? existing.env : {};
543
+ const desiredEnv = isJsonObject(desired.env) ? desired.env : {};
544
+ const mergedEnv = {
545
+ ...existingEnv,
546
+ ...desiredEnv,
547
+ };
548
+ if (!Object.prototype.hasOwnProperty.call(desiredEnv, "PRISM_SYNALUX_API_KEY")) {
549
+ delete mergedEnv.PRISM_SYNALUX_API_KEY;
550
+ }
551
+ return {
552
+ ...existing,
553
+ command: desired.command,
554
+ args: desired.args,
555
+ env: mergedEnv,
556
+ };
557
+ }
558
+ function isErrno(error, code) {
559
+ return error instanceof Error && error.code === code;
560
+ }
@@ -94,41 +94,18 @@ session_save_ledger({
94
94
  };
95
95
  }
96
96
  function getIDEConfigContent(client = "claude_desktop") {
97
- const configs = {
98
- claude_desktop: `{
99
- "mcpServers": {
100
- "prism-mcp": {
101
- "command": "npx",
102
- "args": ["-y", "prism-mcp@latest"],
103
- "env": {
104
- "BRAVE_API_KEY": "your-brave-key"
105
- }
106
- }
107
- }
108
- }`,
109
- cursor: `{
110
- "mcpServers": {
111
- "prism-mcp": {
112
- "command": "npx",
113
- "args": ["-y", "prism-mcp@latest"],
114
- "env": {
115
- "BRAVE_API_KEY": "your-brave-key"
116
- }
117
- }
118
- }
119
- }`,
120
- };
97
+ const host = client === "cursor" ? "cursor" : "claude-desktop";
121
98
  return {
122
99
  step: "ide_config",
123
100
  title: "⚙️ Configure Your IDE",
124
- description: "Add Prism as an MCP server in your AI coding tool's configuration.",
101
+ description: "Register Prism as an MCP server in your AI coding tool.",
125
102
  instructions: [
126
- "Copy the config below into your IDE's MCP configuration file",
127
- "For Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json",
128
- "For Cursor: .cursor/mcp.json in your workspace",
103
+ "Run the command below in a terminal",
104
+ "Prism adds only a missing registration and never overwrites custom entries",
105
+ "Use --dry-run first if you want to preview the target file",
129
106
  "Restart your IDE to pick up the new MCP server",
130
107
  ],
131
- codeSnippet: configs[client] || configs.claude_desktop,
108
+ codeSnippet: `prism connect --host ${host}`,
132
109
  nextStep: "first_save",
133
110
  progress: 43,
134
111
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.1.0",
3
+ "version": "20.2.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, 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",
@@ -65,6 +65,7 @@
65
65
  "rag",
66
66
  "embeddings",
67
67
  "cursor",
68
+ "codex",
68
69
  "windsurf",
69
70
  "cline",
70
71
  "persistent-memory",
@@ -144,6 +145,7 @@
144
145
  "p-limit": "^7.3.0",
145
146
  "postcss": "8.5.12",
146
147
  "quickjs-emscripten": "^0.32.0",
148
+ "smol-toml": "^1.7.0",
147
149
  "stream-json": "^2.0.0",
148
150
  "turndown": "^7.2.2",
149
151
  "zod": "^4.3.6"
@@ -1,203 +0,0 @@
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: qwen3.5:4b is the default verifier (fast, 2.5GB).
13
- * 14b drafts; 4b verifies. Different model = Patronus rule satisfied.
14
- * Falls back to 2b on devices with <4GB free RAM.
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 ?? "qwen3.5:4b";
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
- }