prism-mcp-server 20.1.0 → 20.2.1
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 +51 -13
- package/dist/cli.js +62 -0
- package/dist/config.js +2 -1
- package/dist/connect.js +565 -0
- package/dist/onboarding/wizard.js +6 -29
- package/dist/storage/index.js +11 -1
- package/dist/storage/inferMetricsLedger.js +17 -2
- package/dist/storage/sqlite.js +1 -1
- package/dist/utils/entitlements.js +6 -2
- package/dist/utils/synaluxJwt.js +7 -3
- package/package.json +6 -2
- package/dist/utils/groundingVerifier.js +0 -203
package/README.md
CHANGED
|
@@ -18,6 +18,28 @@ 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.1
|
|
22
|
+
|
|
23
|
+
### Subscription-Aware Memory Storage
|
|
24
|
+
`prism connect` now carries an explicit `PRISM_STORAGE=auto|local|synalux|supabase`
|
|
25
|
+
into every managed host registration and rejects invalid values before changing a
|
|
26
|
+
config file. In `auto`, a portal-confirmed free tier uses local SQLite, while
|
|
27
|
+
Standard, Advanced, and Enterprise use Synalux cloud memory. If entitlement
|
|
28
|
+
resolution is unavailable, Prism fails closed instead of splitting history across
|
|
29
|
+
backends. Storage remains independent of local-first model routing.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## What's New in v20.2.0
|
|
34
|
+
|
|
35
|
+
### One Command Connects Every Supported Host
|
|
36
|
+
Install Prism globally and run `prism connect`. It detects Claude Code, Claude
|
|
37
|
+
Desktop on macOS, Windows, and Linux (beta), Cursor, Gemini CLI, and Codex, then safely registers the
|
|
38
|
+
server from the installed package. Existing custom entries are untouched;
|
|
39
|
+
`--dry-run` previews changes and `--refresh` updates only Prism-managed entries.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
21
43
|
## What's New in v20.1.0
|
|
22
44
|
|
|
23
45
|
### Every Inference Outcome Is Now Observable
|
|
@@ -97,20 +119,36 @@ External contributions now require signing the [Individual CLA](./CLA.md). The C
|
|
|
97
119
|
|
|
98
120
|
## Quickstart
|
|
99
121
|
|
|
100
|
-
The free tier needs no account, no API key, and no cloud.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"command": "npx",
|
|
107
|
-
"args": ["-y", "prism-mcp-server"]
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
122
|
+
The free tier needs no account, no API key, and no cloud. Install Prism, then
|
|
123
|
+
register it with every supported MCP host already installed on your machine:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm install --global prism-mcp-server
|
|
127
|
+
prism connect
|
|
111
128
|
```
|
|
112
129
|
|
|
113
|
-
|
|
130
|
+
`prism connect` detects Claude Code, Claude Desktop (macOS/Windows/Linux), Cursor,
|
|
131
|
+
Gemini CLI, and Codex.
|
|
132
|
+
Use `prism connect --all` to target all five, `--host <name>` for one host, or
|
|
133
|
+
`--dry-run` to preview the files that would change. Existing `prism` and
|
|
134
|
+
`prism-mcp` entries are never overwritten by default. `--refresh` updates only
|
|
135
|
+
an entry previously created by Prism; custom entries remain untouched.
|
|
136
|
+
Close the target MCP hosts before a non-dry-run registration so they cannot
|
|
137
|
+
edit their configuration at the same time.
|
|
138
|
+
|
|
139
|
+
Set `PRISM_STORAGE` before running `prism connect` to preserve an explicit
|
|
140
|
+
storage choice in the generated host entries. This does not change local-model
|
|
141
|
+
routing; Synalux cloud storage separately requires an active cloud-memory
|
|
142
|
+
entitlement.
|
|
143
|
+
|
|
144
|
+
Codex registration preserves `~/.codex/config.toml` and appends only a marked
|
|
145
|
+
Prism-managed block. `CODEX_HOME` is respected when set and must already exist,
|
|
146
|
+
matching Codex's own contract. Restart Codex CLI, the
|
|
147
|
+
IDE extension, or the ChatGPT desktop app after connecting.
|
|
148
|
+
|
|
149
|
+
Restart the connected host and your agent now has memory backed by a local
|
|
150
|
+
SQLite database (`~/.prism-mcp/data.db`). See [IDE setup](docs/IDE_SETUP.md)
|
|
151
|
+
for manual configuration and host-specific paths.
|
|
114
152
|
|
|
115
153
|
**Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
|
|
116
154
|
|
|
@@ -671,7 +709,7 @@ Routing is automatic: `9b → 4b → cloud fallback` on desktop/server, `2b →
|
|
|
671
709
|
| `PRISM_FORCE_LOCAL` | Force local SQLite regardless of credentials | `false` |
|
|
672
710
|
| `TELEMETRY_WRITE_TOKEN` | Portal analytics token (optional — metrics display works without it) | -- |
|
|
673
711
|
|
|
674
|
-
With no variables set, Prism runs fully local.
|
|
712
|
+
With no variables set, Prism runs fully local. With an active cloud-memory subscription, set `PRISM_SYNALUX_API_KEY` (and leave `PRISM_STORAGE=auto`) to use the Synalux backend; a portal-confirmed free tier remains on local SQLite.
|
|
675
713
|
|
|
676
714
|
---
|
|
677
715
|
|
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; no Synalux subscription key was copied into new registrations.');
|
|
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.
|
package/dist/config.js
CHANGED
|
@@ -81,7 +81,8 @@ export const GOOGLE_SEARCH_CX = process.env.GOOGLE_SEARCH_CX;
|
|
|
81
81
|
//
|
|
82
82
|
// Auto-resolution (PRISM_STORAGE=auto, the default) picks in this order:
|
|
83
83
|
// 1. PRISM_FORCE_LOCAL=true → "local" (override everything)
|
|
84
|
-
// 2.
|
|
84
|
+
// 2. Synalux credentials + portal-confirmed cloud-memory entitlement → "synalux"
|
|
85
|
+
// (portal-confirmed free → "local"; unverifiable entitlement → fail closed)
|
|
85
86
|
// 3. SUPABASE_URL + SUPABASE_KEY set → "supabase" (legacy)
|
|
86
87
|
// 4. else → "local"
|
|
87
88
|
export const PRISM_STORAGE = process.env.PRISM_STORAGE || "auto";
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
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
|
+
const CONNECT_STORAGE_BACKENDS = ["auto", "local", "synalux", "supabase"];
|
|
29
|
+
export function normalizeHostName(value) {
|
|
30
|
+
const normalized = value.trim().toLowerCase();
|
|
31
|
+
const host = HOST_ALIASES[normalized];
|
|
32
|
+
if (!host) {
|
|
33
|
+
throw new Error(`Unsupported host "${value}". Choose one of: ${CONNECT_HOSTS.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
return host;
|
|
36
|
+
}
|
|
37
|
+
export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
|
|
38
|
+
const manifestPath = fileURLToPath(new URL("../package.json", moduleUrl));
|
|
39
|
+
let manifest;
|
|
40
|
+
try {
|
|
41
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new Error(`Cannot read Prism package manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!isJsonObject(manifest) || typeof manifest.main !== "string" || !manifest.main.trim()) {
|
|
47
|
+
throw new Error(`Prism package manifest has no valid "main" entry: ${manifestPath}`);
|
|
48
|
+
}
|
|
49
|
+
const serverPath = resolve(dirname(manifestPath), manifest.main);
|
|
50
|
+
if (!existsSync(serverPath)) {
|
|
51
|
+
throw new Error(`Prism server entrypoint was not found: ${serverPath}. Run npm run build first.`);
|
|
52
|
+
}
|
|
53
|
+
return realpathSync(serverPath);
|
|
54
|
+
}
|
|
55
|
+
export function connectHosts(options = {}) {
|
|
56
|
+
if (options.all && options.hosts?.length) {
|
|
57
|
+
throw new Error("Use either --all or --host, not both.");
|
|
58
|
+
}
|
|
59
|
+
const homeDir = options.homeDir ?? homedir();
|
|
60
|
+
const platform = options.platform ?? process.platform;
|
|
61
|
+
const env = options.env ?? process.env;
|
|
62
|
+
const serverPath = options.serverPath ?? resolveInstalledServerPath();
|
|
63
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
64
|
+
const definitions = getHostDefinitions(homeDir, platform, env, options.homeDir === undefined);
|
|
65
|
+
const pathEnv = options.pathEnv ?? env.PATH ?? "";
|
|
66
|
+
let selected;
|
|
67
|
+
if (options.hosts?.length) {
|
|
68
|
+
const requested = new Set(options.hosts);
|
|
69
|
+
selected = definitions.filter((definition) => requested.has(definition.name));
|
|
70
|
+
const unavailable = options.hosts.filter((host) => !selected.some((definition) => definition.name === host));
|
|
71
|
+
if (unavailable.length > 0) {
|
|
72
|
+
throw new Error(`${unavailable.join(", ")} is not supported on ${platform}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (options.all) {
|
|
76
|
+
selected = definitions;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
selected = definitions.filter((definition) => isHostDetected(definition, platform, pathEnv));
|
|
80
|
+
}
|
|
81
|
+
const entry = buildMcpEntry(nodePath, serverPath, env);
|
|
82
|
+
const results = selected.map((definition) => registerHost(definition, entry, !!options.dryRun, !!options.refresh, options.beforeCommit));
|
|
83
|
+
return {
|
|
84
|
+
results,
|
|
85
|
+
usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
|
|
86
|
+
serverPath,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
|
|
90
|
+
const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
|
|
91
|
+
const cursorDetectionPaths = [join(homeDir, ".cursor")];
|
|
92
|
+
const desktopDetectionPaths = claudeDesktop ? [dirname(claudeDesktop)] : [];
|
|
93
|
+
const configuredCodexHome = includeSystemPaths && env.CODEX_HOME?.trim()
|
|
94
|
+
? resolve(env.CODEX_HOME.trim())
|
|
95
|
+
: undefined;
|
|
96
|
+
const codexHome = configuredCodexHome ?? join(homeDir, ".codex");
|
|
97
|
+
const codexDetectionPaths = [codexHome];
|
|
98
|
+
let codexConfigurationError;
|
|
99
|
+
if (configuredCodexHome) {
|
|
100
|
+
try {
|
|
101
|
+
if (!statSync(configuredCodexHome).isDirectory()) {
|
|
102
|
+
codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome} (${error instanceof Error ? error.message : String(error)})`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (platform === "darwin") {
|
|
110
|
+
desktopDetectionPaths.push(join(homeDir, "Applications", "Claude.app"));
|
|
111
|
+
cursorDetectionPaths.push(join(homeDir, "Applications", "Cursor.app"));
|
|
112
|
+
codexDetectionPaths.push(join(homeDir, "Applications", "ChatGPT.app"));
|
|
113
|
+
if (includeSystemPaths) {
|
|
114
|
+
desktopDetectionPaths.push("/Applications/Claude.app");
|
|
115
|
+
cursorDetectionPaths.push("/Applications/Cursor.app");
|
|
116
|
+
codexDetectionPaths.push("/Applications/ChatGPT.app");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (platform === "win32") {
|
|
120
|
+
const localAppData = env.LOCALAPPDATA || join(homeDir, "AppData", "Local");
|
|
121
|
+
desktopDetectionPaths.push(join(localAppData, "Programs", "Claude", "Claude.exe"), join(localAppData, "AnthropicClaude", "Claude.exe"));
|
|
122
|
+
cursorDetectionPaths.push(join(localAppData, "Programs", "cursor", "Cursor.exe"), join(localAppData, "Cursor", "Cursor.exe"));
|
|
123
|
+
codexDetectionPaths.push(join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe"), join(localAppData, "Microsoft", "WindowsApps", "ChatGPT.exe"));
|
|
124
|
+
}
|
|
125
|
+
const definitions = [
|
|
126
|
+
{
|
|
127
|
+
name: "claude-code",
|
|
128
|
+
label: "Claude Code",
|
|
129
|
+
format: "json",
|
|
130
|
+
configPath: join(homeDir, ".claude.json"),
|
|
131
|
+
detectionPaths: [join(homeDir, ".claude.json"), join(homeDir, ".claude")],
|
|
132
|
+
executables: ["claude"],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
if (claudeDesktop) {
|
|
136
|
+
definitions.push({
|
|
137
|
+
name: "claude-desktop",
|
|
138
|
+
label: "Claude Desktop",
|
|
139
|
+
format: "json",
|
|
140
|
+
configPath: claudeDesktop,
|
|
141
|
+
detectionPaths: desktopDetectionPaths,
|
|
142
|
+
executables: ["claude-desktop"],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
definitions.push({
|
|
146
|
+
name: "cursor",
|
|
147
|
+
label: "Cursor",
|
|
148
|
+
format: "json",
|
|
149
|
+
configPath: join(homeDir, ".cursor", "mcp.json"),
|
|
150
|
+
detectionPaths: cursorDetectionPaths,
|
|
151
|
+
executables: ["cursor"],
|
|
152
|
+
}, {
|
|
153
|
+
name: "gemini",
|
|
154
|
+
label: "Gemini CLI",
|
|
155
|
+
format: "json",
|
|
156
|
+
configPath: join(homeDir, ".gemini", "settings.json"),
|
|
157
|
+
detectionPaths: [join(homeDir, ".gemini")],
|
|
158
|
+
executables: ["gemini"],
|
|
159
|
+
}, {
|
|
160
|
+
name: "codex",
|
|
161
|
+
label: "Codex",
|
|
162
|
+
format: "codex-toml",
|
|
163
|
+
configPath: join(codexHome, "config.toml"),
|
|
164
|
+
detectionPaths: codexDetectionPaths,
|
|
165
|
+
executables: ["codex"],
|
|
166
|
+
configurationError: codexConfigurationError,
|
|
167
|
+
});
|
|
168
|
+
return definitions;
|
|
169
|
+
}
|
|
170
|
+
function claudeDesktopPath(homeDir, platform, env) {
|
|
171
|
+
if (platform === "darwin") {
|
|
172
|
+
return join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
173
|
+
}
|
|
174
|
+
if (platform === "win32") {
|
|
175
|
+
const appData = env.APPDATA || join(homeDir, "AppData", "Roaming");
|
|
176
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
177
|
+
}
|
|
178
|
+
if (platform === "linux") {
|
|
179
|
+
const configHome = env.XDG_CONFIG_HOME || join(homeDir, ".config");
|
|
180
|
+
return join(configHome, "Claude", "claude_desktop_config.json");
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function isHostDetected(definition, platform, pathEnv) {
|
|
185
|
+
return definition.configurationError !== undefined
|
|
186
|
+
|| definition.detectionPaths.some(existsSync)
|
|
187
|
+
|| definition.executables.some((executable) => executableExists(executable, platform, pathEnv));
|
|
188
|
+
}
|
|
189
|
+
function executableExists(name, platform, pathEnv) {
|
|
190
|
+
if (!pathEnv)
|
|
191
|
+
return false;
|
|
192
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
193
|
+
const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
194
|
+
for (const directory of pathEnv.split(separator).filter(Boolean)) {
|
|
195
|
+
for (const extension of extensions) {
|
|
196
|
+
const candidate = join(directory, `${name}${extension}`);
|
|
197
|
+
try {
|
|
198
|
+
if (!statSync(candidate).isFile())
|
|
199
|
+
continue;
|
|
200
|
+
if (platform !== "win32")
|
|
201
|
+
accessSync(candidate, constants.X_OK);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Keep searching PATH entries when a candidate is absent or not executable.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
function buildMcpEntry(nodePath, serverPath, env) {
|
|
212
|
+
const storage = env.PRISM_STORAGE ?? "auto";
|
|
213
|
+
if (!CONNECT_STORAGE_BACKENDS.includes(storage)) {
|
|
214
|
+
throw new Error(`Invalid PRISM_STORAGE "${storage}". Choose one of: ${CONNECT_STORAGE_BACKENDS.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
const serverEnv = {
|
|
217
|
+
PRISM_INSTANCE: "prism-mcp",
|
|
218
|
+
PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
|
|
219
|
+
PRISM_STORAGE: storage,
|
|
220
|
+
};
|
|
221
|
+
if (env.PRISM_SYNALUX_API_KEY) {
|
|
222
|
+
serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
command: nodePath,
|
|
226
|
+
args: [serverPath],
|
|
227
|
+
env: serverEnv,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function registerHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
231
|
+
if (definition.configurationError) {
|
|
232
|
+
return result(definition, "error", definition.configurationError);
|
|
233
|
+
}
|
|
234
|
+
if (definition.format === "codex-toml") {
|
|
235
|
+
return registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit);
|
|
236
|
+
}
|
|
237
|
+
return registerJsonHost(definition, entry, dryRun, refresh, beforeCommit);
|
|
238
|
+
}
|
|
239
|
+
function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
240
|
+
const { configPath } = definition;
|
|
241
|
+
let config = {};
|
|
242
|
+
let originalText;
|
|
243
|
+
let writePath = configPath;
|
|
244
|
+
let symlinkPath;
|
|
245
|
+
try {
|
|
246
|
+
const pathInfo = lstatSync(configPath);
|
|
247
|
+
if (pathInfo.isSymbolicLink()) {
|
|
248
|
+
try {
|
|
249
|
+
writePath = realpathSync(configPath);
|
|
250
|
+
symlinkPath = configPath;
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
originalText = readFileSync(writePath, "utf8");
|
|
258
|
+
const parsed = JSON.parse(originalText);
|
|
259
|
+
if (!isJsonObject(parsed)) {
|
|
260
|
+
throw new Error("top-level JSON value must be an object");
|
|
261
|
+
}
|
|
262
|
+
config = parsed;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (!isErrno(error, "ENOENT")) {
|
|
270
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const currentServers = config.mcpServers;
|
|
274
|
+
if (currentServers !== undefined && !isJsonObject(currentServers)) {
|
|
275
|
+
return result(definition, "error", '"mcpServers" must be a JSON object');
|
|
276
|
+
}
|
|
277
|
+
const mcpServers = (currentServers ?? {});
|
|
278
|
+
const existingKey = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp")
|
|
279
|
+
? "prism-mcp"
|
|
280
|
+
: Object.prototype.hasOwnProperty.call(mcpServers, "prism")
|
|
281
|
+
? "prism"
|
|
282
|
+
: undefined;
|
|
283
|
+
if (existingKey) {
|
|
284
|
+
const existingEntry = mcpServers[existingKey];
|
|
285
|
+
if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
|
|
286
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
287
|
+
}
|
|
288
|
+
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
289
|
+
if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
|
|
290
|
+
return result(definition, "existing", "Prism-managed entry is already current");
|
|
291
|
+
}
|
|
292
|
+
if (dryRun) {
|
|
293
|
+
return result(definition, "would-refresh");
|
|
294
|
+
}
|
|
295
|
+
mcpServers[existingKey] = refreshedEntry;
|
|
296
|
+
config.mcpServers = mcpServers;
|
|
297
|
+
try {
|
|
298
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
299
|
+
return result(definition, "refreshed");
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (dryRun) {
|
|
306
|
+
return result(definition, "would-register");
|
|
307
|
+
}
|
|
308
|
+
mcpServers["prism-mcp"] = entry;
|
|
309
|
+
config.mcpServers = mcpServers;
|
|
310
|
+
try {
|
|
311
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
312
|
+
return result(definition, "registered");
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
319
|
+
const { configPath } = definition;
|
|
320
|
+
let config = {};
|
|
321
|
+
let originalText;
|
|
322
|
+
let writePath = configPath;
|
|
323
|
+
let symlinkPath;
|
|
324
|
+
try {
|
|
325
|
+
const pathInfo = lstatSync(configPath);
|
|
326
|
+
if (pathInfo.isSymbolicLink()) {
|
|
327
|
+
try {
|
|
328
|
+
writePath = realpathSync(configPath);
|
|
329
|
+
symlinkPath = configPath;
|
|
330
|
+
if (!statSync(writePath).isFile()) {
|
|
331
|
+
throw new Error("config symlink target is not a regular file");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
originalText = readFileSync(writePath, "utf8");
|
|
340
|
+
const parsed = parseToml(originalText);
|
|
341
|
+
if (!isJsonObject(parsed)) {
|
|
342
|
+
throw new Error("top-level TOML value must be a table");
|
|
343
|
+
}
|
|
344
|
+
config = parsed;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (!isErrno(error, "ENOENT")) {
|
|
352
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
let managedBlock;
|
|
356
|
+
try {
|
|
357
|
+
managedBlock = locateCodexManagedBlock(originalText ?? "");
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
361
|
+
}
|
|
362
|
+
const currentServers = config.mcp_servers;
|
|
363
|
+
if (currentServers !== undefined && !isJsonObject(currentServers)) {
|
|
364
|
+
return result(definition, "error", '"mcp_servers" must be a TOML table');
|
|
365
|
+
}
|
|
366
|
+
const mcpServers = (currentServers ?? {});
|
|
367
|
+
const hasCanonical = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp");
|
|
368
|
+
const hasLegacy = Object.prototype.hasOwnProperty.call(mcpServers, "prism");
|
|
369
|
+
if (hasCanonical && hasLegacy) {
|
|
370
|
+
return result(definition, "error", "Codex config contains both prism and prism-mcp entries; resolve the duplicate before retrying");
|
|
371
|
+
}
|
|
372
|
+
if (hasCanonical || hasLegacy) {
|
|
373
|
+
const existingKey = hasCanonical ? "prism-mcp" : "prism";
|
|
374
|
+
const existingEntry = mcpServers[existingKey];
|
|
375
|
+
if (!refresh || existingKey !== "prism-mcp") {
|
|
376
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
377
|
+
}
|
|
378
|
+
if (!managedBlock) {
|
|
379
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
380
|
+
}
|
|
381
|
+
if (!isManagedPrismEntry(existingEntry)) {
|
|
382
|
+
return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
|
|
383
|
+
}
|
|
384
|
+
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
385
|
+
if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
|
|
386
|
+
return result(definition, "existing", "Prism-managed entry is already current");
|
|
387
|
+
}
|
|
388
|
+
let nextText;
|
|
389
|
+
try {
|
|
390
|
+
nextText = `${originalText.slice(0, managedBlock.start)}${serializeCodexManagedBlock(refreshedEntry, originalText)}${originalText.slice(managedBlock.end)}`;
|
|
391
|
+
validateCodexCandidate(nextText);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
|
+
}
|
|
396
|
+
if (dryRun) {
|
|
397
|
+
return result(definition, "would-refresh");
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
401
|
+
return result(definition, "refreshed");
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (managedBlock) {
|
|
408
|
+
return result(definition, "error", "Codex config contains a Prism-managed marker without its MCP entry");
|
|
409
|
+
}
|
|
410
|
+
let nextText;
|
|
411
|
+
try {
|
|
412
|
+
const currentText = originalText ?? "";
|
|
413
|
+
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
414
|
+
const separator = currentText.length === 0
|
|
415
|
+
? ""
|
|
416
|
+
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
417
|
+
? newline
|
|
418
|
+
: `${newline}${newline}`;
|
|
419
|
+
nextText = `${currentText}${separator}${serializeCodexManagedBlock(entry, currentText)}`;
|
|
420
|
+
validateCodexCandidate(nextText);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
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`);
|
|
424
|
+
}
|
|
425
|
+
if (dryRun) {
|
|
426
|
+
return result(definition, "would-register");
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
430
|
+
return result(definition, "registered");
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function serializeCodexManagedBlock(entry, existingText) {
|
|
437
|
+
const newline = existingText.includes("\r\n") ? "\r\n" : "\n";
|
|
438
|
+
const serialized = stringifyToml({ mcp_servers: { "prism-mcp": entry } }).trimEnd();
|
|
439
|
+
return `${CODEX_MANAGED_START}\n${serialized}\n${CODEX_MANAGED_END}\n`.replaceAll("\n", newline);
|
|
440
|
+
}
|
|
441
|
+
function validateCodexCandidate(text) {
|
|
442
|
+
const parsed = parseToml(text);
|
|
443
|
+
if (!isJsonObject(parsed) || !isJsonObject(parsed.mcp_servers)) {
|
|
444
|
+
throw new Error("generated mcp_servers table is unavailable");
|
|
445
|
+
}
|
|
446
|
+
const entry = parsed.mcp_servers["prism-mcp"];
|
|
447
|
+
if (!isManagedPrismEntry(entry)) {
|
|
448
|
+
throw new Error("generated prism-mcp entry failed ownership validation");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function locateCodexManagedBlock(text) {
|
|
452
|
+
const starts = findExactLineRanges(text, CODEX_MANAGED_START);
|
|
453
|
+
const ends = findExactLineRanges(text, CODEX_MANAGED_END);
|
|
454
|
+
if (starts.length === 0 && ends.length === 0)
|
|
455
|
+
return undefined;
|
|
456
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start) {
|
|
457
|
+
throw new Error("Codex config has malformed or duplicate Prism-managed markers; no changes made");
|
|
458
|
+
}
|
|
459
|
+
return { start: starts[0].start, end: ends[0].end };
|
|
460
|
+
}
|
|
461
|
+
function findExactLineRanges(text, expected) {
|
|
462
|
+
const ranges = [];
|
|
463
|
+
let start = 0;
|
|
464
|
+
while (start <= text.length) {
|
|
465
|
+
const newline = text.indexOf("\n", start);
|
|
466
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
467
|
+
const contentEnd = lineEnd > start && text[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
468
|
+
if (text.slice(start, contentEnd) === expected) {
|
|
469
|
+
ranges.push({ start, end: newline === -1 ? text.length : newline + 1 });
|
|
470
|
+
}
|
|
471
|
+
if (newline === -1)
|
|
472
|
+
break;
|
|
473
|
+
start = newline + 1;
|
|
474
|
+
}
|
|
475
|
+
return ranges;
|
|
476
|
+
}
|
|
477
|
+
function writeTextAtomically(filePath, contents, expectedText, beforeCommit, symlinkPath) {
|
|
478
|
+
const directory = dirname(filePath);
|
|
479
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
480
|
+
const mode = existsSync(filePath) ? statSync(filePath).mode & 0o777 : 0o600;
|
|
481
|
+
const tempPath = join(directory, `.${basename(filePath)}.prism-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
482
|
+
try {
|
|
483
|
+
writeFileSync(tempPath, contents, {
|
|
484
|
+
encoding: "utf8",
|
|
485
|
+
flag: "wx",
|
|
486
|
+
mode,
|
|
487
|
+
});
|
|
488
|
+
beforeCommit?.(filePath);
|
|
489
|
+
if (symlinkPath) {
|
|
490
|
+
let currentTarget;
|
|
491
|
+
try {
|
|
492
|
+
currentTarget = realpathSync(symlinkPath);
|
|
493
|
+
if (currentTarget !== filePath || !statSync(currentTarget).isFile()) {
|
|
494
|
+
throw new Error("target changed");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
throw new Error(`Config symlink changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (expectedText === undefined) {
|
|
502
|
+
if (existsSync(filePath)) {
|
|
503
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
let currentText;
|
|
508
|
+
try {
|
|
509
|
+
currentText = readFileSync(filePath, "utf8");
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
|
|
513
|
+
}
|
|
514
|
+
if (currentText !== expectedText) {
|
|
515
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// This compare-before-rename catches observable host edits and the rename
|
|
519
|
+
// itself is atomic. No portable filesystem API can make the comparison and
|
|
520
|
+
// replacement one operation against an uncooperative host, which is why the
|
|
521
|
+
// CLI also tells users to close target hosts before writing.
|
|
522
|
+
renameSync(tempPath, filePath);
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
if (existsSync(tempPath))
|
|
526
|
+
unlinkSync(tempPath);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function result(definition, status, message) {
|
|
530
|
+
return {
|
|
531
|
+
host: definition.name,
|
|
532
|
+
label: definition.label,
|
|
533
|
+
path: definition.configPath,
|
|
534
|
+
status,
|
|
535
|
+
message,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function isJsonObject(value) {
|
|
539
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
540
|
+
}
|
|
541
|
+
function isManagedPrismEntry(value) {
|
|
542
|
+
return isJsonObject(value)
|
|
543
|
+
&& isJsonObject(value.env)
|
|
544
|
+
&& value.env.PRISM_INSTANCE === "prism-mcp";
|
|
545
|
+
}
|
|
546
|
+
function refreshManagedEntry(existing, desired) {
|
|
547
|
+
const existingEnv = isJsonObject(existing.env) ? existing.env : {};
|
|
548
|
+
const desiredEnv = isJsonObject(desired.env) ? desired.env : {};
|
|
549
|
+
const mergedEnv = {
|
|
550
|
+
...existingEnv,
|
|
551
|
+
...desiredEnv,
|
|
552
|
+
};
|
|
553
|
+
if (!Object.prototype.hasOwnProperty.call(desiredEnv, "PRISM_SYNALUX_API_KEY")) {
|
|
554
|
+
delete mergedEnv.PRISM_SYNALUX_API_KEY;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
...existing,
|
|
558
|
+
command: desired.command,
|
|
559
|
+
args: desired.args,
|
|
560
|
+
env: mergedEnv,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function isErrno(error, code) {
|
|
564
|
+
return error instanceof Error && error.code === code;
|
|
565
|
+
}
|
|
@@ -94,41 +94,18 @@ session_save_ledger({
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
function getIDEConfigContent(client = "claude_desktop") {
|
|
97
|
-
const
|
|
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: "
|
|
101
|
+
description: "Register Prism as an MCP server in your AI coding tool.",
|
|
125
102
|
instructions: [
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
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:
|
|
108
|
+
codeSnippet: `prism connect --host ${host}`,
|
|
132
109
|
nextStep: "first_save",
|
|
133
110
|
progress: 43,
|
|
134
111
|
};
|
package/dist/storage/index.js
CHANGED
|
@@ -68,9 +68,19 @@ export async function getStorage() {
|
|
|
68
68
|
debugLog("[Prism Storage] PRISM_FORCE_LOCAL=true — forcing local SQLite");
|
|
69
69
|
}
|
|
70
70
|
// ─── Resolve "auto" → synalux > supabase > local ─────────────
|
|
71
|
+
// Synalux is eligible only when the portal is the entitlement source;
|
|
72
|
+
// direct Supabase remains an independent legacy backend.
|
|
71
73
|
if (requested === "auto") {
|
|
72
74
|
if (await ensureSynaluxCredentials()) {
|
|
73
|
-
|
|
75
|
+
const { getEntitlements } = await import("../utils/entitlements.js");
|
|
76
|
+
const entitlements = await getEntitlements();
|
|
77
|
+
const memoryEntitled = entitlements.features?.session_memory_unlimited;
|
|
78
|
+
if (entitlements.source !== "portal" || typeof memoryEntitled !== "boolean") {
|
|
79
|
+
throw new Error("[Prism Storage] Could not verify the Synalux cloud-memory entitlement. " +
|
|
80
|
+
"Refusing to fall back to another backend because that could split session history. " +
|
|
81
|
+
"Retry when Synalux is reachable or set PRISM_STORAGE=local explicitly.");
|
|
82
|
+
}
|
|
83
|
+
requested = memoryEntitled ? "synalux" : "local";
|
|
74
84
|
}
|
|
75
85
|
else if (await ensureSupabaseCredentials()) {
|
|
76
86
|
requested = "supabase";
|
|
@@ -38,6 +38,18 @@ let ensured = null;
|
|
|
38
38
|
let disabled = false;
|
|
39
39
|
let initFailures = 0;
|
|
40
40
|
const MAX_INIT_FAILURES = 3;
|
|
41
|
+
function closeClient(context) {
|
|
42
|
+
const activeClient = client;
|
|
43
|
+
client = null;
|
|
44
|
+
if (!activeClient)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
activeClient.close();
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
debugLog(`[infer-ledger] ${context} close failed: ${e instanceof Error ? e.message : e}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
41
53
|
function ensureTable() {
|
|
42
54
|
if (!ensured) {
|
|
43
55
|
ensured = (async () => {
|
|
@@ -72,7 +84,7 @@ function ensureTable() {
|
|
|
72
84
|
// the next append; only repeated failure disables for the process.
|
|
73
85
|
initFailures++;
|
|
74
86
|
ensured = null;
|
|
75
|
-
|
|
87
|
+
closeClient("init failure");
|
|
76
88
|
if (initFailures >= MAX_INIT_FAILURES)
|
|
77
89
|
disabled = true;
|
|
78
90
|
debugLog(`[infer-ledger] init failed (${initFailures}/${MAX_INIT_FAILURES}${disabled ? ", ledger disabled" : ", will retry"}): ${e instanceof Error ? e.message : e}`);
|
|
@@ -152,7 +164,10 @@ export async function queryInferMetrics(sinceTs) {
|
|
|
152
164
|
}
|
|
153
165
|
/** Test hook — reset module state so a fresh DB path/env can be exercised. */
|
|
154
166
|
export function _resetInferLedgerForTest() {
|
|
155
|
-
client
|
|
167
|
+
// Close the logical client instead of only dropping its reference.
|
|
168
|
+
// libsql 0.5.29 can still retain native prepared-statement handles until
|
|
169
|
+
// V8 GC (libsql-js#228), so this is not a synchronous file-unlock barrier.
|
|
170
|
+
closeClient("test reset");
|
|
156
171
|
ensured = null;
|
|
157
172
|
disabled = false;
|
|
158
173
|
initFailures = 0;
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite Local Storage Backend (v2.0 — Step 2)
|
|
3
3
|
*
|
|
4
|
-
* Zero-cloud
|
|
4
|
+
* Zero-cloud local SQLite storage using @libsql/client (libSQL/SQLite).
|
|
5
5
|
* Data lives at ~/.prism-mcp/data.db — no account, no API keys, no network.
|
|
6
6
|
*
|
|
7
7
|
* ═══════════════════════════════════════════════════════════════════
|
|
@@ -68,7 +68,11 @@ export function clampCeiling(requested, planCeiling) {
|
|
|
68
68
|
}
|
|
69
69
|
// ── Fetch ─────────────────────────────────────────────────────────
|
|
70
70
|
async function fetchEntitlements() {
|
|
71
|
-
|
|
71
|
+
// Re-read process.env because dashboard/bootstrap configuration can inject
|
|
72
|
+
// credentials after config.ts captured its module-load constants.
|
|
73
|
+
const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || PRISM_SYNALUX_BASE_URL;
|
|
74
|
+
const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
|
|
75
|
+
if ((!SYNALUX_CONFIGURED && !apiKey) || !baseUrl) {
|
|
72
76
|
debugLog("[entitlements] no Synalux auth configured — free tier");
|
|
73
77
|
return { ...FREE_ENTITLEMENTS, source: "unconfigured" };
|
|
74
78
|
}
|
|
@@ -78,7 +82,7 @@ async function fetchEntitlements() {
|
|
|
78
82
|
return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
|
|
79
83
|
}
|
|
80
84
|
try {
|
|
81
|
-
const url = `${
|
|
85
|
+
const url = `${baseUrl}/api/v1/prism/entitlements`;
|
|
82
86
|
const res = await fetch(url, {
|
|
83
87
|
method: "GET",
|
|
84
88
|
headers: { Authorization: `Bearer ${jwt}` },
|
package/dist/utils/synaluxJwt.js
CHANGED
|
@@ -32,7 +32,11 @@ let inFlight = null;
|
|
|
32
32
|
* Concurrent callers share a single in-flight exchange (no thundering herd).
|
|
33
33
|
*/
|
|
34
34
|
export async function getSynaluxJwt() {
|
|
35
|
-
|
|
35
|
+
// Re-read process.env because storage/dashboard configuration can inject
|
|
36
|
+
// credentials after config.ts captured its module-load constants.
|
|
37
|
+
const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || PRISM_SYNALUX_BASE_URL;
|
|
38
|
+
const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim() || PRISM_SYNALUX_API_KEY;
|
|
39
|
+
if (!baseUrl || !apiKey) {
|
|
36
40
|
return null;
|
|
37
41
|
}
|
|
38
42
|
const now = Date.now();
|
|
@@ -43,11 +47,11 @@ export async function getSynaluxJwt() {
|
|
|
43
47
|
return inFlight;
|
|
44
48
|
inFlight = (async () => {
|
|
45
49
|
try {
|
|
46
|
-
const url = `${
|
|
50
|
+
const url = `${baseUrl}/api/v1/auth/jwt`;
|
|
47
51
|
const res = await fetch(url, {
|
|
48
52
|
method: "POST",
|
|
49
53
|
headers: {
|
|
50
|
-
"Authorization": `Bearer ${
|
|
54
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
51
55
|
"Content-Type": "application/json",
|
|
52
56
|
},
|
|
53
57
|
signal: AbortSignal.timeout(10_000),
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.1
|
|
3
|
+
"version": "20.2.1",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
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-
|
|
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 or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
|
|
6
6
|
"module": "index.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "dist/server.js",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
20
|
+
"prebuild": "npm run clean",
|
|
19
21
|
"build": "tsc && npm run chmod-bins",
|
|
20
22
|
"chmod-bins": "node -e \"['dist/cli.js','dist/server.js','dist/utils/universalImporter.js'].forEach(f => { try { require('fs').chmodSync(f, 0o755); } catch (e) { console.warn('chmod skipped', f, e.message); } })\"",
|
|
21
23
|
"prepublishOnly": "npm run build",
|
|
@@ -65,6 +67,7 @@
|
|
|
65
67
|
"rag",
|
|
66
68
|
"embeddings",
|
|
67
69
|
"cursor",
|
|
70
|
+
"codex",
|
|
68
71
|
"windsurf",
|
|
69
72
|
"cline",
|
|
70
73
|
"persistent-memory",
|
|
@@ -144,6 +147,7 @@
|
|
|
144
147
|
"p-limit": "^7.3.0",
|
|
145
148
|
"postcss": "8.5.12",
|
|
146
149
|
"quickjs-emscripten": "^0.32.0",
|
|
150
|
+
"smol-toml": "^1.7.0",
|
|
147
151
|
"stream-json": "^2.0.0",
|
|
148
152
|
"turndown": "^7.2.2",
|
|
149
153
|
"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
|
-
}
|