prism-mcp-server 20.13.1 → 20.15.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 +6 -0
- package/dist/cli.js +26 -0
- package/dist/dashboard/dashboardToken.js +43 -0
- package/dist/dashboard/hostGuard.js +92 -0
- package/dist/dashboard/server.js +63 -2
- package/dist/modelConvergeRunner.js +94 -0
- package/dist/utils/modelConverge.js +102 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -724,6 +724,12 @@ Every conversation feeds a persistent store. The next session loads the right co
|
|
|
724
724
|
|
|
725
725
|
The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
|
|
726
726
|
|
|
727
|
+
It runs on loopback and is gated by a per-startup token by default — open the
|
|
728
|
+
tokenized URL printed in the startup log (`http://localhost:3000/?token=…`).
|
|
729
|
+
Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
|
|
730
|
+
exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
|
|
731
|
+
to pin the token, disable it, or configure Basic Auth / JWKS.
|
|
732
|
+
|
|
727
733
|
### Export — read the record outside the agent
|
|
728
734
|
|
|
729
735
|
`session_export_memory` writes your memory out as plain files you can read,
|
package/dist/cli.js
CHANGED
|
@@ -221,6 +221,7 @@ program
|
|
|
221
221
|
.option('--dry-run', 'Preview configuration changes without writing files')
|
|
222
222
|
.option('--refresh', 'Refresh only entries previously created by Prism; custom entries stay untouched')
|
|
223
223
|
.option('--no-self-update', 'Skip the npm self-update check; configure with the currently installed version')
|
|
224
|
+
.option('--no-models', 'Skip model convergence; keep whatever model versions are installed')
|
|
224
225
|
.action(async (options) => {
|
|
225
226
|
try {
|
|
226
227
|
// ── Converge the PACKAGE first, then the configs ──────────────
|
|
@@ -447,6 +448,17 @@ program
|
|
|
447
448
|
if (!summary.usedApiKey) {
|
|
448
449
|
console.log('PRISM_SYNALUX_API_KEY is not set; no Synalux subscription key was copied into new registrations.');
|
|
449
450
|
}
|
|
451
|
+
// ── Converge the MODELS last ────────────────────────────────
|
|
452
|
+
// selfUpdate above is the package half of convergence; without this
|
|
453
|
+
// half a machine kept vision-less models for four days after the
|
|
454
|
+
// registry carried the fix (2026-08-18), and `ollama cp` aliases are
|
|
455
|
+
// snapshots that silently detach from their source on every re-pull.
|
|
456
|
+
// Failures here never fail connect — runOllamaConverge reports and
|
|
457
|
+
// returns.
|
|
458
|
+
if (options.models !== false) {
|
|
459
|
+
const { runOllamaConverge } = await import('./modelConvergeRunner.js');
|
|
460
|
+
await runOllamaConverge({ dryRun: options.dryRun === true });
|
|
461
|
+
}
|
|
450
462
|
}
|
|
451
463
|
catch (err) {
|
|
452
464
|
console.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1028,6 +1040,20 @@ scmCmd
|
|
|
1028
1040
|
process.exit(1);
|
|
1029
1041
|
}
|
|
1030
1042
|
});
|
|
1043
|
+
// ─── prism update-models ──────────────────────────────────────
|
|
1044
|
+
// Standalone model convergence: pull each installed prism-coder tier from
|
|
1045
|
+
// the registry and repair its local alias. connect runs this automatically;
|
|
1046
|
+
// this command is for updating models without touching host configuration.
|
|
1047
|
+
program
|
|
1048
|
+
.command('update-models')
|
|
1049
|
+
.description('Pull installed prism-coder models from the registry and repair stale local aliases')
|
|
1050
|
+
.option('--dry-run', 'Print what would be pulled/re-aliased without executing')
|
|
1051
|
+
.action(async (options) => {
|
|
1052
|
+
const { runOllamaConverge } = await import('./modelConvergeRunner.js');
|
|
1053
|
+
const outcomes = await runOllamaConverge({ dryRun: options.dryRun === true });
|
|
1054
|
+
if (outcomes.every(o => o.action === 'failed'))
|
|
1055
|
+
process.exitCode = 1;
|
|
1056
|
+
});
|
|
1031
1057
|
// ─── prism register-models ────────────────────────────────────
|
|
1032
1058
|
// Convenience: alias namespaced HF-style prism-coder tags
|
|
1033
1059
|
// (`dcostenco/prism-coder:9b`) to the bare tags (`prism-coder:9b`)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { safeCompare } from "./authUtils.js";
|
|
3
|
+
function isTruthy(v) {
|
|
4
|
+
const s = (v || "").trim().toLowerCase();
|
|
5
|
+
return s === "1" || s === "true" || s === "yes" || s === "on";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the active dashboard token, or null when token mode is off. A pinned
|
|
9
|
+
* token wins over a random one so operators can share a stable URL; an empty or
|
|
10
|
+
* whitespace-only pin is ignored (falls back to a random token).
|
|
11
|
+
*/
|
|
12
|
+
export function resolveDashboardToken(cfg) {
|
|
13
|
+
if (cfg.authEnabled)
|
|
14
|
+
return null; // real auth is the gate
|
|
15
|
+
if (isTruthy(cfg.optOut))
|
|
16
|
+
return null; // explicit opt-out (Host guard still applies)
|
|
17
|
+
const pinned = (cfg.pinnedToken || "").trim();
|
|
18
|
+
if (pinned)
|
|
19
|
+
return pinned;
|
|
20
|
+
return randomBytes(32).toString("hex");
|
|
21
|
+
}
|
|
22
|
+
/** Extract the prism_dashboard_token cookie value, if present. */
|
|
23
|
+
export function tokenFromCookie(cookieHeader) {
|
|
24
|
+
// Capture the whole value (any run of non-";", non-space) so pinned tokens
|
|
25
|
+
// with hyphens/underscores match; the name is anchored to start-or-"; " so a
|
|
26
|
+
// look-alike cookie (evil_prism_dashboard_token=…) cannot match.
|
|
27
|
+
const m = (cookieHeader || "").match(/(?:^|;\s*)prism_dashboard_token=([^;\s]+)/);
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* True when the request presents the active token via the cookie, the
|
|
32
|
+
* X-Prism-Dashboard-Token header, or a ?token= query param. Every comparison is
|
|
33
|
+
* timing-safe and only runs against the single active token.
|
|
34
|
+
*/
|
|
35
|
+
export function requestHasToken(headers, queryToken, activeToken) {
|
|
36
|
+
const candidates = [tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
|
|
37
|
+
return candidates.some((c) => c !== null && safeCompare(c, activeToken));
|
|
38
|
+
}
|
|
39
|
+
/** Build the Set-Cookie value that stores the token for a browser session. */
|
|
40
|
+
export function buildTokenCookie(token, maxAgeMs, secure) {
|
|
41
|
+
return (`prism_dashboard_token=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
|
|
42
|
+
`Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host / Origin allow-listing for the Mind Palace dashboard.
|
|
3
|
+
*
|
|
4
|
+
* GHSA-9cvx-7x8q-3g6m: the dashboard auto-starts on every MCP boot, binds
|
|
5
|
+
* loopback, and by default runs with auth disabled. Loopback binding does NOT
|
|
6
|
+
* stop DNS rebinding — a page the developer merely visits can rebind its own
|
|
7
|
+
* hostname to 127.0.0.1 and reach this server as "same-origin", carrying an
|
|
8
|
+
* attacker-chosen Host/Origin. The server must therefore reject any request
|
|
9
|
+
* whose Host (or, when present, Origin) is not a trusted local name or the
|
|
10
|
+
* operator-configured public origin, BEFORE any route runs and independent of
|
|
11
|
+
* whether auth is configured. This mirrors the standard fix for the class
|
|
12
|
+
* (Host-header validation, cf. CVE-2025-10193).
|
|
13
|
+
*
|
|
14
|
+
* Matching by hostname only (port-agnostic) is deliberate and safe: the attack
|
|
15
|
+
* requires an attacker-controlled *name*, so `localhost` / `127.0.0.1` / `[::1]`
|
|
16
|
+
* are trustworthy on any port — which also keeps the guard correct when the
|
|
17
|
+
* server falls back to PORT+1/PORT+2 on an address-in-use conflict.
|
|
18
|
+
*/
|
|
19
|
+
/** Loopback host names browsers use for the local dashboard. Exact match only. */
|
|
20
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
21
|
+
/**
|
|
22
|
+
* Lowercased hostname (never the port) of a Host authority (`host[:port]`) or a
|
|
23
|
+
* full Origin URL. Returns null when the value cannot be parsed — a malformed or
|
|
24
|
+
* opaque value (e.g. the literal `null` Origin) is never trusted.
|
|
25
|
+
*/
|
|
26
|
+
function hostnameOf(value, asUrl = false) {
|
|
27
|
+
try {
|
|
28
|
+
const u = asUrl ? new URL(value) : new URL(`http://${value}`);
|
|
29
|
+
return u.hostname.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The set of hostnames that may be served: loopback + configured origin. */
|
|
36
|
+
function trustedHostnames(cfg) {
|
|
37
|
+
const names = new Set(LOOPBACK_HOSTNAMES);
|
|
38
|
+
if (cfg.configuredOrigin) {
|
|
39
|
+
const h = hostnameOf(cfg.configuredOrigin, true);
|
|
40
|
+
if (h)
|
|
41
|
+
names.add(h);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* True when a request target addresses a sensitive dashboard route — the memory
|
|
47
|
+
* API (`/api/*`) or the MCP HTTP transport (`/sse`, `/messages`) — and must
|
|
48
|
+
* therefore pass the Host/Origin gate.
|
|
49
|
+
*
|
|
50
|
+
* Scoping MUST use the same normalized pathname the router resolves, or a
|
|
51
|
+
* dot-segment target like `/x/../api/settings` slips past a raw-string prefix
|
|
52
|
+
* check while the router still collapses it to `/api/settings` and serves the
|
|
53
|
+
* data. A fixed base keeps this independent of the attacker-supplied Host; an
|
|
54
|
+
* unparseable target is treated as guarded (fail closed). Public routes (the
|
|
55
|
+
* static shell, PWA assets, the Smithery manifest) carry no session data and
|
|
56
|
+
* are intentionally out of scope.
|
|
57
|
+
*/
|
|
58
|
+
export function isRebindGuardedPath(requestTarget) {
|
|
59
|
+
let pathname;
|
|
60
|
+
try {
|
|
61
|
+
pathname = new URL(requestTarget || "/", "http://prism-dashboard.invalid").pathname;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return true; // unparseable target → fail closed
|
|
65
|
+
}
|
|
66
|
+
return pathname.startsWith("/api/") || pathname === "/sse" || pathname === "/messages";
|
|
67
|
+
}
|
|
68
|
+
/** True when the HTTP Host header names a trusted local or configured host. */
|
|
69
|
+
export function isTrustedHost(hostHeader, cfg) {
|
|
70
|
+
if (!hostHeader)
|
|
71
|
+
return false; // HTTP/1.1 requires Host; absence is not trusted.
|
|
72
|
+
const name = hostnameOf(hostHeader);
|
|
73
|
+
return name !== null && trustedHostnames(cfg).has(name);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* True when the Origin header (if the request carries one) is trusted. A request
|
|
77
|
+
* with no Origin — a top-level navigation or a non-CORS GET — is not rejected on
|
|
78
|
+
* Origin grounds; the Host check is the load-bearing gate there.
|
|
79
|
+
*/
|
|
80
|
+
export function isTrustedOrigin(originHeader, cfg) {
|
|
81
|
+
if (!originHeader)
|
|
82
|
+
return true; // absent Origin defers to the Host check
|
|
83
|
+
const name = hostnameOf(originHeader, true);
|
|
84
|
+
return name !== null && trustedHostnames(cfg).has(name);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Combined DNS-rebinding gate: the request must pass BOTH the Host and the
|
|
88
|
+
* (when present) Origin check. Call this before serving any sensitive route.
|
|
89
|
+
*/
|
|
90
|
+
export function isTrustedRequest(headers, cfg) {
|
|
91
|
+
return isTrustedHost(headers.host, cfg) && isTrustedOrigin(headers.origin, cfg);
|
|
92
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -33,6 +33,8 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
|
|
|
33
33
|
import { redactSettings } from "../tools/commonHelpers.js";
|
|
34
34
|
import { handleGraphRoutes } from "./graphRouter.js";
|
|
35
35
|
import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
|
|
36
|
+
import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
|
|
37
|
+
import { resolveDashboardToken, requestHasToken, buildTokenCookie, } from "./dashboardToken.js";
|
|
36
38
|
import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
|
|
37
39
|
const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
|
|
38
40
|
/** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
|
|
@@ -95,6 +97,16 @@ export async function startDashboardServer() {
|
|
|
95
97
|
}
|
|
96
98
|
const SESSION_TTL_MS = parseInt(process.env.PRISM_SESSION_TTL_MS ?? String(24 * 60 * 60 * 1000), 10);
|
|
97
99
|
const activeSessions = new Map();
|
|
100
|
+
// ─── SECURITY: default-on dashboard token (GHSA-9cvx-7x8q-3g6m, remediation #2) ───
|
|
101
|
+
// Null when real auth is configured (that is the gate) or explicitly opted
|
|
102
|
+
// out; otherwise a random per-startup secret gates the data API as a second
|
|
103
|
+
// layer beneath the Host guard. Surfaced only in the startup log below.
|
|
104
|
+
const DASHBOARD_TOKEN = resolveDashboardToken({
|
|
105
|
+
authEnabled: AUTH_ENABLED,
|
|
106
|
+
pinnedToken: process.env.PRISM_DASHBOARD_TOKEN,
|
|
107
|
+
optOut: process.env.PRISM_DASHBOARD_NO_TOKEN,
|
|
108
|
+
});
|
|
109
|
+
const COOKIE_SECURE = !!process.env.PRISM_DASHBOARD_ORIGIN?.startsWith("https://") || !!process.env.PRISM_DASHBOARD_SECURE;
|
|
98
110
|
// Auth config object — injectable for testing via authUtils.ts
|
|
99
111
|
const authConfig = {
|
|
100
112
|
authEnabled: AUTH_ENABLED,
|
|
@@ -199,8 +211,49 @@ return false;}
|
|
|
199
211
|
res.writeHead(204);
|
|
200
212
|
return res.end();
|
|
201
213
|
}
|
|
214
|
+
// ─── SECURITY: Host / Origin allow-list (GHSA-9cvx-7x8q-3g6m) ───
|
|
215
|
+
// Reject DNS-rebinding requests before any sensitive route runs, independent
|
|
216
|
+
// of AUTH_ENABLED. A rebound browser reaches this loopback server carrying an
|
|
217
|
+
// attacker-controlled Host/Origin; only a trusted local name or the operator-
|
|
218
|
+
// configured PRISM_DASHBOARD_ORIGIN may be served the memory API / MCP
|
|
219
|
+
// transport. The public Smithery manifest (/.well-known/...) is intentionally
|
|
220
|
+
// left out of scope — it exposes no session data.
|
|
221
|
+
if (isRebindGuardedPath(req.url) &&
|
|
222
|
+
!isTrustedRequest({ host: req.headers.host, origin: req.headers.origin }, { configuredOrigin: process.env.PRISM_DASHBOARD_ORIGIN })) {
|
|
223
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
224
|
+
return res.end(JSON.stringify({ error: "Forbidden: untrusted Host or Origin (possible DNS rebinding)" }));
|
|
225
|
+
}
|
|
202
226
|
// ─── v5.1: Auth login endpoint (always accessible) ───
|
|
203
227
|
const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
228
|
+
// ─── SECURITY: dashboard token gate (GHSA-9cvx-7x8q-3g6m, remediation #2) ───
|
|
229
|
+
// Second layer beneath the Host guard. Inert when DASHBOARD_TOKEN is null
|
|
230
|
+
// (real auth configured, or opted out). A page load carrying a valid ?token=
|
|
231
|
+
// is bootstrapped into a SameSite cookie so the SPA's later same-origin
|
|
232
|
+
// fetches authenticate transparently; the data API otherwise requires the
|
|
233
|
+
// token via cookie, X-Prism-Dashboard-Token header, or ?token= query.
|
|
234
|
+
if (DASHBOARD_TOKEN) {
|
|
235
|
+
const qToken = reqUrl.searchParams.get("token");
|
|
236
|
+
const isApiPath = reqUrl.pathname.startsWith("/api/");
|
|
237
|
+
if (!isApiPath && qToken && safeCompare(qToken, DASHBOARD_TOKEN)) {
|
|
238
|
+
reqUrl.searchParams.delete("token");
|
|
239
|
+
const cleanTarget = reqUrl.pathname + (reqUrl.search ? reqUrl.search : "");
|
|
240
|
+
res.writeHead(302, {
|
|
241
|
+
"Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE),
|
|
242
|
+
Location: cleanTarget,
|
|
243
|
+
});
|
|
244
|
+
return res.end();
|
|
245
|
+
}
|
|
246
|
+
if (isApiPath &&
|
|
247
|
+
!requestHasToken({
|
|
248
|
+
cookie: req.headers.cookie,
|
|
249
|
+
headerToken: req.headers["x-prism-dashboard-token"] || null,
|
|
250
|
+
}, qToken, DASHBOARD_TOKEN)) {
|
|
251
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
252
|
+
return res.end(JSON.stringify({
|
|
253
|
+
error: "Dashboard token required — open the tokenized URL printed in the Prism startup log.",
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
204
257
|
if (AUTH_ENABLED && reqUrl.pathname === "/api/auth/login" && req.method === "POST") {
|
|
205
258
|
// v6.5.1: Rate limiting — prevent brute-force attacks
|
|
206
259
|
const clientIP = (req.socket?.remoteAddress || "unknown").replace(/^::ffff:/, "");
|
|
@@ -260,7 +313,8 @@ return false;}
|
|
|
260
313
|
version: SERVER_CONFIG.version,
|
|
261
314
|
},
|
|
262
315
|
authentication: {
|
|
263
|
-
|
|
316
|
+
// True when either configured auth or the default token gate is active.
|
|
317
|
+
required: AUTH_ENABLED || !!DASHBOARD_TOKEN
|
|
264
318
|
},
|
|
265
319
|
configSchema: {
|
|
266
320
|
type: "object",
|
|
@@ -1406,7 +1460,14 @@ self.addEventListener('message', (e) => {
|
|
|
1406
1460
|
catch {
|
|
1407
1461
|
// Non-fatal — just means the user has to know the port
|
|
1408
1462
|
}
|
|
1409
|
-
|
|
1463
|
+
if (DASHBOARD_TOKEN) {
|
|
1464
|
+
console.error(`[Prism] 🔐 Mind Palace Dashboard → http://localhost:${boundPort}/?token=${DASHBOARD_TOKEN}`);
|
|
1465
|
+
console.error(`[Prism] Data API is token-gated by default (GHSA-9cvx-7x8q-3g6m). Open the URL above once; ` +
|
|
1466
|
+
`pin it with PRISM_DASHBOARD_TOKEN, or disable with PRISM_DASHBOARD_NO_TOKEN=1.`);
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
|
|
1470
|
+
}
|
|
1410
1471
|
// ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
|
|
1411
1472
|
// NOTE (v5.4): The Background Scheduler in server.ts now also handles
|
|
1412
1473
|
// TTL sweeps. This dashboard sweep is kept as a legacy fallback for
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production wiring for model convergence — real Ollama, real registry.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of cli.ts so the pure logic (utils/modelConverge.ts) stays
|
|
5
|
+
* testable with injected deps, and out of utils/ because this half owns
|
|
6
|
+
* process spawning. `ollama pull` runs with inherited stdio deliberately:
|
|
7
|
+
* a multi-GB download with no progress bar reads as a hang, and ollama's
|
|
8
|
+
* own progress output is better than anything we would reimplement.
|
|
9
|
+
*
|
|
10
|
+
* Adversarial review 2026-08-18 (pre-20.14.0), two findings fixed here:
|
|
11
|
+
*
|
|
12
|
+
* 1. SPLIT-BRAIN DAEMON TARGETING: listTags read /api/tags from
|
|
13
|
+
* PRISM_LOCAL_LLM_URL while the spawned `ollama` CLI targeted whatever
|
|
14
|
+
* OLLAMA_HOST the user's shell had (default localhost). On a machine
|
|
15
|
+
* with a remote Ollama, convergence read tags from one daemon and
|
|
16
|
+
* pulled/re-aliased against another. The spawn env now pins OLLAMA_HOST
|
|
17
|
+
* to the SAME url the tags came from — one daemon, one truth.
|
|
18
|
+
*
|
|
19
|
+
* 2. SILENT MULTI-GB DOWNLOADS: connect gave no warning before pulling.
|
|
20
|
+
* Measured the day this shipped: the registry 27b had just changed
|
|
21
|
+
* bytes, so every 27b holder's first converge pulls ~16 GB. The plan
|
|
22
|
+
* is now announced up front, with the --no-models escape hatch named,
|
|
23
|
+
* before any network transfer starts.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { convergeModels, MODEL_NAMESPACE, LOCAL_PREFIX, CONVERGE_TIERS } from "./utils/modelConverge.js";
|
|
27
|
+
const OLLAMA_URL = process.env.PRISM_LOCAL_LLM_URL || "http://localhost:11434";
|
|
28
|
+
/**
|
|
29
|
+
* Env for spawned `ollama` processes. OLLAMA_HOST is pinned to the same
|
|
30
|
+
* daemon /api/tags was read from — never the shell's ambient value — so
|
|
31
|
+
* list, pull, and cp can never disagree about which Ollama they act on.
|
|
32
|
+
* Exported for tests.
|
|
33
|
+
*/
|
|
34
|
+
export function convergeEnv(base, ollamaUrl = OLLAMA_URL) {
|
|
35
|
+
return { ...base, OLLAMA_HOST: ollamaUrl };
|
|
36
|
+
}
|
|
37
|
+
function runOllama(args, inheritStdio) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn("ollama", args, {
|
|
40
|
+
stdio: inheritStdio ? "inherit" : "ignore",
|
|
41
|
+
env: convergeEnv(process.env),
|
|
42
|
+
});
|
|
43
|
+
child.on("error", (err) => reject(new Error(`ollama ${args[0]}: ${err.message}`)));
|
|
44
|
+
child.on("close", (code) => {
|
|
45
|
+
if (code === 0)
|
|
46
|
+
resolve();
|
|
47
|
+
else
|
|
48
|
+
reject(new Error(`ollama ${args.join(" ")} exited ${code}`));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function runOllamaConverge(opts = {}) {
|
|
53
|
+
// Announce the plan BEFORE any network transfer: which tiers are
|
|
54
|
+
// installed (and will be checked), that pulls can be large, and how to
|
|
55
|
+
// opt out. A 16 GB download must never be the first sign convergence
|
|
56
|
+
// is running.
|
|
57
|
+
let installedTiers = [];
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5_000) });
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
const data = (await res.json());
|
|
62
|
+
const names = new Set((data.models ?? []).map((m) => m.name));
|
|
63
|
+
installedTiers = CONVERGE_TIERS.filter((t) => names.has(`${MODEL_NAMESPACE}:${t}`) || names.has(`${LOCAL_PREFIX}:${t}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// convergeModels handles the unreachable case with its own message
|
|
68
|
+
}
|
|
69
|
+
if (installedTiers.length > 0) {
|
|
70
|
+
console.log(`\nConverging ${installedTiers.length} installed model tier(s) [${installedTiers.join(", ")}] against the registry.`);
|
|
71
|
+
console.log(" Updated models download in full (can be multiple GB). Skip with: prism connect --no-models");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log("\nConverging local models against the registry…");
|
|
75
|
+
}
|
|
76
|
+
const outcomes = await convergeModels({
|
|
77
|
+
listTags: async () => {
|
|
78
|
+
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5_000) });
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
throw new Error(`Ollama /api/tags HTTP ${res.status}`);
|
|
81
|
+
const data = (await res.json());
|
|
82
|
+
return (data.models ?? []).map((m) => ({ name: m.name, digest: m.digest }));
|
|
83
|
+
},
|
|
84
|
+
pull: (ref) => runOllama(["pull", ref], true),
|
|
85
|
+
copy: (from, to) => runOllama(["cp", from, to], false),
|
|
86
|
+
log: (line) => console.log(` ${line}`),
|
|
87
|
+
dryRun: opts.dryRun,
|
|
88
|
+
});
|
|
89
|
+
const failed = outcomes.filter((o) => o.action === "failed" && o.detail !== "ollama_unreachable");
|
|
90
|
+
if (failed.length > 0) {
|
|
91
|
+
console.log(` ⚠ ${failed.length} tier(s) did not converge — re-run \`prism update-models\` when the network allows`);
|
|
92
|
+
}
|
|
93
|
+
return outcomes;
|
|
94
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model convergence for `prism connect` — the missing half of self-update.
|
|
3
|
+
*
|
|
4
|
+
* selfUpdate.ts converges the PACKAGE; nothing converged the MODELS. The gap
|
|
5
|
+
* was measured on 2026-08-18: the vision-restored artifacts had been on the
|
|
6
|
+
* Ollama registry for four days while a laptop that pulled earlier kept
|
|
7
|
+
* refusing image requests (layer1_classifier_no_vision) — and on the machine
|
|
8
|
+
* where this was written, the `prism-coder:2b` alias pointed at different
|
|
9
|
+
* bytes than `dcostenco/prism-coder:2b`, because `ollama cp` makes a
|
|
10
|
+
* SNAPSHOT: a re-pull updates the source name and silently leaves every
|
|
11
|
+
* alias behind.
|
|
12
|
+
*
|
|
13
|
+
* Design decisions, in order of importance:
|
|
14
|
+
*
|
|
15
|
+
* 1. DELEGATE freshness to `ollama pull`. The registry serves no
|
|
16
|
+
* docker-content-digest header and its manifest body hash is not the
|
|
17
|
+
* digest ollama reports locally, so a client-side digest comparison
|
|
18
|
+
* would reimplement (and drift from) ollama's own logic. `ollama pull`
|
|
19
|
+
* of an up-to-date tag is a manifest check that downloads nothing.
|
|
20
|
+
*
|
|
21
|
+
* 2. REPAIR the alias after every pull. `prism-coder:<t>` must equal
|
|
22
|
+
* `dcostenco/prism-coder:<t>` byte-for-byte; when digests differ, re-cp.
|
|
23
|
+
* This is the trap register-models cannot fix on its own — it only
|
|
24
|
+
* aliases tags that are MISSING, not tags that are stale.
|
|
25
|
+
*
|
|
26
|
+
* 3. Converge only tiers the machine already has (either the alias or the
|
|
27
|
+
* namespaced source). Convergence updates what you chose to install; it
|
|
28
|
+
* never decides that a 16 GB model belongs on your laptop.
|
|
29
|
+
*
|
|
30
|
+
* 4. Models must never break connect. Ollama down, registry unreachable,
|
|
31
|
+
* one tier failing — report and continue. A machine with stale models
|
|
32
|
+
* and fresh config beats a machine with neither.
|
|
33
|
+
*/
|
|
34
|
+
export const MODEL_NAMESPACE = "dcostenco/prism-coder";
|
|
35
|
+
export const LOCAL_PREFIX = "prism-coder";
|
|
36
|
+
export const CONVERGE_TIERS = ["2b", "4b", "9b", "27b"];
|
|
37
|
+
export async function convergeModels(deps) {
|
|
38
|
+
let tags;
|
|
39
|
+
try {
|
|
40
|
+
tags = await deps.listTags();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
deps.log(`− model convergence skipped: Ollama unreachable (${err instanceof Error ? err.message : String(err)})`);
|
|
44
|
+
return CONVERGE_TIERS.map((tier) => ({ tier, action: "failed", detail: "ollama_unreachable" }));
|
|
45
|
+
}
|
|
46
|
+
const byName = new Map(tags.map((t) => [t.name, t]));
|
|
47
|
+
const outcomes = [];
|
|
48
|
+
for (const tier of CONVERGE_TIERS) {
|
|
49
|
+
const source = `${MODEL_NAMESPACE}:${tier}`;
|
|
50
|
+
const alias = `${LOCAL_PREFIX}:${tier}`;
|
|
51
|
+
const hadSource = byName.has(source);
|
|
52
|
+
const hadAlias = byName.has(alias);
|
|
53
|
+
if (!hadSource && !hadAlias) {
|
|
54
|
+
outcomes.push({ tier, action: "skipped_not_installed" });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (deps.dryRun) {
|
|
58
|
+
deps.log(`• would pull ${source} and repair the ${alias} alias if stale`);
|
|
59
|
+
outcomes.push({ tier, action: "up_to_date", detail: "dry_run" });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const digestBefore = byName.get(source)?.digest;
|
|
64
|
+
await deps.pull(source);
|
|
65
|
+
// Re-list AFTER the pull: both the freshness answer and the alias
|
|
66
|
+
// comparison must come from post-pull state, or a pull that
|
|
67
|
+
// changed bytes looks identical to one that did nothing.
|
|
68
|
+
const after = await deps.listTags();
|
|
69
|
+
const afterByName = new Map(after.map((t) => [t.name, t]));
|
|
70
|
+
const sourceNow = afterByName.get(source);
|
|
71
|
+
const aliasNow = afterByName.get(alias);
|
|
72
|
+
if (!sourceNow) {
|
|
73
|
+
outcomes.push({ tier, action: "failed", detail: "source_missing_after_pull" });
|
|
74
|
+
deps.log(`⚠ ${source}: pull reported success but the tag is not installed`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const pulledNewBytes = digestBefore !== undefined && digestBefore !== sourceNow.digest;
|
|
78
|
+
const aliasStale = !aliasNow || aliasNow.digest !== sourceNow.digest;
|
|
79
|
+
if (aliasStale) {
|
|
80
|
+
await deps.copy(source, alias);
|
|
81
|
+
const action = pulledNewBytes || !hadSource ? "pulled_and_aliased" : "aliased_only";
|
|
82
|
+
outcomes.push({ tier, action });
|
|
83
|
+
deps.log(`✓ ${alias} ${aliasNow ? "re-aliased (was a stale snapshot)" : "aliased"} → ${sourceNow.digest.slice(0, 12)}`);
|
|
84
|
+
}
|
|
85
|
+
else if (pulledNewBytes) {
|
|
86
|
+
// Alias digest already matches the fresh source — cp raced us
|
|
87
|
+
// or the user re-aliased by hand; either way converged.
|
|
88
|
+
outcomes.push({ tier, action: "pulled_and_aliased" });
|
|
89
|
+
deps.log(`✓ ${source} updated; ${alias} already matches`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
outcomes.push({ tier, action: "up_to_date" });
|
|
93
|
+
deps.log(`= ${alias} up to date (${sourceNow.digest.slice(0, 12)})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
outcomes.push({ tier, action: "failed", detail: err instanceof Error ? err.message : String(err) });
|
|
98
|
+
deps.log(`⚠ ${source}: ${err instanceof Error ? err.message : String(err)} — continuing with the other tiers`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return outcomes;
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.15.0",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
|
|
6
6
|
"module": "index.ts",
|