prism-mcp-server 20.11.0 → 20.12.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 +38 -0
- package/dist/autoUpdate.js +233 -0
- package/dist/cli.js +45 -0
- package/dist/server.js +15 -0
- package/dist/tools/ledgerHandlers.js +32 -16
- package/dist/updateNotice.js +104 -0
- package/dist/utils/projectResolver.js +38 -5
- package/package.json +1 -1
- package/scripts/dev/browse.py +25 -5
package/README.md
CHANGED
|
@@ -116,6 +116,44 @@ 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.0
|
|
120
|
+
|
|
121
|
+
- **Prism now tells you when it's out of date.** Session startup shows a
|
|
122
|
+
one-line update notice when a newer release exists — cache-backed, at most
|
|
123
|
+
one registry check per day, silent offline. `PRISM_NO_UPDATE_CHECK=1`
|
|
124
|
+
opts out.
|
|
125
|
+
- **Hands-free updates, if you want them.** `prism autoupdate enable` sets up
|
|
126
|
+
a daily `prism update --if-idle`: it updates only the global npm package,
|
|
127
|
+
defers while any Prism server is running, and never touches host
|
|
128
|
+
configuration — that stays behind a visible `prism connect`.
|
|
129
|
+
|
|
130
|
+
## What's New in v20.11.1
|
|
131
|
+
|
|
132
|
+
- **Saving memory never gets refused.** The save path used to reject
|
|
133
|
+
`session_save_ledger`/`save_handoff` calls when its path-to-project
|
|
134
|
+
heuristic disagreed with the project you declared — and the registry the
|
|
135
|
+
heuristic trusted could contain junk from earlier auto-registration, so
|
|
136
|
+
legitimate sessions ended unsaved. Your declaration now always wins; the
|
|
137
|
+
disagreement is returned as an advisory warning, and auto-registration
|
|
138
|
+
only accepts real repository roots.
|
|
139
|
+
- **Screenshots are evidence again.** `prism browser` captures on macOS were
|
|
140
|
+
silently *upscaled* to the size cap, so a screenshot no longer showed what
|
|
141
|
+
actually rendered. Only genuinely oversized captures are resized now, and
|
|
142
|
+
the cap no longer clips a standard 1920-wide viewport.
|
|
143
|
+
|
|
144
|
+
## What's New in v20.10.0 – v20.11.0
|
|
145
|
+
|
|
146
|
+
- **Skill routing now works mid-session.** New prompts are matched on-device
|
|
147
|
+
as the conversation moves — not just on turn one — and injected within each
|
|
148
|
+
host's real context limits (Claude Code caps hook output at 10k chars;
|
|
149
|
+
Codex truncates by default), with pointer-first delivery when a payload
|
|
150
|
+
can't fit inline.
|
|
151
|
+
- **`prism connect` is a converge command.** It self-updates first, re-execs,
|
|
152
|
+
then reconciles MCP registration, skills, and hooks — no more
|
|
153
|
+
"fresh config, stale code" machines.
|
|
154
|
+
- **Scoped skills route on prompts too**, and startup output survives hosts
|
|
155
|
+
that discard structured tool content.
|
|
156
|
+
|
|
119
157
|
## What's New in v20.9.0 – v20.9.3
|
|
120
158
|
|
|
121
159
|
- **Your skills follow your account.** `skill_save` stores a skill at the
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prism update` / `prism autoupdate` — unattended-safe package updates.
|
|
3
|
+
*
|
|
4
|
+
* Why this is NOT `prism connect` on a timer (design review 2026-08-14):
|
|
5
|
+
* connect writes host configuration and expects hosts to be closed — a
|
|
6
|
+
* condition no scheduler can guarantee. This module's whole authority is
|
|
7
|
+
* `npm install -g prism-mcp-server@<latest>`:
|
|
8
|
+
* - host configuration, hooks, and hook trust are untouchable from here —
|
|
9
|
+
* there is no code path to them;
|
|
10
|
+
* - running server processes keep the code they booted with; the next
|
|
11
|
+
* process start picks up the new release (global registrations resolve
|
|
12
|
+
* through the bin symlink);
|
|
13
|
+
* - `--if-idle` defers entirely while any Prism MCP server process is
|
|
14
|
+
* alive, and an UNVERIFIABLE process list counts as busy — fail safe;
|
|
15
|
+
* - a single-instance lock prevents overlapping npm runs (a scheduler
|
|
16
|
+
* firing while an operator updates by hand).
|
|
17
|
+
*
|
|
18
|
+
* Configuration migrations stay behind a visible `prism connect` run.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { isNewer } from "./selfUpdate.js";
|
|
25
|
+
const PACKAGE = "prism-mcp-server";
|
|
26
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
27
|
+
export const AUTOUPDATE_LABEL = "com.synalux.prism.autoupdate";
|
|
28
|
+
function defaultFetchLatest() {
|
|
29
|
+
return execFileSync("npm", ["view", PACKAGE, "version"], {
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
timeout: 15_000,
|
|
32
|
+
}).trim();
|
|
33
|
+
}
|
|
34
|
+
function defaultInstall(version) {
|
|
35
|
+
execFileSync("npm", ["install", "-g", `${PACKAGE}@${version}`], {
|
|
36
|
+
stdio: "inherit",
|
|
37
|
+
timeout: 300_000,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Lines for live Prism MCP server processes. Throws when `ps` is unusable —
|
|
41
|
+
* the caller treats that as busy, never as idle. */
|
|
42
|
+
export function defaultListPrismProcesses() {
|
|
43
|
+
const out = execFileSync("ps", ["-axo", "pid=,command="], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
timeout: 10_000,
|
|
46
|
+
});
|
|
47
|
+
return out
|
|
48
|
+
.split("\n")
|
|
49
|
+
.filter((line) => /server\.js/.test(line) && /prism/i.test(line))
|
|
50
|
+
.map((line) => line.trim());
|
|
51
|
+
}
|
|
52
|
+
const LOCK_DIR = () => join(homedir(), ".prism-mcp");
|
|
53
|
+
const LOCK_FILE = () => join(LOCK_DIR(), "update.lock");
|
|
54
|
+
/** O_EXCL lockfile with stale-holder recovery (dead pid → reclaim once). */
|
|
55
|
+
export function defaultAcquireLock() {
|
|
56
|
+
mkdirSync(LOCK_DIR(), { recursive: true });
|
|
57
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = openSync(LOCK_FILE(), "wx");
|
|
60
|
+
writeFileSync(fd, String(process.pid));
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
return () => { try {
|
|
63
|
+
rmSync(LOCK_FILE(), { force: true });
|
|
64
|
+
}
|
|
65
|
+
catch { /* released is released */ } };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
try {
|
|
69
|
+
const holder = parseInt(readFileSync(LOCK_FILE(), "utf8").trim(), 10);
|
|
70
|
+
if (Number.isInteger(holder)) {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(holder, 0); // alive → genuinely locked
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
rmSync(LOCK_FILE(), { force: true }); // stale → reclaim and retry
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
rmSync(LOCK_FILE(), { force: true }); // unreadable holder → reclaim
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
export function runPackageUpdate(deps) {
|
|
90
|
+
const env = deps.env ?? process.env;
|
|
91
|
+
const log = deps.log ?? (() => { });
|
|
92
|
+
if (env.PRISM_NO_SELF_UPDATE === "1") {
|
|
93
|
+
return { action: "skipped", detail: "PRISM_NO_SELF_UPDATE=1" };
|
|
94
|
+
}
|
|
95
|
+
if ((env.VITEST || env.NODE_ENV === "test") && !deps.fetchLatest) {
|
|
96
|
+
return { action: "skipped", detail: "test environment" };
|
|
97
|
+
}
|
|
98
|
+
if (deps.currentVersion.includes("-")) {
|
|
99
|
+
return { action: "skipped", detail: `dev build ${deps.currentVersion} — not touching it` };
|
|
100
|
+
}
|
|
101
|
+
if (deps.ifIdle) {
|
|
102
|
+
let running;
|
|
103
|
+
try {
|
|
104
|
+
running = (deps.listPrismProcesses ?? defaultListPrismProcesses)();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
action: "deferred",
|
|
109
|
+
detail: `cannot verify idleness (${error instanceof Error ? error.message.split("\n")[0] : String(error)}) — deferring`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (running.length > 0) {
|
|
113
|
+
return {
|
|
114
|
+
action: "deferred",
|
|
115
|
+
detail: `${running.length} Prism MCP process(es) running — deferred until idle`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const release = (deps.acquireLock ?? defaultAcquireLock)();
|
|
120
|
+
if (!release) {
|
|
121
|
+
return { action: "locked", detail: "another prism update is already running" };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
let latest;
|
|
125
|
+
try {
|
|
126
|
+
latest = (deps.fetchLatest ?? defaultFetchLatest)();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return { action: "failed", detail: `registry unreachable (${error instanceof Error ? error.message.split("\n")[0] : String(error)})` };
|
|
130
|
+
}
|
|
131
|
+
if (!SEMVER.test(latest)) {
|
|
132
|
+
return { action: "failed", detail: `registry returned unexpected version "${latest}"` };
|
|
133
|
+
}
|
|
134
|
+
if (!isNewer(deps.currentVersion, latest)) {
|
|
135
|
+
return { action: "current", detail: `${deps.currentVersion} is current`, latest };
|
|
136
|
+
}
|
|
137
|
+
log(`prism ${deps.currentVersion} → ${latest}: updating the global package …`);
|
|
138
|
+
try {
|
|
139
|
+
(deps.install ?? defaultInstall)(latest);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return { action: "failed", detail: `npm install -g failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`, latest };
|
|
143
|
+
}
|
|
144
|
+
return { action: "updated", detail: `global package now ${latest}; running servers pick it up on their next start`, latest };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
release();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ─── LaunchAgent (macOS) scheduling ──────────────────────────────
|
|
151
|
+
export function autoupdatePlistPath() {
|
|
152
|
+
return join(homedir(), "Library", "LaunchAgents", `${AUTOUPDATE_LABEL}.plist`);
|
|
153
|
+
}
|
|
154
|
+
/** Daily 03:30 local, catch-up on wake (LaunchAgents coalesce missed runs). */
|
|
155
|
+
export function buildAutoupdatePlist(prismBin) {
|
|
156
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
157
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
158
|
+
<plist version="1.0">
|
|
159
|
+
<dict>
|
|
160
|
+
<key>Label</key><string>${AUTOUPDATE_LABEL}</string>
|
|
161
|
+
<key>ProgramArguments</key>
|
|
162
|
+
<array>
|
|
163
|
+
<string>${prismBin}</string>
|
|
164
|
+
<string>update</string>
|
|
165
|
+
<string>--if-idle</string>
|
|
166
|
+
</array>
|
|
167
|
+
<key>StartCalendarInterval</key>
|
|
168
|
+
<dict>
|
|
169
|
+
<key>Hour</key><integer>3</integer>
|
|
170
|
+
<key>Minute</key><integer>30</integer>
|
|
171
|
+
</dict>
|
|
172
|
+
<key>StandardOutPath</key><string>/tmp/${AUTOUPDATE_LABEL}.log</string>
|
|
173
|
+
<key>StandardErrorPath</key><string>/tmp/${AUTOUPDATE_LABEL}.log</string>
|
|
174
|
+
</dict>
|
|
175
|
+
</plist>
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
export function autoupdateStatus() {
|
|
179
|
+
const plistPath = autoupdatePlistPath();
|
|
180
|
+
if (process.platform !== "darwin") {
|
|
181
|
+
return { supported: false, enabled: false, plistPath, detail: "scheduled updates are macOS-only for now (LaunchAgent)" };
|
|
182
|
+
}
|
|
183
|
+
const enabled = existsSync(plistPath);
|
|
184
|
+
return {
|
|
185
|
+
supported: true,
|
|
186
|
+
enabled,
|
|
187
|
+
plistPath,
|
|
188
|
+
detail: enabled ? `enabled — daily 03:30, log: /tmp/${AUTOUPDATE_LABEL}.log` : "disabled",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Resolve the `prism` bin the LaunchAgent should run. Warns (does not
|
|
192
|
+
* refuse) when it resolves outside node_modules — a checkout CLI still
|
|
193
|
+
* updates the global package, but the operator should know which code
|
|
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");
|
|
199
|
+
try {
|
|
200
|
+
const real = execFileSync("readlink", ["-f", bin], { encoding: "utf8", timeout: 5_000 }).trim();
|
|
201
|
+
if (real && !real.includes("node_modules")) {
|
|
202
|
+
log(`⚠ ${bin} resolves to a source checkout (${real}); the scheduled update will run that CLI (it still updates only the global package)`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch { /* readlink unavailable — proceed with the raw path */ }
|
|
206
|
+
return bin;
|
|
207
|
+
}
|
|
208
|
+
export function enableAutoupdate(log) {
|
|
209
|
+
if (process.platform !== "darwin")
|
|
210
|
+
return autoupdateStatus();
|
|
211
|
+
const plistPath = autoupdatePlistPath();
|
|
212
|
+
const bin = resolvePrismBin(log);
|
|
213
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
214
|
+
writeFileSync(plistPath, buildAutoupdatePlist(bin));
|
|
215
|
+
// Reload cleanly whether or not a previous generation was loaded.
|
|
216
|
+
try {
|
|
217
|
+
execFileSync("launchctl", ["unload", plistPath], { timeout: 10_000, stdio: "ignore" });
|
|
218
|
+
}
|
|
219
|
+
catch { /* not loaded */ }
|
|
220
|
+
execFileSync("launchctl", ["load", "-w", plistPath], { timeout: 10_000 });
|
|
221
|
+
return autoupdateStatus();
|
|
222
|
+
}
|
|
223
|
+
export function disableAutoupdate() {
|
|
224
|
+
if (process.platform !== "darwin")
|
|
225
|
+
return autoupdateStatus();
|
|
226
|
+
const plistPath = autoupdatePlistPath();
|
|
227
|
+
try {
|
|
228
|
+
execFileSync("launchctl", ["unload", plistPath], { timeout: 10_000, stdio: "ignore" });
|
|
229
|
+
}
|
|
230
|
+
catch { /* not loaded */ }
|
|
231
|
+
rmSync(plistPath, { force: true });
|
|
232
|
+
return autoupdateStatus();
|
|
233
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -164,6 +164,51 @@ program
|
|
|
164
164
|
program
|
|
165
165
|
.command('browser')
|
|
166
166
|
.description('Run the packaged local Prism Browser automation CLI');
|
|
167
|
+
// ─── prism update / prism autoupdate ──────────────────────────
|
|
168
|
+
// Unattended-safe package updates. Deliberately NOT connect-on-a-timer:
|
|
169
|
+
// connect writes host configuration and expects hosts to be closed, which
|
|
170
|
+
// a scheduler cannot guarantee. `update` touches only the global npm
|
|
171
|
+
// package; running servers pick the release up on their next start.
|
|
172
|
+
program
|
|
173
|
+
.command('update')
|
|
174
|
+
.description('Update the global prism-mcp-server package (never touches host configuration)')
|
|
175
|
+
.option('--if-idle', 'Only update while no Prism MCP server process is running (scheduler-safe)')
|
|
176
|
+
.action(async (options) => {
|
|
177
|
+
const { runPackageUpdate } = await import('./autoUpdate.js');
|
|
178
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
179
|
+
const result = runPackageUpdate({
|
|
180
|
+
currentVersion: pkg.version,
|
|
181
|
+
ifIdle: options.ifIdle,
|
|
182
|
+
log: (l) => console.log(l),
|
|
183
|
+
});
|
|
184
|
+
console.log(`${result.action}: ${result.detail}`);
|
|
185
|
+
process.exit(result.action === 'failed' ? 1 : 0);
|
|
186
|
+
});
|
|
187
|
+
program
|
|
188
|
+
.command('autoupdate <action>')
|
|
189
|
+
.description('Scheduled daily `prism update --if-idle` — enable | disable | status (opt-in, macOS)')
|
|
190
|
+
.action(async (action) => {
|
|
191
|
+
const { enableAutoupdate, disableAutoupdate, autoupdateStatus } = await import('./autoUpdate.js');
|
|
192
|
+
const log = (l) => console.log(l);
|
|
193
|
+
let status;
|
|
194
|
+
if (action === 'enable')
|
|
195
|
+
status = enableAutoupdate(log);
|
|
196
|
+
else if (action === 'disable')
|
|
197
|
+
status = disableAutoupdate();
|
|
198
|
+
else if (action === 'status')
|
|
199
|
+
status = autoupdateStatus();
|
|
200
|
+
else {
|
|
201
|
+
console.error(`unknown action "${action}" — use enable, disable, or status`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (!status.supported) {
|
|
205
|
+
console.log(`− ${status.detail}`);
|
|
206
|
+
process.exit(action === 'status' ? 0 : 1);
|
|
207
|
+
}
|
|
208
|
+
console.log(status.enabled
|
|
209
|
+
? `✓ autoupdate ${status.detail}\n agent: ${status.plistPath}`
|
|
210
|
+
: `− autoupdate ${status.detail}`);
|
|
211
|
+
});
|
|
167
212
|
// ─── prism connect ────────────────────────────────────────────
|
|
168
213
|
// Registers this installed package with supported MCP hosts. The
|
|
169
214
|
// merge is additive: an existing `prism` or `prism-mcp` entry is
|
package/dist/server.js
CHANGED
|
@@ -1293,6 +1293,21 @@ export async function startServer() {
|
|
|
1293
1293
|
runSkillManifestSync();
|
|
1294
1294
|
const skillManifestRefresh = setInterval(runSkillManifestSync, 5 * 60 * 1000);
|
|
1295
1295
|
skillManifestRefresh.unref();
|
|
1296
|
+
// Update-check refresh: 30s AFTER startup on an unref'd timer, so the
|
|
1297
|
+
// registry ping is never on the prompt-critical path and never interleaves
|
|
1298
|
+
// with first-boot storage initialization (a boot-time kick raced the
|
|
1299
|
+
// first-run demo seed on CI — same-instant sqlite init from two paths).
|
|
1300
|
+
// Short-lived spawns (codex exec) exit before the timer fires and never
|
|
1301
|
+
// ping npm; long-lived hosts refresh 30s in, ample for a 24h TTL.
|
|
1302
|
+
// session_bootstrap renders from the persisted cache only.
|
|
1303
|
+
const updateCheckTimer = setTimeout(() => {
|
|
1304
|
+
void (async () => {
|
|
1305
|
+
const { refreshUpdateCache } = await import("./updateNotice.js");
|
|
1306
|
+
const { getSetting, setSetting } = await import("./storage/configStorage.js");
|
|
1307
|
+
await refreshUpdateCache({ getSetting, setSetting });
|
|
1308
|
+
})().catch(() => { });
|
|
1309
|
+
}, 30_000);
|
|
1310
|
+
updateCheckTimer.unref();
|
|
1296
1311
|
// Register graceful shutdown handlers (SIGTERM, SIGINT, SIGHUP, stdin close).
|
|
1297
1312
|
// The stdin close handler is critical — when MCP clients disconnect, they
|
|
1298
1313
|
// often just close the pipe without sending a signal, leaving zombie processes.
|
|
@@ -34,6 +34,18 @@ import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } fro
|
|
|
34
34
|
import { MATERIALIZED_GENERATION_KEY } from "../skillManifestSync.js";
|
|
35
35
|
import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
|
|
36
36
|
import { resolveProject } from "../utils/projectResolver.js";
|
|
37
|
+
import { getUpdateNotice } from "../updateNotice.js";
|
|
38
|
+
// The running server's own version, for the update-available notice. A read
|
|
39
|
+
// failure must never affect startup: an empty string fails the notice's
|
|
40
|
+
// semver gate, so no notice renders rather than a bogus "running 0.0.0".
|
|
41
|
+
const SERVER_PACKAGE_VERSION = (() => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version ?? "";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
37
49
|
import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
|
|
38
50
|
import { PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS } from "../config.js";
|
|
39
51
|
import { captureLocalEnvironment } from "../utils/autoCapture.js";
|
|
@@ -499,25 +511,19 @@ export async function sessionSaveLedgerHandler(args) {
|
|
|
499
511
|
};
|
|
500
512
|
}
|
|
501
513
|
const storage = await getStorage();
|
|
502
|
-
// ─── Project
|
|
503
|
-
//
|
|
504
|
-
//
|
|
514
|
+
// ─── Project validation (v14 — warns, NEVER refuses the write) ───
|
|
515
|
+
// v13 hard-rejected on mismatch, trusting auto-created registry rows that
|
|
516
|
+
// turned out to be junk on a live machine (home-dir and relative-path
|
|
517
|
+
// entries) — agents got contradictory rejections and sessions ended
|
|
518
|
+
// UNSAVED. A memory product must not drop data to enforce taxonomy.
|
|
505
519
|
let resolverNote = "";
|
|
506
520
|
const resolved = await resolveProject(project, files_changed);
|
|
507
|
-
if (!resolved.ok) {
|
|
508
|
-
return {
|
|
509
|
-
content: [{
|
|
510
|
-
type: "text",
|
|
511
|
-
text: `❌ ${resolved.error}\n` +
|
|
512
|
-
(resolved.hint ? `Hint: ${resolved.hint}\n` : "") +
|
|
513
|
-
`\nNo ledger entry was written. Re-issue the call with the correct project.`,
|
|
514
|
-
}],
|
|
515
|
-
isError: true,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
521
|
project = resolved.project;
|
|
522
|
+
if (resolved.warning) {
|
|
523
|
+
resolverNote = `\n⚠️ ${resolved.warning}`;
|
|
524
|
+
}
|
|
519
525
|
if (resolved.autoCreated) {
|
|
520
|
-
resolverNote
|
|
526
|
+
resolverNote += `\n📝 Auto-registered project "${project}" with repo_path derived from files_changed.`;
|
|
521
527
|
}
|
|
522
528
|
debugLog(`[session_save_ledger] Saving ledger entry for project="${project}"`);
|
|
523
529
|
// Auto-extract keywords from summary + decisions for knowledge accumulation
|
|
@@ -2032,7 +2038,17 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
2032
2038
|
? `👋 Welcome to Prism — first run detected. Let's get you productive in a few minutes.`
|
|
2033
2039
|
: `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
|
|
2034
2040
|
const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
|
|
2035
|
-
|
|
2041
|
+
// Cache-only by contract: server startup refreshes the cache asynchronously;
|
|
2042
|
+
// this call never touches the npm registry. First run skips the notice — a
|
|
2043
|
+
// just-installed machine is current, and that screen is action-first.
|
|
2044
|
+
const updateNotice = isFirstRun ? "" : await getUpdateNotice({
|
|
2045
|
+
currentVersion: SERVER_PACKAGE_VERSION,
|
|
2046
|
+
getSetting,
|
|
2047
|
+
runningFrom: process.argv[1],
|
|
2048
|
+
});
|
|
2049
|
+
const startupHeader = isFirstRun
|
|
2050
|
+
? greeting
|
|
2051
|
+
: `${greeting}\n\n${identityBlock}${updateNotice ? `\n${updateNotice}` : ""}`;
|
|
2036
2052
|
if (projects.length === 0) {
|
|
2037
2053
|
const dashboardUrl = await readDashboardUrl();
|
|
2038
2054
|
const dashboardLine = dashboardUrl
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-available notice — the trigger half of self-update.
|
|
3
|
+
*
|
|
4
|
+
* `prism connect` can converge a machine to the latest release, but nothing
|
|
5
|
+
* ever *ran* it: no launch agent, cron, or background updater exists, so
|
|
6
|
+
* releases only reached machines whose operator happened to re-run connect.
|
|
7
|
+
* The notice closes that gap at the only place every user reliably looks —
|
|
8
|
+
* the first turn of a session.
|
|
9
|
+
*
|
|
10
|
+
* Split enforced by design review (2026-08-14):
|
|
11
|
+
* - `getUpdateNotice` is CACHE-ONLY. It runs on the prompt-critical path
|
|
12
|
+
* (session_bootstrap) and must never touch the npm registry.
|
|
13
|
+
* - `refreshUpdateCache` is the async half. Server startup kicks it
|
|
14
|
+
* fire-and-forget after initialization; it respects a 24-hour TTL so a
|
|
15
|
+
* machine pings the registry at most once a day no matter how many
|
|
16
|
+
* short-lived server processes spawn (codex exec starts one per run).
|
|
17
|
+
* - Offline is silent. A registry answer that is not plain semver is
|
|
18
|
+
* discarded, never cached, never rendered — the cached value ends up
|
|
19
|
+
* inside model-facing markdown, so it is validated on write AND on read.
|
|
20
|
+
*/
|
|
21
|
+
import { execFile } from "node:child_process";
|
|
22
|
+
import { realpathSync } from "node:fs";
|
|
23
|
+
import { isNewer } from "./selfUpdate.js";
|
|
24
|
+
export const UPDATE_CHECK_KEY = "update_check";
|
|
25
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
27
|
+
const PACKAGE = "prism-mcp-server";
|
|
28
|
+
async function readCache(getSetting) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await getSetting(UPDATE_CHECK_KEY, "{}"));
|
|
31
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function cacheIsFresh(cache, now) {
|
|
38
|
+
return typeof cache.checked_at === "number" &&
|
|
39
|
+
now() - cache.checked_at < CACHE_TTL_MS &&
|
|
40
|
+
now() - cache.checked_at >= 0; // a future timestamp is corruption, not freshness
|
|
41
|
+
}
|
|
42
|
+
/** Cache-only: renders the notice line, or "" — never touches the network. */
|
|
43
|
+
export async function getUpdateNotice(deps) {
|
|
44
|
+
const env = deps.env ?? process.env;
|
|
45
|
+
if (env.PRISM_NO_UPDATE_CHECK === "1")
|
|
46
|
+
return "";
|
|
47
|
+
// A caller that could not determine its own version renders nothing —
|
|
48
|
+
// a comparison against "" or "0.0.0" would call every release an update.
|
|
49
|
+
if (!SEMVER.test(deps.currentVersion))
|
|
50
|
+
return "";
|
|
51
|
+
const now = deps.now ?? Date.now;
|
|
52
|
+
const cache = await readCache(deps.getSetting);
|
|
53
|
+
if (!cacheIsFresh(cache, now))
|
|
54
|
+
return "";
|
|
55
|
+
const latest = typeof cache.latest === "string" && SEMVER.test(cache.latest) ? cache.latest : "";
|
|
56
|
+
if (!latest || !isNewer(deps.currentVersion, latest))
|
|
57
|
+
return "";
|
|
58
|
+
let fromCheckout = false;
|
|
59
|
+
if (deps.runningFrom) {
|
|
60
|
+
let resolved = deps.runningFrom;
|
|
61
|
+
try {
|
|
62
|
+
resolved = realpathSync(deps.runningFrom);
|
|
63
|
+
}
|
|
64
|
+
catch { /* keep raw */ }
|
|
65
|
+
fromCheckout = !resolved.includes("node_modules");
|
|
66
|
+
}
|
|
67
|
+
const command = fromCheckout ? "prism connect --refresh" : "prism connect";
|
|
68
|
+
return `- ⬆️ **Update available:** Prism ${latest} (running ${deps.currentVersion}) — run \`${command}\``;
|
|
69
|
+
}
|
|
70
|
+
function defaultFetchLatest() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = execFile("npm", ["view", PACKAGE, "version"], { encoding: "utf8", timeout: 10_000 }, (error, stdout) => (error ? reject(error) : resolve(stdout.trim())));
|
|
73
|
+
// A short-lived server process (codex exec) must be able to exit without
|
|
74
|
+
// waiting on this check.
|
|
75
|
+
child.unref?.();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Async half: refresh the persisted cache when the TTL has lapsed.
|
|
79
|
+
* Never throws — offline machines boot silently. */
|
|
80
|
+
export async function refreshUpdateCache(deps) {
|
|
81
|
+
const env = deps.env ?? process.env;
|
|
82
|
+
if (env.PRISM_NO_UPDATE_CHECK === "1")
|
|
83
|
+
return;
|
|
84
|
+
if (env.VITEST || env.NODE_ENV === "test") {
|
|
85
|
+
// Test runners never reach the network — same rule as self-update —
|
|
86
|
+
// EXCEPT through an explicitly injected fetcher, which is the test.
|
|
87
|
+
if (!deps.fetchLatest)
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const now = deps.now ?? Date.now;
|
|
91
|
+
try {
|
|
92
|
+
const cache = await readCache(deps.getSetting);
|
|
93
|
+
if (cacheIsFresh(cache, now))
|
|
94
|
+
return;
|
|
95
|
+
const latest = (await (deps.fetchLatest ?? defaultFetchLatest)()).trim();
|
|
96
|
+
if (!SEMVER.test(latest))
|
|
97
|
+
return;
|
|
98
|
+
await deps.setSetting(UPDATE_CHECK_KEY, JSON.stringify({ checked_at: now(), latest }));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Offline, registry down, npm missing: silence. The notice simply
|
|
102
|
+
// does not render until a later successful check.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -81,12 +81,22 @@ export async function resolveProject(declaredProject, filesChanged) {
|
|
|
81
81
|
}
|
|
82
82
|
const registry = await loadRegistry();
|
|
83
83
|
const derivedProject = pickFromRegistry(registry, filesChanged);
|
|
84
|
+
// 2026-08-13: this used to HARD-REJECT on mismatch — and the registry it
|
|
85
|
+
// trusted was auto-created junk (a live machine carried a repo_path entry
|
|
86
|
+
// pointing at the HOME DIRECTORY, which contains every absolute path, plus
|
|
87
|
+
// five relative-path rows). Agents got contradictory rejections, ping-ponged
|
|
88
|
+
// by the hint, and sessions ended UNSAVED — a memory product dropping data to
|
|
89
|
+
// enforce taxonomy. The declaration now always wins; the derivation is an
|
|
90
|
+
// advisory warning. (The wrong-project class this gate was built for —
|
|
91
|
+
// a 2026-04-30 memory-loss incident — is still surfaced, as a warning
|
|
92
|
+
// the agent sees at save time instead of a refusal the user never does.)
|
|
84
93
|
if (derivedProject && derivedProject !== declaredProject) {
|
|
85
94
|
return {
|
|
86
|
-
ok:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
`
|
|
95
|
+
ok: true,
|
|
96
|
+
project: declaredProject,
|
|
97
|
+
warning: `Registry suggests these files belong to "${derivedProject}", but the entry was saved under ` +
|
|
98
|
+
`"${declaredProject}" as declared. If "${derivedProject}" is correct, re-save with that project; ` +
|
|
99
|
+
`if the registry is wrong, its repo_path for "${derivedProject}" needs repair.`,
|
|
90
100
|
};
|
|
91
101
|
}
|
|
92
102
|
if (derivedProject && derivedProject === declaredProject) {
|
|
@@ -96,7 +106,7 @@ export async function resolveProject(declaredProject, filesChanged) {
|
|
|
96
106
|
return { ok: true, project: declaredProject };
|
|
97
107
|
}
|
|
98
108
|
const prefix = commonPathPrefix(filesChanged);
|
|
99
|
-
if (prefix) {
|
|
109
|
+
if (prefix && isRegistrablePrefix(prefix, registry)) {
|
|
100
110
|
try {
|
|
101
111
|
await setSetting(`${REPO_PATH_PREFIX}${declaredProject}`, prefix);
|
|
102
112
|
debugLog(`[projectResolver] auto-created repo_path:${declaredProject} = ${prefix}`);
|
|
@@ -108,3 +118,26 @@ export async function resolveProject(declaredProject, filesChanged) {
|
|
|
108
118
|
}
|
|
109
119
|
return { ok: true, project: declaredProject };
|
|
110
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Auto-create hygiene, added 2026-08-13. Every class rejected here was FOUND
|
|
123
|
+
* in a live registry, where it poisoned later saves:
|
|
124
|
+
* - relative prefixes ("Tests/UITests") match or miss depending on the path
|
|
125
|
+
* style a later save happens to use;
|
|
126
|
+
* - short prefixes (a 2-segment home directory) contain every absolute
|
|
127
|
+
* path on the machine;
|
|
128
|
+
* - an ancestor of an existing entry re-creates the same containment bomb
|
|
129
|
+
* one level down.
|
|
130
|
+
* Refusing to register is safe: the save proceeds either way, and the next
|
|
131
|
+
* save with a cleaner file list can still register the project.
|
|
132
|
+
*/
|
|
133
|
+
function isRegistrablePrefix(prefix, registry) {
|
|
134
|
+
if (!prefix.startsWith("/"))
|
|
135
|
+
return false;
|
|
136
|
+
if (prefix.split("/").filter(Boolean).length < 3)
|
|
137
|
+
return false;
|
|
138
|
+
for (const entry of registry) {
|
|
139
|
+
if (isUnder(entry.repo_path, prefix))
|
|
140
|
+
return false; // ancestor of an existing entry
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.12.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",
|
package/scripts/dev/browse.py
CHANGED
|
@@ -1259,6 +1259,22 @@ def _enforce_max_edge(path, max_edge):
|
|
|
1259
1259
|
try:
|
|
1260
1260
|
import shutil as _shutil, subprocess as _subprocess
|
|
1261
1261
|
if _shutil.which("sips"):
|
|
1262
|
+
# `sips -Z` resamples in BOTH directions — it UPSCALES an image that
|
|
1263
|
+
# is already smaller than max_edge. Unguarded, every macOS capture
|
|
1264
|
+
# came out at exactly the cap on its long edge: a 1440x900 viewport
|
|
1265
|
+
# was written as 1900x1187 and a 1920x1280 one as 1900x1266. That is
|
|
1266
|
+
# not evidence of what rendered, and it breaks any acceptance gate
|
|
1267
|
+
# that asserts a capture is viewport-bound. Measure first and only
|
|
1268
|
+
# shrink when the image genuinely exceeds the cap — the Pillow branch
|
|
1269
|
+
# below already guards this way.
|
|
1270
|
+
probe = _subprocess.run(
|
|
1271
|
+
["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
|
|
1272
|
+
capture_output=True, timeout=30, text=True)
|
|
1273
|
+
edges = [int(part.split(":")[1].strip())
|
|
1274
|
+
for part in probe.stdout.splitlines()
|
|
1275
|
+
if "pixelWidth:" in part or "pixelHeight:" in part]
|
|
1276
|
+
if edges and max(edges) <= max_edge:
|
|
1277
|
+
return None
|
|
1262
1278
|
_subprocess.run(["sips", "-Z", str(max_edge), str(path)],
|
|
1263
1279
|
capture_output=True, timeout=30, check=True)
|
|
1264
1280
|
return None
|
|
@@ -1306,11 +1322,15 @@ def cmd_screenshot(session, output=None, cleanup=False, full_page=True, selector
|
|
|
1306
1322
|
# oversized capture early in a session poisons every later attach — the
|
|
1307
1323
|
# agent that must LOOK at screenshots loses the ability to see them,
|
|
1308
1324
|
# mid-conversation, permanently. Full-page captures routinely exceed
|
|
1309
|
-
# 2000px in height, so
|
|
1310
|
-
#
|
|
1311
|
-
#
|
|
1312
|
-
#
|
|
1313
|
-
|
|
1325
|
+
# 2000px in height, so oversized captures are shrunk here, at the source.
|
|
1326
|
+
# sips is macOS-only; elsewhere we fall back to Pillow if present and
|
|
1327
|
+
# otherwise WARN LOUDLY rather than emit poison silently.
|
|
1328
|
+
#
|
|
1329
|
+
# 2000, not 1900: the API limit quoted above is 2000px per dimension, and a
|
|
1330
|
+
# 1900 cap sits just under the standard 1920-wide desktop viewport — so the
|
|
1331
|
+
# single most common UI-evidence capture was resampled to 1900x1266 for no
|
|
1332
|
+
# benefit, failing every gate that asserts a capture is viewport-bound.
|
|
1333
|
+
_downscale_warning = _enforce_max_edge(path, 2000)
|
|
1314
1334
|
|
|
1315
1335
|
size = path.stat().st_size
|
|
1316
1336
|
audit_log("screenshot", str(path), f"size={size},ephemeral={cleanup}")
|