prism-mcp-server 20.12.0 → 20.13.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 +66 -21
- package/dist/autoUpdate.js +113 -21
- package/dist/cli.js +5 -17
- package/dist/connect.js +95 -12
- package/dist/tools/compactionHandler.js +5 -2
- package/dist/tools/ingestHandler.js +45 -35
- package/dist/tools/prismInferHandler.js +811 -33
- package/dist/tools/taskRouterHandler.js +5 -2
- package/dist/utils/entitlements.js +10 -2
- package/dist/utils/imageDownscale.js +303 -0
- package/dist/utils/inferenceMetrics.js +56 -1
- package/dist/utils/layer1.js +319 -9
- package/dist/utils/modelPicker.js +64 -10
- package/dist/utils/nerExtractor.js +6 -2
- package/dist/utils/routeContract.js +54 -2
- package/package.json +2 -2
- package/scripts/dev/browse.py +68 -0
package/README.md
CHANGED
|
@@ -116,6 +116,16 @@ or by re-enabling after each run.
|
|
|
116
116
|
<details>
|
|
117
117
|
<summary>Release history (optional)</summary>
|
|
118
118
|
|
|
119
|
+
## What's New in v20.12.1
|
|
120
|
+
|
|
121
|
+
- **`prism connect --refresh` now converges every registration it owns**, not
|
|
122
|
+
just the top-level one — directory-scoped entries could otherwise keep
|
|
123
|
+
launching an old build indefinitely.
|
|
124
|
+
- **`prism update` checks the installed package**, not the CLI that happens to
|
|
125
|
+
be running, so it can no longer report "current" while the install is stale.
|
|
126
|
+
- **The opt-in scheduled updater can actually start** — the LaunchAgent now
|
|
127
|
+
carries a PATH that includes node and npm.
|
|
128
|
+
|
|
119
129
|
## What's New in v20.12.0
|
|
120
130
|
|
|
121
131
|
- **Prism now tells you when it's out of date.** Session startup shows a
|
|
@@ -684,14 +694,20 @@ for manual configuration and host-specific paths.
|
|
|
684
694
|
**Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
|
|
685
695
|
|
|
686
696
|
```bash
|
|
687
|
-
ollama pull dcostenco/prism-coder:2b #
|
|
688
|
-
ollama pull dcostenco/prism-coder:4b # 3.
|
|
689
|
-
ollama pull dcostenco/prism-coder:9b #
|
|
690
|
-
ollama pull dcostenco/prism-coder:27b # 16 GB
|
|
697
|
+
ollama pull dcostenco/prism-coder:2b # 3.3 GB · on-device / lowest RAM · sees images (100% on our routing suite)
|
|
698
|
+
ollama pull dcostenco/prism-coder:4b # 3.5 GB · verifier · sees images (100%)
|
|
699
|
+
ollama pull dcostenco/prism-coder:9b # 6.7 GB · default router · sees images (95.7%, reasons before answering)
|
|
700
|
+
ollama pull dcostenco/prism-coder:27b # 16.8 GB · complex code / quality · text only (100%)
|
|
691
701
|
```
|
|
692
702
|
|
|
693
703
|
Prism detects both the namespaced (`dcostenco/prism-coder:9b`) and bare (`prism-coder:9b`) Ollama tags automatically.
|
|
694
704
|
|
|
705
|
+
The 2b/4b/9b tiers carry a vision tower and accept screenshots through
|
|
706
|
+
`prism_infer({ images: [...] })` — pass absolute paths or base64. Image
|
|
707
|
+
requests are refused rather than answered blind when no tier (or the Layer 1
|
|
708
|
+
safety classifier) can actually see the image, so a text-only model is never
|
|
709
|
+
handed a prompt about a screenshot it never received. The 27b is text only.
|
|
710
|
+
|
|
695
711
|
---
|
|
696
712
|
|
|
697
713
|
## What it does
|
|
@@ -834,9 +850,9 @@ air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
|
|
|
834
850
|
|
|
835
851
|
## Models
|
|
836
852
|
|
|
837
|
-
The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned
|
|
853
|
+
The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned; the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on our internal 115-case tool-routing suite and 100% on an internal 15-problem coding eval, at $0 inference cost. These are self-run evaluations, not [BFCL](https://gorilla.cs.berkeley.edu/leaderboard.html) leaderboard submissions.
|
|
838
854
|
|
|
839
|
-
`prism_infer` supports three modes: `route` (tool routing, fast
|
|
855
|
+
`prism_infer` supports three modes: `route` (tool routing, fast), `chat` (conversation) and `code` (code generation). Reasoning is decided by the **tier**, not the mode: a tier carrying `MODEL_TIERS.prefersThinking` also carries a `minLocalTokens` floor so reasoning cannot crowd out the answer, and only those tiers use `<think>` blocks (stripped before the response is served). The 9B does; the 4B and 2B do not, because on those tiers reasoning drew down the same `num_predict` budget the answer needed and returned an empty response. An explicit `think: true` still overrides, for a caller who has sized `max_tokens` for it. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Gemini 3.6 Flash via the Synalux portal.
|
|
840
856
|
|
|
841
857
|
Every route-mode result is parsed locally and checked against `allowed_tools`
|
|
842
858
|
before it reaches the host. Malformed or unadvertised calls become `NO_TOOL`.
|
|
@@ -846,16 +862,33 @@ draft that may need correction—to Synalux for authenticated deterministic
|
|
|
846
862
|
correction. Advertised custom host tools remain local. Set
|
|
847
863
|
`route_guard: "local"` for a fully on-device route path.
|
|
848
864
|
|
|
849
|
-
| Model | Ollama tag | Size | Routing accuracy¹ | Role | Automatic routing tier |
|
|
850
|
-
|
|
851
|
-
| Qwen3.5-4B
|
|
852
|
-
| Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.
|
|
853
|
-
| Qwen3.5-9B (LoRA) | `prism-coder:9b` |
|
|
854
|
-
| Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100%
|
|
855
|
-
|
|
856
|
-
¹ Self-run on a narrow 115-case MCP tool-selection suite,
|
|
857
|
-
|
|
858
|
-
|
|
865
|
+
| Model | Ollama tag | Size | Vision | Routing accuracy¹ | Role | Automatic routing tier |
|
|
866
|
+
|---|---|---|---|---|---|---|
|
|
867
|
+
| Qwen3.5-4B Q4_K_S | `prism-coder:2b` | 3.3 GB | ✅ | 100% | On-device / lowest RAM (4.5 GiB free) | Free |
|
|
868
|
+
| Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.5 GB | ✅ | 100% | Verifier (5.2 GiB free) | Free |
|
|
869
|
+
| Qwen3.5-9B (LoRA) | `prism-coder:9b` | 6.7 GB | ✅ | 95.7%² | Default router / workhorse (9 GiB free) | Standard+ |
|
|
870
|
+
| Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16.8 GB | — | 100% | Complex code / quality (21 GiB free) | Advanced+ |
|
|
871
|
+
|
|
872
|
+
¹ Self-run on a narrow 115-case MCP tool-selection suite, `temperature: 0`,
|
|
873
|
+
measured through the call path `prism_infer` actually uses (`/api/chat`, each
|
|
874
|
+
model's own template). It says these models pick the right tool on our own eval,
|
|
875
|
+
nothing more — not a general capability measure, and not an independent
|
|
876
|
+
benchmark result. Earlier revisions of this table quoted 99.1–100% from a
|
|
877
|
+
harness that hand-rolled a ChatML prompt with `raw: true`, bypassing the
|
|
878
|
+
template; those numbers described a path no caller exercises. Full methodology
|
|
879
|
+
caveats below.
|
|
880
|
+
|
|
881
|
+
² The 9B is the one tier that reasons before answering, and it is measured with
|
|
882
|
+
reasoning enabled: 95.7% with thinking, 83.5% without. `prism_infer` sets this
|
|
883
|
+
per-tier (`MODEL_TIERS.prefersThinking`), so callers get the 95.7% path by
|
|
884
|
+
default. Reasoning costs roughly 600 tokens, which is why the 9B also carries a
|
|
885
|
+
2,048-token local floor.
|
|
886
|
+
|
|
887
|
+
**Vision.** The 2B/4B/9B tags ship a separate `projector` layer (0.68–0.92 GB)
|
|
888
|
+
and read images; the 27B is text-only. `prism_infer` probes for that layer and
|
|
889
|
+
skips a tier with no vision rather than sending it an image — asked directly, a
|
|
890
|
+
text-only model will still answer confidently about pixels it never received.
|
|
891
|
+
Exercised against the real models in `tests/integration/visionScreenshot.test.ts`.
|
|
859
892
|
|
|
860
893
|
These tiers control automatic `prism_infer` selection, not Ollama itself. Any
|
|
861
894
|
user can run any downloaded on-device model directly through Ollama on every
|
|
@@ -910,10 +943,17 @@ reliability, not general model capability.
|
|
|
910
943
|
|
|
911
944
|
| Model | Routing accuracy | Notes |
|
|
912
945
|
|---|---|---|
|
|
913
|
-
| prism-coder:2b (
|
|
914
|
-
| prism-coder:4b
|
|
946
|
+
| prism-coder:2b (Q4_K_S) | 100% | The 2B was requantised when vision shipped; the old 99.1% was Q3_K_M |
|
|
947
|
+
| prism-coder:4b | 100% | |
|
|
948
|
+
| prism-coder:9b | 95.7% with reasoning | 83.5% without — the only tier where this differs |
|
|
949
|
+
| prism-coder:27b | 100% | |
|
|
915
950
|
| Claude (frontier, same eval) | ~98% | Stronger everywhere outside this narrow task |
|
|
916
951
|
|
|
952
|
+
Measured through `/api/chat` with each model's own template — the path
|
|
953
|
+
`prism_infer` uses. `temperature: 0`, so the three seeds only reshuffle case
|
|
954
|
+
order and cannot disagree; earlier revisions cited that agreement as
|
|
955
|
+
confirmation, which it never was.
|
|
956
|
+
|
|
917
957
|
**Memory uplift (LoCoMo-Plus, self-published).** A separate long-context dialogue benchmark ([dcostenco/Locomo-Plus](https://github.com/dcostenco/Locomo-Plus)) measures how much structured memory helps a base model retain multi-day context. Results show large gains when a model is paired with Prism memory versus running raw. Note this benchmark is authored, run, and LLM-judged by this project — treat it as a reproducible demonstration, not an independent third-party result, and run it yourself with the commands in that repo.
|
|
918
958
|
|
|
919
959
|
**Code generation evaluation.** In a small July 2026 deterministic execution
|
|
@@ -1043,9 +1083,14 @@ prism_infer({
|
|
|
1043
1083
|
|
|
1044
1084
|
| Mode | Think | Model | Use case |
|
|
1045
1085
|
|------|-------|-------|----------|
|
|
1046
|
-
| `route` | Off (fast) | 9B default | MCP tool routing |
|
|
1047
|
-
| `chat` |
|
|
1048
|
-
| `code` |
|
|
1086
|
+
| `route` | Off (fast) — except a tier that reasons better, e.g. 9B | 9B default | MCP tool routing |
|
|
1087
|
+
| `chat` | Per tier: on for 9B, off for 4B/2B | 27B preferred | Conversation, reasoning |
|
|
1088
|
+
| `code` | Per tier: on for 9B, off for 4B/2B | 27B preferred | Code generation, debugging |
|
|
1089
|
+
|
|
1090
|
+
Think is a **tier** property, not a mode property. Tiers with
|
|
1091
|
+
`prefersThinking` also declare a `minLocalTokens` floor that reserves budget for
|
|
1092
|
+
the answer; tiers without it spend the whole `num_predict` allowance inside
|
|
1093
|
+
`<think>` and return nothing. Pass `think` explicitly to override either way.
|
|
1049
1094
|
|
|
1050
1095
|
Full TypeScript signatures live in [`src/tools/`](src/tools/); architecture in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
|
1051
1096
|
|
package/dist/autoUpdate.js
CHANGED
|
@@ -31,6 +31,22 @@ function defaultFetchLatest() {
|
|
|
31
31
|
timeout: 15_000,
|
|
32
32
|
}).trim();
|
|
33
33
|
}
|
|
34
|
+
/** Read the version of the globally installed package. npm puts it under
|
|
35
|
+
* <prefix>/lib/node_modules on POSIX and <prefix>/node_modules on Windows. */
|
|
36
|
+
function defaultInstalledVersion() {
|
|
37
|
+
const prefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 15_000 }).trim();
|
|
38
|
+
for (const candidate of [
|
|
39
|
+
join(prefix, "lib", "node_modules", PACKAGE, "package.json"),
|
|
40
|
+
join(prefix, "node_modules", PACKAGE, "package.json"),
|
|
41
|
+
]) {
|
|
42
|
+
if (existsSync(candidate)) {
|
|
43
|
+
const version = JSON.parse(readFileSync(candidate, "utf8"))?.version;
|
|
44
|
+
if (typeof version === "string" && version.trim())
|
|
45
|
+
return version.trim();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
34
50
|
function defaultInstall(version) {
|
|
35
51
|
execFileSync("npm", ["install", "-g", `${PACKAGE}@${version}`], {
|
|
36
52
|
stdio: "inherit",
|
|
@@ -95,9 +111,6 @@ export function runPackageUpdate(deps) {
|
|
|
95
111
|
if ((env.VITEST || env.NODE_ENV === "test") && !deps.fetchLatest) {
|
|
96
112
|
return { action: "skipped", detail: "test environment" };
|
|
97
113
|
}
|
|
98
|
-
if (deps.currentVersion.includes("-")) {
|
|
99
|
-
return { action: "skipped", detail: `dev build ${deps.currentVersion} — not touching it` };
|
|
100
|
-
}
|
|
101
114
|
if (deps.ifIdle) {
|
|
102
115
|
let running;
|
|
103
116
|
try {
|
|
@@ -116,6 +129,28 @@ export function runPackageUpdate(deps) {
|
|
|
116
129
|
};
|
|
117
130
|
}
|
|
118
131
|
}
|
|
132
|
+
// The version that matters is the INSTALLED one; the running CLI may be a
|
|
133
|
+
// checkout, a shim, or an older global. Probing shells out to npm, so tests
|
|
134
|
+
// reach it only through an injected dep.
|
|
135
|
+
let targetVersion = deps.currentVersion;
|
|
136
|
+
// Test detection must read the REAL process env: suites pass env: {} to
|
|
137
|
+
// exercise the policy guards, and that would otherwise let this probe shell
|
|
138
|
+
// out to npm from inside the test run (caught by a 57ms test that suddenly
|
|
139
|
+
// consulted the machine's actual install).
|
|
140
|
+
const inTest = Boolean(env.VITEST || env.NODE_ENV === "test" ||
|
|
141
|
+
process.env.VITEST || process.env.NODE_ENV === "test");
|
|
142
|
+
const mayProbe = Boolean(deps.installedVersion) || !inTest;
|
|
143
|
+
if (mayProbe) {
|
|
144
|
+
try {
|
|
145
|
+
const installed = (deps.installedVersion ?? defaultInstalledVersion)().trim();
|
|
146
|
+
if (installed)
|
|
147
|
+
targetVersion = installed;
|
|
148
|
+
}
|
|
149
|
+
catch { /* not installed / npm unavailable — compare the running version */ }
|
|
150
|
+
}
|
|
151
|
+
if (targetVersion.includes("-")) {
|
|
152
|
+
return { action: "skipped", detail: `dev build ${targetVersion} — not touching it` };
|
|
153
|
+
}
|
|
119
154
|
const release = (deps.acquireLock ?? defaultAcquireLock)();
|
|
120
155
|
if (!release) {
|
|
121
156
|
return { action: "locked", detail: "another prism update is already running" };
|
|
@@ -131,10 +166,10 @@ export function runPackageUpdate(deps) {
|
|
|
131
166
|
if (!SEMVER.test(latest)) {
|
|
132
167
|
return { action: "failed", detail: `registry returned unexpected version "${latest}"` };
|
|
133
168
|
}
|
|
134
|
-
if (!isNewer(
|
|
135
|
-
return { action: "current", detail:
|
|
169
|
+
if (!isNewer(targetVersion, latest)) {
|
|
170
|
+
return { action: "current", detail: `installed package ${targetVersion} is current`, latest };
|
|
136
171
|
}
|
|
137
|
-
log(`prism ${
|
|
172
|
+
log(`prism ${targetVersion} → ${latest}: updating the global package …`);
|
|
138
173
|
try {
|
|
139
174
|
(deps.install ?? defaultInstall)(latest);
|
|
140
175
|
}
|
|
@@ -151,8 +186,27 @@ export function runPackageUpdate(deps) {
|
|
|
151
186
|
export function autoupdatePlistPath() {
|
|
152
187
|
return join(homedir(), "Library", "LaunchAgents", `${AUTOUPDATE_LABEL}.plist`);
|
|
153
188
|
}
|
|
189
|
+
function xmlEscape(value) {
|
|
190
|
+
return value
|
|
191
|
+
.replace(/&/g, "&")
|
|
192
|
+
.replace(/</g, "<")
|
|
193
|
+
.replace(/>/g, ">");
|
|
194
|
+
}
|
|
195
|
+
/** The PATH a scheduled run needs. launchd hands an agent a minimal
|
|
196
|
+
* PATH (/usr/bin:/bin:/usr/sbin:/sbin) that excludes /usr/local/bin and
|
|
197
|
+
* /opt/homebrew/bin — where node and npm live on a standard macOS install.
|
|
198
|
+
* Measured 2026-08-14: without this the agent died at `env: node: No such
|
|
199
|
+
* file or directory` before running a single line of Prism. The directory
|
|
200
|
+
* of the interpreter running this code leads, because that is provably the
|
|
201
|
+
* node the operator uses. */
|
|
202
|
+
export function schedulerPath(execPath = process.execPath) {
|
|
203
|
+
const lastSlash = execPath.lastIndexOf("/");
|
|
204
|
+
const nodeDir = lastSlash > 0 ? execPath.slice(0, lastSlash) : "/usr/local/bin";
|
|
205
|
+
const defaults = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
206
|
+
return [nodeDir, ...defaults.filter((dir) => dir !== nodeDir)].join(":");
|
|
207
|
+
}
|
|
154
208
|
/** Daily 03:30 local, catch-up on wake (LaunchAgents coalesce missed runs). */
|
|
155
|
-
export function buildAutoupdatePlist(prismBin) {
|
|
209
|
+
export function buildAutoupdatePlist(prismBin, pathEnv = schedulerPath()) {
|
|
156
210
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
157
211
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
158
212
|
<plist version="1.0">
|
|
@@ -160,10 +214,14 @@ export function buildAutoupdatePlist(prismBin) {
|
|
|
160
214
|
<key>Label</key><string>${AUTOUPDATE_LABEL}</string>
|
|
161
215
|
<key>ProgramArguments</key>
|
|
162
216
|
<array>
|
|
163
|
-
<string>${prismBin}</string>
|
|
217
|
+
<string>${xmlEscape(prismBin)}</string>
|
|
164
218
|
<string>update</string>
|
|
165
219
|
<string>--if-idle</string>
|
|
166
220
|
</array>
|
|
221
|
+
<key>EnvironmentVariables</key>
|
|
222
|
+
<dict>
|
|
223
|
+
<key>PATH</key><string>${xmlEscape(pathEnv)}</string>
|
|
224
|
+
</dict>
|
|
167
225
|
<key>StartCalendarInterval</key>
|
|
168
226
|
<dict>
|
|
169
227
|
<key>Hour</key><integer>3</integer>
|
|
@@ -181,28 +239,62 @@ export function autoupdateStatus() {
|
|
|
181
239
|
return { supported: false, enabled: false, plistPath, detail: "scheduled updates are macOS-only for now (LaunchAgent)" };
|
|
182
240
|
}
|
|
183
241
|
const enabled = existsSync(plistPath);
|
|
242
|
+
if (!enabled) {
|
|
243
|
+
return { supported: true, enabled, plistPath, detail: "disabled" };
|
|
244
|
+
}
|
|
245
|
+
// 20.12.0 wrote a plist with no PATH. launchd hands an agent
|
|
246
|
+
// /usr/bin:/bin:/usr/sbin:/sbin, which excludes the directories holding node
|
|
247
|
+
// and npm on a standard macOS install, so that generation could never run —
|
|
248
|
+
// and it failed into a log file nobody reads. Say so instead of reporting a
|
|
249
|
+
// confident "enabled".
|
|
250
|
+
let healthy = false;
|
|
251
|
+
try {
|
|
252
|
+
healthy = readFileSync(plistPath, "utf8").includes("<key>PATH</key>");
|
|
253
|
+
}
|
|
254
|
+
catch { /* unreadable — treat as needing repair */ }
|
|
184
255
|
return {
|
|
185
256
|
supported: true,
|
|
186
257
|
enabled,
|
|
187
258
|
plistPath,
|
|
188
|
-
detail:
|
|
259
|
+
detail: healthy
|
|
260
|
+
? `enabled — daily 03:30, log: /tmp/${AUTOUPDATE_LABEL}.log`
|
|
261
|
+
// Deliberately "may not run", not "cannot": launchd's default PATH does
|
|
262
|
+
// contain /usr/bin, so an operator whose node lives there is fine. On a
|
|
263
|
+
// standard install (Homebrew, /usr/local) it never runs. Claiming a
|
|
264
|
+
// certain failure we have not measured on THIS machine would be the same
|
|
265
|
+
// overclaim in the other direction.
|
|
266
|
+
: "enabled, but this agent predates the PATH fix and may not run (launchd's default PATH omits /usr/local/bin and /opt/homebrew/bin) — re-run `prism autoupdate enable` to repair",
|
|
189
267
|
};
|
|
190
268
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
* their scheduler runs. */
|
|
195
|
-
export function resolvePrismBin(log) {
|
|
196
|
-
const bin = execFileSync("which", ["prism"], { encoding: "utf8", timeout: 5_000 }).trim();
|
|
197
|
-
if (!bin)
|
|
198
|
-
throw new Error("`prism` not found on PATH — install with: npm install -g prism-mcp-server");
|
|
269
|
+
export function resolvePrismBin(log, deps = {}) {
|
|
270
|
+
const exists = deps.exists ?? existsSync;
|
|
271
|
+
let globalBin;
|
|
199
272
|
try {
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
|
|
273
|
+
const prefix = (deps.npmPrefix ?? (() => execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 15_000 })))().trim();
|
|
274
|
+
if (prefix) {
|
|
275
|
+
const candidate = join(prefix, "bin", "prism");
|
|
276
|
+
if (exists(candidate))
|
|
277
|
+
globalBin = candidate;
|
|
203
278
|
}
|
|
204
279
|
}
|
|
205
|
-
catch { /*
|
|
280
|
+
catch { /* npm unavailable — fall back to PATH lookup */ }
|
|
281
|
+
if (globalBin)
|
|
282
|
+
return globalBin;
|
|
283
|
+
let bin = "";
|
|
284
|
+
try {
|
|
285
|
+
bin = (deps.whichPrism ?? (() => execFileSync("which", ["prism"], { encoding: "utf8", timeout: 5_000 })))().trim();
|
|
286
|
+
}
|
|
287
|
+
catch { /* not on PATH */ }
|
|
288
|
+
if (!bin)
|
|
289
|
+
throw new Error("`prism` not found — install it with: npm install -g prism-mcp-server");
|
|
290
|
+
let real = bin;
|
|
291
|
+
try {
|
|
292
|
+
real = (deps.readlink ?? ((p) => execFileSync("readlink", ["-f", p], { encoding: "utf8", timeout: 5_000 })))(bin).trim() || bin;
|
|
293
|
+
}
|
|
294
|
+
catch { /* keep bin */ }
|
|
295
|
+
if (!real.includes("node_modules") && /\/dist\/[^/]+$/.test(real)) {
|
|
296
|
+
log(`⚠ ${bin} resolves to a source checkout (${real}); the scheduled job will run that CLI — it still updates only the global package`);
|
|
297
|
+
}
|
|
206
298
|
return bin;
|
|
207
299
|
}
|
|
208
300
|
export function enableAutoupdate(log) {
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { getSetting } from './storage/configStorage.js';
|
|
|
11
11
|
import { PRISM_USER_ID, SERVER_CONFIG } from './config.js';
|
|
12
12
|
import { getCurrentGitState } from './utils/git.js';
|
|
13
13
|
import { sessionBootstrapHandler, sessionLoadContextHandler, sessionSaveLedgerHandler, sessionSaveHandoffHandler, } from './tools/ledgerHandlers.js';
|
|
14
|
-
import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCodexAgentPolicy, configureCodexNativeStartup, configureGeminiAgentPolicy, configureGeminiNativeStartup, connectHosts, migrateLegacyClaudeHooks, migrateLegacyClaudeInstructions, migrateLegacyClaudeManagedStartup, migrateLegacyClaudeProjectMcp, normalizeHostName, } from './connect.js';
|
|
14
|
+
import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCodexAgentPolicy, configureCodexNativeStartup, configureGeminiAgentPolicy, configureGeminiNativeStartup, connectHosts, migrateLegacyClaudeHooks, migrateLegacyClaudeInstructions, migrateLegacyClaudeManagedStartup, migrateLegacyClaudeProjectMcp, connectResultLine, normalizeHostName, } from './connect.js';
|
|
15
15
|
import { runBrowserCli } from './browserCli.js';
|
|
16
16
|
import { filterPrismMemoryContext } from './utils/memoryQuality.js';
|
|
17
17
|
import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
|
|
@@ -261,24 +261,12 @@ program
|
|
|
261
261
|
return;
|
|
262
262
|
}
|
|
263
263
|
for (const result of summary.results) {
|
|
264
|
-
if (result.status === '
|
|
265
|
-
console.
|
|
266
|
-
|
|
267
|
-
else if (result.status === 'would-register') {
|
|
268
|
-
console.log(`• ${result.label}: would register (${result.path})`);
|
|
269
|
-
}
|
|
270
|
-
else if (result.status === 'refreshed') {
|
|
271
|
-
console.log(`✓ ${result.label}: Prism-managed entry refreshed (${result.path})`);
|
|
272
|
-
}
|
|
273
|
-
else if (result.status === 'would-refresh') {
|
|
274
|
-
console.log(`• ${result.label}: would refresh Prism-managed entry (${result.path})`);
|
|
275
|
-
}
|
|
276
|
-
else if (result.status === 'existing') {
|
|
277
|
-
console.log(`− ${result.label}: already registered — untouched (${result.path})`);
|
|
264
|
+
if (result.status === 'error') {
|
|
265
|
+
console.error(connectResultLine(result));
|
|
266
|
+
process.exitCode = 1;
|
|
278
267
|
}
|
|
279
268
|
else {
|
|
280
|
-
console.
|
|
281
|
-
process.exitCode = 1;
|
|
269
|
+
console.log(connectResultLine(result));
|
|
282
270
|
}
|
|
283
271
|
}
|
|
284
272
|
const connectedClaude = summary.results.some((result) => result.host === 'claude-code' && result.status !== 'error' && result.startupCompatible);
|
package/dist/connect.js
CHANGED
|
@@ -1220,36 +1220,63 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
1220
1220
|
: Object.prototype.hasOwnProperty.call(mcpServers, "prism")
|
|
1221
1221
|
? "prism"
|
|
1222
1222
|
: undefined;
|
|
1223
|
+
// Claude Code keeps ADDITIONAL, directory-scoped registrations under
|
|
1224
|
+
// projects["<dir>"].mcpServers, and the scoped one wins for sessions started
|
|
1225
|
+
// in that directory. Refreshing only the top-level entry left those pinned to
|
|
1226
|
+
// a stale server path forever — measured live 2026-08-14 on a machine
|
|
1227
|
+
// carrying three registrations, where `--refresh` converged exactly one and
|
|
1228
|
+
// two directories kept launching an old build indefinitely. Only entries
|
|
1229
|
+
// Prism itself created are eligible, and only under --refresh, so this
|
|
1230
|
+
// cannot reach a hand-rolled entry. Hosts without a `projects` map are
|
|
1231
|
+
// unaffected: the collector returns nothing.
|
|
1232
|
+
const pendingProjects = refresh ? collectProjectScopedRefreshes(config, entry) : [];
|
|
1233
|
+
const scopedCount = pendingProjects.length;
|
|
1234
|
+
const scopedNoun = `${scopedCount} project-scoped ${scopedCount === 1 ? "entry" : "entries"}`;
|
|
1235
|
+
const writeConfig = (status, message, compatible) => {
|
|
1236
|
+
try {
|
|
1237
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
1238
|
+
return result(definition, status, message, compatible);
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1223
1244
|
if (existingKey) {
|
|
1224
1245
|
const existingEntry = mcpServers[existingKey];
|
|
1225
1246
|
const startupCompatible = existingKey === "prism-mcp"
|
|
1226
1247
|
&& isManagedPrismEntry(existingEntry)
|
|
1227
1248
|
&& isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
|
|
1228
1249
|
if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
|
|
1229
|
-
|
|
1250
|
+
if (scopedCount === 0) {
|
|
1251
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
|
|
1252
|
+
}
|
|
1253
|
+
if (dryRun) {
|
|
1254
|
+
return result(definition, "would-refresh", `top-level entry left untouched; would refresh ${scopedNoun}`, startupCompatible);
|
|
1255
|
+
}
|
|
1256
|
+
applyProjectScopedRefreshes(config, pendingProjects);
|
|
1257
|
+
return writeConfig("refreshed", `top-level entry left untouched; refreshed ${scopedNoun}`, startupCompatible);
|
|
1230
1258
|
}
|
|
1231
1259
|
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
1232
|
-
|
|
1260
|
+
const topLevelStale = JSON.stringify(refreshedEntry) !== JSON.stringify(existingEntry);
|
|
1261
|
+
if (!topLevelStale && scopedCount === 0) {
|
|
1233
1262
|
return result(definition, "existing", "Prism-managed entry is already current", true);
|
|
1234
1263
|
}
|
|
1235
1264
|
if (dryRun) {
|
|
1236
|
-
return result(definition, "would-refresh", undefined, true);
|
|
1265
|
+
return result(definition, "would-refresh", scopedCount > 0 ? `also ${scopedNoun}` : undefined, true);
|
|
1237
1266
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
1242
|
-
return result(definition, "refreshed", undefined, true);
|
|
1243
|
-
}
|
|
1244
|
-
catch (error) {
|
|
1245
|
-
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
1267
|
+
if (topLevelStale) {
|
|
1268
|
+
mcpServers[existingKey] = refreshedEntry;
|
|
1269
|
+
config.mcpServers = mcpServers;
|
|
1246
1270
|
}
|
|
1271
|
+
applyProjectScopedRefreshes(config, pendingProjects);
|
|
1272
|
+
return writeConfig("refreshed", scopedCount > 0 ? `also refreshed ${scopedNoun}` : undefined, true);
|
|
1247
1273
|
}
|
|
1248
1274
|
if (dryRun) {
|
|
1249
|
-
return result(definition, "would-register", undefined, true);
|
|
1275
|
+
return result(definition, "would-register", scopedCount > 0 ? `also ${scopedNoun}` : undefined, true);
|
|
1250
1276
|
}
|
|
1251
1277
|
mcpServers["prism-mcp"] = entry;
|
|
1252
1278
|
config.mcpServers = mcpServers;
|
|
1279
|
+
applyProjectScopedRefreshes(config, pendingProjects);
|
|
1253
1280
|
try {
|
|
1254
1281
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
1255
1282
|
return result(definition, "registered", undefined, true);
|
|
@@ -1563,6 +1590,22 @@ function result(definition, status, message, startupCompatible = false) {
|
|
|
1563
1590
|
message,
|
|
1564
1591
|
};
|
|
1565
1592
|
}
|
|
1593
|
+
/** One operator-facing line per host result.
|
|
1594
|
+
* The `message` a host writer attaches (e.g. "also refreshed 2 project-scoped
|
|
1595
|
+
* entries") MUST survive to stdout: the earlier printer used canned per-status
|
|
1596
|
+
* text and dropped it, so a converged directory-scoped registration was
|
|
1597
|
+
* invisible to the person who asked for it. */
|
|
1598
|
+
export function connectResultLine(result) {
|
|
1599
|
+
const detail = result.message ? ` — ${result.message}` : "";
|
|
1600
|
+
switch (result.status) {
|
|
1601
|
+
case "registered": return `✓ ${result.label}: registered${detail} (${result.path})`;
|
|
1602
|
+
case "would-register": return `• ${result.label}: would register${detail} (${result.path})`;
|
|
1603
|
+
case "refreshed": return `✓ ${result.label}: Prism-managed entry refreshed${detail} (${result.path})`;
|
|
1604
|
+
case "would-refresh": return `• ${result.label}: would refresh Prism-managed entry${detail} (${result.path})`;
|
|
1605
|
+
case "existing": return `− ${result.label}: already registered — untouched (${result.path})`;
|
|
1606
|
+
default: return `✗ ${result.label}: ${result.message || "registration failed"} (${result.path})`;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1566
1609
|
function isJsonObject(value) {
|
|
1567
1610
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1568
1611
|
}
|
|
@@ -1576,6 +1619,46 @@ function isManagedPrismEntry(value) {
|
|
|
1576
1619
|
&& isJsonObject(value.env)
|
|
1577
1620
|
&& value.env.PRISM_INSTANCE === "prism-mcp";
|
|
1578
1621
|
}
|
|
1622
|
+
/** Prism-managed, directory-scoped registrations that are out of date.
|
|
1623
|
+
* Claude Code stores these under projects["<dir>"].mcpServers; other hosts
|
|
1624
|
+
* have no `projects` map, so this returns nothing for them. */
|
|
1625
|
+
function collectProjectScopedRefreshes(config, desired) {
|
|
1626
|
+
const projects = config.projects;
|
|
1627
|
+
if (!isJsonObject(projects))
|
|
1628
|
+
return [];
|
|
1629
|
+
const pending = [];
|
|
1630
|
+
for (const [project, projectConfig] of Object.entries(projects)) {
|
|
1631
|
+
if (!isJsonObject(projectConfig))
|
|
1632
|
+
continue;
|
|
1633
|
+
const servers = projectConfig.mcpServers;
|
|
1634
|
+
if (!isJsonObject(servers))
|
|
1635
|
+
continue;
|
|
1636
|
+
const existing = servers["prism-mcp"];
|
|
1637
|
+
if (!isManagedPrismEntry(existing))
|
|
1638
|
+
continue; // hand-rolled entries stay untouched
|
|
1639
|
+
const refreshed = refreshManagedEntry(existing, desired);
|
|
1640
|
+
if (JSON.stringify(refreshed) !== JSON.stringify(existing)) {
|
|
1641
|
+
pending.push({ project, entry: refreshed });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return pending;
|
|
1645
|
+
}
|
|
1646
|
+
function applyProjectScopedRefreshes(config, pending) {
|
|
1647
|
+
if (pending.length === 0)
|
|
1648
|
+
return;
|
|
1649
|
+
const projects = config.projects;
|
|
1650
|
+
if (!isJsonObject(projects))
|
|
1651
|
+
return;
|
|
1652
|
+
for (const { project, entry } of pending) {
|
|
1653
|
+
const projectConfig = projects[project];
|
|
1654
|
+
if (!isJsonObject(projectConfig))
|
|
1655
|
+
continue;
|
|
1656
|
+
const servers = projectConfig.mcpServers;
|
|
1657
|
+
if (!isJsonObject(servers))
|
|
1658
|
+
continue;
|
|
1659
|
+
servers["prism-mcp"] = entry;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1579
1662
|
function refreshManagedEntry(existing, desired) {
|
|
1580
1663
|
const existingEnv = isJsonObject(existing.env) ? existing.env : {};
|
|
1581
1664
|
const desiredEnv = isJsonObject(desired.env) ? desired.env : {};
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { getStorage } from "../storage/index.js";
|
|
10
10
|
import { PRISM_USER_ID } from "../config.js";
|
|
11
11
|
import { getLLMProvider } from "../utils/llm/factory.js";
|
|
12
|
-
import {
|
|
12
|
+
import { inferText } from "./prismInferHandler.js";
|
|
13
13
|
import { PRISM_LOCAL_LLM_ENABLED, PRISM_STRICT_LOCAL_MODE } from "../config.js";
|
|
14
14
|
import { debugLog } from "../utils/logger.js";
|
|
15
15
|
// ─── Constants ────────────────────────────────────────────────
|
|
@@ -111,7 +111,10 @@ async function summarizeEntries(entries) {
|
|
|
111
111
|
// ── Path 1: Local LLM (prism-coder:9b) ───────────────────────────
|
|
112
112
|
if (PRISM_LOCAL_LLM_ENABLED) {
|
|
113
113
|
debugLog(`[compact_ledger] Attempting local LLM summarization (${entries.length} entries)`);
|
|
114
|
-
|
|
114
|
+
// Ledger content goes through the full ladder so Layer 1 screening, the
|
|
115
|
+
// quality gate and the entitlement ceiling all apply — this summarises
|
|
116
|
+
// session records that may carry sensitive material.
|
|
117
|
+
const localResponse = await inferText(prompt, { mode: "chat" });
|
|
115
118
|
if (localResponse) {
|
|
116
119
|
debugLog(`[compact_ledger] Local LLM summarization succeeded`);
|
|
117
120
|
return parseCompactionResponse(localResponse, "local-llm");
|
|
@@ -54,50 +54,60 @@ function chunkSource(content, chunkSize, source) {
|
|
|
54
54
|
totalChars: content.length,
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
-
// ─── Q&A Generator (
|
|
57
|
+
// ─── Q&A Generator (via prism_infer) ────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Generate training Q&A pairs for a chunk.
|
|
60
|
+
*
|
|
61
|
+
* This used to POST straight to api.anthropic.com with a key read from
|
|
62
|
+
* ANTHROPIC_API_KEY **or ~/.anthropic_key**. That is an ambient-credential
|
|
63
|
+
* path: any process with the variable set bills the owner, which is how a
|
|
64
|
+
* local test run once burned ~$500 against a key exported in a shell profile.
|
|
65
|
+
* It also sat outside every gate prism applies to model calls.
|
|
66
|
+
*
|
|
67
|
+
* Ingest is training-data generation rather than user-facing inference, which
|
|
68
|
+
* is why it was left alone at first — but "not inference" does not make a raw
|
|
69
|
+
* billing surface a good idea, and prism_infer already offers the same
|
|
70
|
+
* capability local-first with an entitlement-gated cloud fallback. So it goes
|
|
71
|
+
* through the ladder like everything else, and prism no longer reads a
|
|
72
|
+
* provider key from the environment at all.
|
|
73
|
+
*
|
|
74
|
+
* PHI redaction still runs BEFORE the model sees anything: a chunk may carry
|
|
75
|
+
* client names in file paths, inline identifiers, or clinical notes, and the
|
|
76
|
+
* local tier is not a licence to skip that.
|
|
77
|
+
*/
|
|
58
78
|
async function generateQAPairs(chunk, source) {
|
|
59
|
-
const
|
|
60
|
-
(existsSync(`${process.env.HOME}/.anthropic_key`)
|
|
61
|
-
? readFileSync(`${process.env.HOME}/.anthropic_key`, "utf-8").trim()
|
|
62
|
-
: null);
|
|
63
|
-
if (!apiKey) {
|
|
64
|
-
debugLog("[ingest] No ANTHROPIC_API_KEY — skipping Q&A generation, storing raw chunks");
|
|
65
|
-
return [{ prompt: `What does this ${source} code do?`, response: chunk.slice(0, 500) }];
|
|
66
|
-
}
|
|
67
|
-
// PHI redaction BEFORE sending to cloud LLM — the chunk may contain
|
|
68
|
-
// client names in file paths, inline identifiers, or clinical notes.
|
|
79
|
+
const fallback = [{ prompt: `What does this ${source} code do?`, response: chunk.slice(0, 500) }];
|
|
69
80
|
const { scanAndRedactPHI } = await import("../utils/phiGuard.js");
|
|
70
81
|
const redactedChunk = scanAndRedactPHI(chunk).redacted;
|
|
82
|
+
const { inferText } = await import("./prismInferHandler.js");
|
|
83
|
+
const text = await inferText(`Source: ${source}\n\`\`\`\n${redactedChunk.slice(0, 5000)}\n\`\`\``, {
|
|
84
|
+
system: 'Generate 3 Q&A training pairs as JSON array: [{"prompt":"...","response":"..."}]. Focus on what the code does, how it works, and key patterns.',
|
|
85
|
+
mode: "chat",
|
|
86
|
+
maxTokens: 2048,
|
|
87
|
+
});
|
|
88
|
+
if (!text) {
|
|
89
|
+
debugLog("[ingest] no model output — storing raw chunk");
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
71
92
|
try {
|
|
72
|
-
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
73
|
-
method: "POST",
|
|
74
|
-
headers: {
|
|
75
|
-
"Content-Type": "application/json",
|
|
76
|
-
"x-api-key": apiKey,
|
|
77
|
-
"anthropic-version": "2023-06-01",
|
|
78
|
-
},
|
|
79
|
-
body: JSON.stringify({
|
|
80
|
-
model: "claude-haiku-4-5-20251001",
|
|
81
|
-
max_tokens: 2048,
|
|
82
|
-
system: 'Generate 3 Q&A training pairs as JSON array: [{"prompt":"...","response":"..."}]. Focus on what the code does, how it works, and key patterns.',
|
|
83
|
-
messages: [{ role: "user", content: `Source: ${source}\n\`\`\`\n${redactedChunk.slice(0, 5000)}\n\`\`\`` }],
|
|
84
|
-
}),
|
|
85
|
-
});
|
|
86
|
-
if (!res.ok) {
|
|
87
|
-
debugLog(`[ingest] Claude API error: ${res.status}`);
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
const data = await res.json();
|
|
91
|
-
const text = data.content?.[0]?.text || "";
|
|
92
93
|
const match = text.match(/\[.*\]/s);
|
|
93
|
-
if (match) {
|
|
94
|
-
|
|
94
|
+
if (!match) {
|
|
95
|
+
debugLog("[ingest] model output had no JSON array — storing raw chunk");
|
|
96
|
+
return fallback;
|
|
95
97
|
}
|
|
98
|
+
const parsed = JSON.parse(match[0]);
|
|
99
|
+
if (!Array.isArray(parsed))
|
|
100
|
+
return fallback;
|
|
101
|
+
// Only keep well-formed pairs; a malformed element must not reach storage
|
|
102
|
+
// as an undefined prompt/response.
|
|
103
|
+
const pairs = parsed.filter((p) => !!p && typeof p.prompt === "string"
|
|
104
|
+
&& typeof p.response === "string");
|
|
105
|
+
return pairs.length > 0 ? pairs : fallback;
|
|
96
106
|
}
|
|
97
107
|
catch (err) {
|
|
98
|
-
debugLog(`[ingest] Q&A
|
|
108
|
+
debugLog(`[ingest] Q&A parse error: ${err}`);
|
|
109
|
+
return fallback;
|
|
99
110
|
}
|
|
100
|
-
return [];
|
|
101
111
|
}
|
|
102
112
|
// ─── Main Ingest Pipeline ───────────────────────────────────────
|
|
103
113
|
export async function ingestKnowledge(args) {
|