prism-mcp-server 20.18.0 → 20.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,14 +86,18 @@ behind it are in the
86
86
  Prism also ships as a plugin, which registers the MCP server and the startup
87
87
  skill for you.
88
88
 
89
- **Claude Code** — from the community marketplace:
89
+ Both hosts install straight from this repository. There is nothing to host and
90
+ no server to run: the catalogue is the `.claude-plugin/marketplace.json` file
91
+ committed here, and your client clones it from GitHub.
92
+
93
+ **Claude Code:**
90
94
 
91
95
  ```bash
92
- /plugin marketplace add anthropics/claude-plugins-community
93
- /plugin install prism-coder@claude-community
96
+ claude plugin marketplace add dcostenco/prism-coder
97
+ claude plugin install prism-coder@prism
94
98
  ```
95
99
 
96
- **Codex** — this repository is itself a plugin marketplace:
100
+ **Codex:**
97
101
 
98
102
  ```bash
99
103
  codex plugin marketplace add dcostenco/prism-coder
@@ -141,6 +145,36 @@ or by re-enabling after each run.
141
145
  <details>
142
146
  <summary>Release history (optional)</summary>
143
147
 
148
+ ## What's New in v20.18.1
149
+
150
+ ### Task skills survive compaction and update in place
151
+
152
+ - **Prompt-routed task skills now return after compaction.** The hook restores
153
+ the three most recently active task skills alongside the protected-floor
154
+ digest, so a short follow-up such as `continue` keeps the workflow and visual
155
+ verification rules that governed the work before context was compacted.
156
+ - **A completed managed-skill sync refreshes active task rules on the next
157
+ prompt.** The hook compares the materialized generation on disk with its
158
+ per-session state, re-authorizes every restored name against the current
159
+ manifest, and injects the current bodies. A downgrade clears stale paid task
160
+ state; Prism-managed directories cannot use the user-owned local-skill
161
+ exception.
162
+ - **User-owned local skills now route with their actual body.** A local
163
+ `SKILL.md` remains usable while signed out, while an entitled platform body
164
+ takes precedence if both sources use the same name.
165
+ - **Codex hook context is anchored to the user's request.** Routed rules tell
166
+ the model to execute the preceding user request with the rules applied,
167
+ avoiding the trailing developer-context placement being mistaken for a new
168
+ task. The anchor and all restored content share the 9,800-character limit.
169
+ - **Hook version 5 enforces its command version.** A still-running host with a
170
+ trusted v4 command cannot execute the rewritten v5 script. After upgrading,
171
+ run `prism connect --refresh --no-models`; Codex must trust both new v5 hook
172
+ entries in `/hooks`. Restart the host if it has not reloaded the new hook
173
+ definitions. Once v5 is active, later skill-body generations refresh on the
174
+ next prompt without another host restart.
175
+ - State names are validated and bounded before they become CLI arguments, and
176
+ the two-call refresh paths fit inside the host's 15-second hook timeout.
177
+
144
178
  ## What's New in v20.18.0
145
179
 
146
180
  ### The protected floor rides the bootstrap — and survives compaction
@@ -1434,27 +1468,32 @@ Prism works alongside these tools — use whichever fits your workflow.
1434
1468
 
1435
1469
  ### Web IDE — Prism Coder
1436
1470
 
1437
- A browser-based IDE at [synalux.ai/coder](https://synalux.ai/coder). Import any GitHub repo and get:
1471
+ Open [synalux.ai/coder](https://synalux.ai/coder), sign in, connect GitHub, and import a repository you can access. Select a branch, open a file, edit, and save back to GitHub.
1438
1472
 
1439
- - **Monaco editor** with multi-tab, split view, syntax highlighting, and VS Code keybindings
1440
- - **In-browser Node.js** via WebContainer (your code runs in the browser sandbox, not on a server)
1441
- - **Integrated terminal** — WebContainer shell in-browser; optional server PTY via WebSocket when connected to a dev server
1442
- - **AI Agent Mode** — describe a task and the agent creates files, runs type-checks, and verifies
1443
- - **Source control** — commit, branch, push/pull, stash, blame, tag management
1444
- - **Live Share** — real-time collaborative editing with session links
1445
- - **Node.js debugger** via Chrome DevTools Protocol
1446
- - **Tasks runner** (VS Code `tasks.json` compatible), **Problems panel** (Monaco diagnostics)
1447
- - **12-language i18n** — full UI localization
1473
+ - **Monaco editor** — multiple tabs, split view, syntax highlighting, and familiar VS Code keybindings.
1474
+ - **GitHub editing** — branch selection, file saves, history, and conflict protection. If a file changes remotely, a conflicting save keeps your local draft for recovery. Binary renames preserve bytes, and Unicode paths are supported.
1475
+ - **AI Agent Mode** — describe a task, review proposed file changes, apply selected changes, and save on an isolated agent branch. Rollback discards that branch and returns to your previous work.
1476
+ - **Live Share** — start a session and share its invite with repository members for synchronized edits and labeled remote cursors. First-party signaling is enabled on the hosted IDE.
1477
+ - **In-browser Node.js and terminal** — WebContainer runs package scripts and previews when the browser supports its runtime. The optional server PTY and Node.js debugger require a connected development server.
1478
+ - **Tasks and diagnostics** — a `tasks.json` runner and a Problems panel for Monaco diagnostics; localized interface strings are available in 12 languages.
1448
1479
 
1449
- <p align="center">
1450
- <img src="docs/screenshots/agent-mode.png" alt="Prism Coder IDE — Agent Mode creating a component with auto-fix and type-checking" width="500" />
1451
- </p>
1480
+ **Production session, September 8, 2026.** The following screenshots were captured from the live IDE using a private test repository. The session exercised GitHub import/save, conflict recovery, binary rename, Unicode files, Agent review/apply/save/rollback, and Live Share text and cursors in both directions.
1452
1481
 
1453
- <p align="center">
1454
- <img src="docs/screenshots/collaboration.png" alt="Prism Coder IDE — Live Share with team members and real-time cursor tracking" width="500" />
1455
- </p>
1482
+ **Review an Agent proposal before applying it.** The diff below proposes a new `add(a, b)` function. Selected changes were then applied and the exact file was saved to the agent branch; rollback was checked afterward.
1483
+
1484
+ ![Live Coder Agent diff showing a proposed add function and Apply selected changes](docs/screenshots/coder-agent-diff-20260908.png)
1485
+
1486
+ **Keep your draft when a remote edit conflicts.** The status bar reports the conflict while the unsaved local edit remains in the editor. Draft recovery was also checked after reloading.
1487
+
1488
+ ![Coder file editor retaining an unsaved draft with a remote-change conflict message](docs/screenshots/coder-conflict-draft-20260908.png)
1489
+
1490
+ **See collaborators' edits and cursors.** The host receives the guest's edit and cursor in the first capture. The guest receives the host's return edit and cursor in the second. These captures show two Safari tabs using the same authorized account; separate-user and cross-device connections were not covered by this run.
1491
+
1492
+ ![Live Share host with two participants and the guest cursor on a synchronized edit](docs/screenshots/coder-liveshare-host-20260908.png)
1493
+
1494
+ ![Live Share guest receiving the host return edit and labeled remote cursor](docs/screenshots/coder-liveshare-guest-20260908.png)
1456
1495
 
1457
- Standard+ plans get cloud AI and higher rate limits. Free tier works with local Ollama. Code execution uses the in-browser WebContainer by default; Live Share and the optional PTY terminal connect to external servers when explicitly enabled.
1496
+ **Runtime requirements.** Chrome or Edge is recommended for WebContainer package installation, tests, and previews; see [WebContainer browser support](https://developer.stackblitz.com/platform/webcontainers/browser-support). Safari editing and the workflows shown above were exercised, but npm installation still failed in this Safari session. Successful editing does not establish successful code execution. Agent test and type-check execution also depends on the runtime. Cloud AI requires the applicable plan and configured service; local Ollama requires an explicit connection.
1458
1497
 
1459
1498
  ### VS Code Extension — Synalux
1460
1499
 
package/dist/cli.js CHANGED
@@ -531,7 +531,11 @@ program
531
531
  const shaped = result.names.length > 0
532
532
  ? reshapeForInlineBudget(result, HOOK_INLINE_SAFE_CHARS, writeRouteOffload)
533
533
  : { text: '' };
534
- const payload = JSON.stringify({ names: result.names, text: result.names.length > 0 ? shaped.text : '' });
534
+ const payload = JSON.stringify({
535
+ names: result.names,
536
+ alreadyLoaded: result.alreadyLoaded,
537
+ text: result.names.length > 0 ? shaped.text : '',
538
+ });
535
539
  await new Promise((resolveWrite) => process.stdout.write(payload + '\n', () => resolveWrite()));
536
540
  }
537
541
  catch {
@@ -546,6 +550,41 @@ program
546
550
  process.exit(0);
547
551
  }
548
552
  });
553
+ // Re-inject the most recently active task skills after the host discards its
554
+ // transcript, or immediately after a new managed manifest reaches disk. Names
555
+ // are re-authorized against the cached manifest before any body is returned.
556
+ program
557
+ .command('reinject-skills')
558
+ .description('Render previously active, still-entitled skills for the host hook.')
559
+ .requiredOption('--names <names>', 'Comma-separated skill names from the hook session state')
560
+ .option('--budget <chars>', 'Maximum inline characters', String(9_800))
561
+ .action(async (options) => {
562
+ try {
563
+ const names = options.names.split(',').map((name) => name.trim()).filter(Boolean);
564
+ const requestedBudget = Number.parseInt(options.budget ?? '', 10);
565
+ const { runNamedSkillRouteFromCache } = await import('./tools/ledgerHandlers.js');
566
+ const { reshapeForInlineBudget, HOOK_INLINE_SAFE_CHARS } = await import('./tools/promptRouteHandler.js');
567
+ const budget = Number.isFinite(requestedBudget)
568
+ ? Math.max(256, Math.min(HOOK_INLINE_SAFE_CHARS, requestedBudget))
569
+ : HOOK_INLINE_SAFE_CHARS;
570
+ const result = await runNamedSkillRouteFromCache(names);
571
+ const shaped = result.names.length > 0
572
+ ? reshapeForInlineBudget(result, budget, writeRouteOffload)
573
+ : { text: '' };
574
+ const payload = JSON.stringify({ ok: true, names: result.names, text: result.names.length > 0 ? shaped.text : '' });
575
+ await new Promise((resolveWrite) => process.stdout.write(payload + '\n', () => resolveWrite()));
576
+ }
577
+ catch {
578
+ await new Promise((resolveWrite) => process.stdout.write('{"ok":false,"names":[],"text":""}\n', () => resolveWrite()));
579
+ }
580
+ finally {
581
+ try {
582
+ await closeStorage();
583
+ }
584
+ catch { /* exit anyway */ }
585
+ process.exit(0);
586
+ }
587
+ });
549
588
  // ── floor-digest ──────────────────────────────────────────────
550
589
  // Called by the same hook on SessionStart with source "compact": the host
551
590
  // just discarded the bootstrap along with the rest of the transcript, and
package/dist/config.js CHANGED
@@ -8,7 +8,10 @@ import { fileURLToPath } from "node:url";
8
8
  * validates required ones, and exports them for use throughout the server.
9
9
  *
10
10
  * Environment variable guide:
11
- * BRAVE_API_KEY — (required) API key for Brave Search Pro. Get one at https://brave.com/search/api/
11
+ * BRAVE_API_KEY — (required for search) API key for Brave Search Pro, and the only
12
+ * thing search needs in fully-local operation. A configured Synalux
13
+ * account supplies it portal-side instead, so exactly one of the two
14
+ * is needed. Get one at https://brave.com/search/api/
12
15
  * GOOGLE_API_KEY — (optional) API key for Google AI Studio / Gemini. Enables paper analysis.
13
16
  * BRAVE_ANSWERS_API_KEY — (optional) API key for Brave Answers (AI grounding). Enables brave_answers tool.
14
17
  * SUPABASE_URL — (optional) Your Supabase project URL. Enables session memory tools.
@@ -18,9 +21,15 @@ import { fileURLToPath } from "node:url";
18
21
  * VOYAGE_API_KEY — (optional) Voyage AI API key for embeddings.
19
22
  * Set embedding_provider=voyage to use. https://dash.voyageai.com
20
23
  *
21
- * If a required key is missing, the process exits immediately.
22
- * If an optional key is missing, a warning is logged but the server continues
23
- * with reduced functionality (the corresponding tools will be unavailable).
24
+ * No key is enforced at startup. Nothing here calls process.exit, so a missing
25
+ * key never stops the server — it starts with reduced functionality and the
26
+ * affected tools error when they are actually called. Missing keys log a
27
+ * warning, except BRAVE_API_KEY, which warns only under PRISM_DEBUG_LOGGING.
28
+ *
29
+ * Said plainly because the old wording ("the process exits immediately") was
30
+ * describing behaviour no key has ever had, and read as a hard startup
31
+ * dependency on a Brave + Firecrawl pair — see issue #188, filed on exactly
32
+ * that premise.
24
33
  */
25
34
  // ─── Server Identity ──────────────────────────────────────────
26
35
  function resolveServerVersion() {
@@ -40,7 +40,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
40
40
  import { homedir } from "node:os";
41
41
  import { dirname, join, resolve } from "node:path";
42
42
  /** Bump to force the on-disk script to be rewritten on the next ensure. */
43
- export const PROMPT_ROUTE_HOOK_VERSION = "4";
43
+ export const PROMPT_ROUTE_HOOK_VERSION = "5";
44
44
  /** SessionStart matcher: only the post-compaction fire carries the digest.
45
45
  * The script re-checks `source` itself, so a host that ignores matchers on
46
46
  * this event still injects nothing at startup/resume. */
@@ -52,7 +52,8 @@ const HOOK_DIR = "prism-route";
52
52
  const COMMAND_SIGNATURE = `${HOOK_DIR}/${SCRIPT_FILE}`;
53
53
  /**
54
54
  * The command registered in the host config carries the version as an
55
- * argument (the script ignores argv — it reads stdin). This is a SECURITY
55
+ * argument. The script reads stdin for the hook payload and rejects a stale
56
+ * version argument before executing routing logic. This is a SECURITY
56
57
  * property, found by an external probe of Codex 0.146: Codex's hook-trust
57
58
  * hash covers the CONFIGURED DEFINITION, not the file the command points at.
58
59
  * With a stable command and a version-refreshed script, every prism upgrade
@@ -94,13 +95,27 @@ import shutil
94
95
  import subprocess
95
96
  import sys
96
97
 
98
+ INLINE_CONTEXT_CAP = 9800
99
+ CLI_TIMEOUT_SECONDS = 6
100
+ MAX_STATE_SKILLS = 500
101
+ SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$")
102
+ TASK_CONTEXT_ANCHOR = (
103
+ "[Context attached to the user's request above. Do not acknowledge this context separately. "
104
+ "Execute the user's request now, applying the following task rules.]"
105
+ )
106
+
107
+
108
+ def content_cap():
109
+ return INLINE_CONTEXT_CAP - len(TASK_CONTEXT_ANCHOR) - 2
110
+
97
111
 
98
112
  def emit(extra=None, event="UserPromptSubmit"):
99
113
  out = {"continue": True, "suppressOutput": True}
100
114
  if extra:
115
+ anchored = TASK_CONTEXT_ANCHOR + "\\n\\n" + extra[:content_cap()]
101
116
  out["hookSpecificOutput"] = {
102
117
  "hookEventName": event,
103
- "additionalContext": extra,
118
+ "additionalContext": anchored,
104
119
  }
105
120
  print(json.dumps(out))
106
121
 
@@ -113,7 +128,7 @@ def run_cli_json(cli, args, stdin_text=""):
113
128
  input=stdin_text,
114
129
  capture_output=True,
115
130
  text=True,
116
- timeout=10,
131
+ timeout=CLI_TIMEOUT_SECONDS,
117
132
  )
118
133
  except Exception:
119
134
  return None
@@ -145,6 +160,70 @@ def session_state_path(payload):
145
160
  return state_dir, os.path.join(state_dir, session + ".json")
146
161
 
147
162
 
163
+ def read_state(state_path):
164
+ try:
165
+ with open(state_path) as fh:
166
+ value = json.load(fh)
167
+ except Exception:
168
+ return {"generation": "", "names": []}
169
+ if isinstance(value, list):
170
+ return {"generation": "", "names": clean_names(value)}
171
+ if not isinstance(value, dict):
172
+ return {"generation": "", "names": []}
173
+ generation = value.get("generation") if isinstance(value.get("generation"), str) else ""
174
+ names = value.get("names") if isinstance(value.get("names"), list) else []
175
+ return {"generation": generation, "names": clean_names(names)}
176
+
177
+
178
+ def clean_names(values):
179
+ kept = []
180
+ seen = set()
181
+ for value in values:
182
+ if not isinstance(value, str) or not SKILL_NAME_RE.fullmatch(value) or value in seen:
183
+ continue
184
+ kept.append(value)
185
+ seen.add(value)
186
+ return kept[-MAX_STATE_SKILLS:]
187
+
188
+
189
+ def write_state(state_dir, state_path, generation, names):
190
+ temp_path = state_path + ".tmp-" + str(os.getpid())
191
+ try:
192
+ os.makedirs(state_dir, exist_ok=True)
193
+ with open(temp_path, "w") as fh:
194
+ json.dump({"generation": generation, "names": names}, fh)
195
+ os.replace(temp_path, state_path)
196
+ except Exception:
197
+ try:
198
+ os.remove(temp_path)
199
+ except Exception:
200
+ pass
201
+
202
+
203
+ def current_generation():
204
+ override = os.environ.get("PRISM_ROUTE_SKILLS_INDEX")
205
+ index_path = override or os.path.expanduser("~/.agents/skills/.prism-managed-skills.json")
206
+ try:
207
+ with open(index_path) as fh:
208
+ value = json.load(fh)
209
+ generation = value.get("generation") if isinstance(value, dict) else ""
210
+ return generation if isinstance(generation, str) else ""
211
+ except Exception:
212
+ return ""
213
+
214
+
215
+ def reinject(cli, names, budget):
216
+ selected = names[-3:]
217
+ if not selected or budget < 256:
218
+ return {"ok": True, "names": [], "text": ""}
219
+ data = run_cli_json(cli, [
220
+ "reinject-skills", "--names", ",".join(selected), "--budget", str(budget)
221
+ ])
222
+ if not isinstance(data, dict) or data.get("ok") is not True:
223
+ return {"ok": False, "names": [], "text": ""}
224
+ return data
225
+
226
+
148
227
  def payload_field(payload, *keys):
149
228
  """First non-empty string among alternative spellings of one field.
150
229
 
@@ -175,24 +254,32 @@ def on_session_start(payload):
175
254
  if not source.startswith("compact"):
176
255
  emit(event="SessionStart")
177
256
  return
178
- # The skills injected before compaction are gone with it; forget the
179
- # dedupe list so the next matching prompt can bring them back.
180
257
  state_dir, state_path = session_state_path(payload)
181
- try:
182
- os.remove(state_path)
183
- except Exception:
184
- pass
258
+ state = read_state(state_path)
259
+ generation = current_generation()
185
260
  cli = find_cli()
186
261
  if not cli:
187
262
  emit(event="SessionStart")
188
263
  return
189
264
  data = run_cli_json(cli, ["floor-digest"])
190
- text = (data or {}).get("text") or ""
191
- names = [n for n in ((data or {}).get("names") or []) if isinstance(n, str)]
192
- if not names or not isinstance(text, str) or not text:
193
- emit(event="SessionStart")
194
- return
195
- emit(text, event="SessionStart")
265
+ raw_floor_text = (data or {}).get("text") or ""
266
+ floor_names = clean_names((data or {}).get("names") or [])
267
+ floor_text = raw_floor_text if floor_names and isinstance(raw_floor_text, str) else ""
268
+ remaining = content_cap() - len(floor_text) - (2 if floor_text else 0)
269
+ restored = reinject(cli, state["names"], remaining)
270
+ restored_names = clean_names(restored.get("names") or [])
271
+ restored_text = restored.get("text") if isinstance(restored.get("text"), str) else ""
272
+ # If the floor consumes the whole host budget or the CLI fails, keep the
273
+ # names but leave the generation stale so the next prompt retries. A
274
+ # successful empty result means the current entitlement revoked them.
275
+ if remaining < 256 and state["names"]:
276
+ write_state(state_dir, state_path, "", state["names"])
277
+ elif restored.get("ok") is not True:
278
+ write_state(state_dir, state_path, "", state["names"])
279
+ else:
280
+ write_state(state_dir, state_path, generation, restored_names)
281
+ combined = "\\n\\n".join(part for part in (floor_text, restored_text if restored_names else "") if part)
282
+ emit(combined[:content_cap()], event="SessionStart")
196
283
 
197
284
 
198
285
  def find_cli():
@@ -233,27 +320,59 @@ def main():
233
320
  or payload.get("user_prompt")
234
321
  or ""
235
322
  ).strip()
236
- # Slash commands and micro-prompts ("ok", "merge") never route; skipping
237
- # them keeps the common turn free.
238
- if len(prompt) < 6 or prompt.startswith("/"):
323
+ state_dir, state_path = session_state_path(payload)
324
+ state = read_state(state_path)
325
+ loaded = state["names"]
326
+ generation = current_generation()
327
+ generation_changed = bool(loaded and generation and generation != state["generation"])
328
+
329
+ cli = find_cli()
330
+ if not cli:
331
+ emit()
332
+ return
333
+
334
+ # Slash commands never route. A micro-prompt normally stays free, but a
335
+ # just-materialized generation must replace any older task rules before
336
+ # even "continue" reaches the model.
337
+ if prompt.startswith("/"):
338
+ emit()
339
+ return
340
+ if len(prompt) < 6:
341
+ if generation_changed:
342
+ restored = reinject(cli, loaded, content_cap())
343
+ restored_names = clean_names(restored.get("names") or [])
344
+ restored_text = restored.get("text") if isinstance(restored.get("text"), str) else ""
345
+ if restored.get("ok") is True:
346
+ write_state(state_dir, state_path, generation, restored_names)
347
+ else:
348
+ write_state(state_dir, state_path, "", loaded)
349
+ if restored_names and restored_text:
350
+ emit(restored_text)
351
+ return
239
352
  emit()
240
353
  return
241
354
  # A pasted log can be megabytes; triggers live in the first human-sized
242
355
  # stretch, and the CLI caps identically on its side.
243
356
  prompt = prompt[:100_000]
244
357
 
245
- state_dir, state_path = session_state_path(payload)
246
- loaded = []
247
- try:
248
- with open(state_path) as fh:
249
- data = json.load(fh)
250
- if isinstance(data, list):
251
- loaded = [n for n in data if isinstance(n, str)]
252
- except Exception:
253
- pass
254
-
255
- cli = find_cli()
256
- if not cli:
358
+ if generation_changed:
359
+ # Ask the normal matcher what this prompt needs, then rebuild one
360
+ # current-generation payload from the prior active set plus new hits.
361
+ # Emitting the matcher text directly would lose the old active rules
362
+ # whenever this prompt happened to match a different skill.
363
+ data = run_cli_json(cli, ["route-prompt", "--loaded", ""], prompt)
364
+ routed_names = clean_names((data or {}).get("names") or [])
365
+ active = [n for n in state["names"] if n not in routed_names] + routed_names
366
+ restored = reinject(cli, active, content_cap())
367
+ restored_names = clean_names(restored.get("names") or [])
368
+ restored_text = restored.get("text") if isinstance(restored.get("text"), str) else ""
369
+ if restored.get("ok") is True:
370
+ write_state(state_dir, state_path, generation, restored_names)
371
+ else:
372
+ write_state(state_dir, state_path, "", active)
373
+ if restored_names and restored_text:
374
+ emit(restored_text)
375
+ return
257
376
  emit()
258
377
  return
259
378
 
@@ -261,24 +380,27 @@ def main():
261
380
  if data is None:
262
381
  emit()
263
382
  return
264
- names = [n for n in (data.get("names") or []) if isinstance(n, str)]
383
+ names = clean_names(data.get("names") or [])
265
384
  text = data.get("text") or ""
266
385
  if not names or not text:
386
+ touched = clean_names(data.get("alreadyLoaded") or [])
387
+ if touched and not generation_changed:
388
+ reordered = [n for n in loaded if n not in touched] + touched
389
+ write_state(state_dir, state_path, generation, reordered)
267
390
  emit()
268
391
  return
269
392
 
270
- try:
271
- os.makedirs(state_dir, exist_ok=True)
272
- merged = loaded + [n for n in names if n not in loaded]
273
- with open(state_path, "w") as fh:
274
- json.dump(merged, fh)
275
- except Exception:
276
- pass # dedupe degrades, injection still happens
393
+ touched = clean_names(data.get("alreadyLoaded") or [])
394
+ merged = [n for n in loaded if n not in touched and n not in names] + touched + names
395
+ write_state(state_dir, state_path, generation, clean_names(merged))
277
396
 
278
397
  emit(text)
279
398
 
280
399
 
281
400
  if __name__ == "__main__":
401
+ if "--v${PROMPT_ROUTE_HOOK_VERSION}" not in sys.argv[1:]:
402
+ print(json.dumps({"continue": True, "suppressOutput": True}))
403
+ sys.exit(0)
282
404
  try:
283
405
  main()
284
406
  except Exception:
@@ -256,6 +256,109 @@ async function listFiles(root, current = root) {
256
256
  }
257
257
  return files.sort();
258
258
  }
259
+ /**
260
+ * Artifacts nobody authors. ADMISSION RULE for these lists — an entry must be
261
+ * (1) machine-generated as a side effect of USING or BROWSING the directory,
262
+ * (2) regenerated on demand at no cost, and (3) never shippable content. Any
263
+ * other untracked file is a local edit and must keep conflicting. Do not widen
264
+ * this into "ignore files we do not recognise".
265
+ */
266
+ const DERIVED_ARTIFACT_DIRS = new Set(["__pycache__"]);
267
+ /** Compared lowercased: Explorer writes both `Thumbs.db` and `thumbs.db`. */
268
+ const DERIVED_ARTIFACT_FILES = new Set([".ds_store", "thumbs.db", "desktop.ini"]);
269
+ const DERIVED_ARTIFACT_EXT = /\.py[co]$/;
270
+ function isDerivedArtifactFile(name) {
271
+ const lower = name.toLocaleLowerCase("en-US");
272
+ return DERIVED_ARTIFACT_FILES.has(lower) || DERIVED_ARTIFACT_EXT.test(lower);
273
+ }
274
+ /**
275
+ * Remove regenerable junk from a marker-owned skill directory.
276
+ *
277
+ * `isPristineMarkedSkill` compares the FULL recursive file list against the
278
+ * marker's key list, so ONE untracked file classifies the skill as a conflict —
279
+ * frozen, silently skipped by every later sync. Two ways that happens without
280
+ * anyone editing anything:
281
+ *
282
+ * - CPython writes `__pycache__/<mod>.cpython-XX.pyc` beside any module it
283
+ * IMPORTS (exec'ing a script does not). Observed 2026-09-14:
284
+ * dead-link-prevention froze after something imported check_links.py.
285
+ * - Finder drops `.DS_Store` into any directory a user OPENS, and Explorer
286
+ * drops `Thumbs.db`/`desktop.ini`. Reproduced 2026-09-14: browsing a
287
+ * managed skill folder is enough to stop it updating. Windows is a
288
+ * supported host, so both families belong here.
289
+ *
290
+ * PURGE, never ignore. Exempting these from the integrity walk would turn a
291
+ * noisy false conflict into a code-execution hole: CPython loads a cached .pyc
292
+ * in preference to its source whenever the pyc header's recorded source mtime
293
+ * and size match the .py, and both of those fields live in the attacker-written
294
+ * pyc. An ignored .pyc is arbitrary bytecode that executes while sync still
295
+ * reports the skill pristine and keeps updating it. Deleting restores integrity
296
+ * instead — the next import regenerates the cache from the .py the marker
297
+ * actually verifies, and any OTHER untracked file still conflicts as before.
298
+ *
299
+ * Touches nothing Prism does not own, nothing the manifest shipped, and never
300
+ * follows a symlink out of the skill root.
301
+ */
302
+ async function purgeDerivedArtifacts(path) {
303
+ // Self-guarding, not caller-guarded. Three sites call this now and the
304
+ // symlink check is the only thing standing between a delete primitive and an
305
+ // arbitrary target, so it lives HERE — a fourth caller that forgets to lstat
306
+ // must not be able to reintroduce the escape.
307
+ let rootStat;
308
+ try {
309
+ rootStat = await lstat(path);
310
+ }
311
+ catch {
312
+ return;
313
+ }
314
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
315
+ return;
316
+ const marker = await readJson(join(path, MARKER));
317
+ if (!marker || marker.owner !== OWNER || !marker.files || typeof marker.files !== "object")
318
+ return;
319
+ const shipped = new Set(Object.keys(marker.files));
320
+ const shippedPaths = [...shipped];
321
+ // BEST EFFORT, always. This runs inside materializeNative's try block before
322
+ // anything is staged, so letting an EACCES escape would convert one skill's
323
+ // junk file into a `partial` sync that freezes EVERY other skill — trading a
324
+ // one-skill problem for the whole-root outage shape this file's header
325
+ // describes. A delete we cannot do just means the pristine check conflicts
326
+ // that one skill, exactly as it did before this function existed.
327
+ const discard = async (child, recursive) => {
328
+ try {
329
+ await rm(child, { recursive, force: true });
330
+ }
331
+ catch { /* leave it; it conflicts */ }
332
+ };
333
+ const walk = async (current) => {
334
+ let entries;
335
+ try {
336
+ entries = await readdir(current, { withFileTypes: true });
337
+ }
338
+ catch {
339
+ return;
340
+ }
341
+ for (const entry of entries) {
342
+ const child = join(current, entry.name);
343
+ const rel = relative(path, child).split(sep).join("/");
344
+ // Never follow a symlink out of the skill root; listFiles still rejects it.
345
+ if (entry.isSymbolicLink())
346
+ continue;
347
+ if (entry.isDirectory()) {
348
+ const shippedUnder = shippedPaths.some((file) => file === rel || file.startsWith(`${rel}/`));
349
+ if (DERIVED_ARTIFACT_DIRS.has(entry.name) && !shippedUnder) {
350
+ await discard(child, true);
351
+ continue;
352
+ }
353
+ await walk(child);
354
+ }
355
+ else if (entry.isFile() && isDerivedArtifactFile(entry.name) && !shipped.has(rel)) {
356
+ await discard(child, false);
357
+ }
358
+ }
359
+ };
360
+ await walk(path);
361
+ }
259
362
  async function isPristineMarkedSkill(path, generation) {
260
363
  const marker = await readJson(join(path, MARKER));
261
364
  if (!marker || marker.owner !== OWNER || (generation && marker.generation !== generation) ||
@@ -404,6 +507,12 @@ async function enforceNativeEntitlements(incomingNames, agentsSkillsDir) {
404
507
  const target = join(agentsSkillsDir, name);
405
508
  if (!(await exists(target)))
406
509
  continue;
510
+ // Same reason as the materialize paths: junk nobody wrote must not make a
511
+ // clean skill look hand-edited. Here the misread is quieter but permanent —
512
+ // the skill leaves discovery either way, but a false "locally modified"
513
+ // parks it in quarantine forever instead of deleting it, and nothing ever
514
+ // collects that directory.
515
+ await purgeDerivedArtifacts(target);
407
516
  if (await isPristineMarkedSkill(target)) {
408
517
  await rm(target, { recursive: true, force: true });
409
518
  continue;
@@ -604,6 +713,8 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
604
713
  if (!(await exists(target)))
605
714
  continue;
606
715
  const stat = await lstat(target);
716
+ if (stat.isDirectory() && !stat.isSymbolicLink())
717
+ await purgeDerivedArtifacts(target);
607
718
  const pristine = stat.isDirectory() && !stat.isSymbolicLink() && await isPristineManagedSkill(target, true);
608
719
  if (!pristine) {
609
720
  // Preserve locally modified managed content, but quarantine it outside
@@ -659,6 +770,8 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
659
770
  }
660
771
  const stat = await lstat(target);
661
772
  const managedSkill = managedCandidates.has(skill.name);
773
+ if (stat.isDirectory() && !stat.isSymbolicLink())
774
+ await purgeDerivedArtifacts(target);
662
775
  if (!stat.isDirectory() || stat.isSymbolicLink() || !(await isPristineManagedSkill(target, managedSkill))) {
663
776
  conflicts.push(skill.name);
664
777
  if (managedSkill)
@@ -496,13 +496,31 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
496
496
  const conflictSuffix = snapshot.conflicts.length > 0
497
497
  ? ` · ${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} — see warning`
498
498
  : "";
499
+ // Name the CONDITION, then hand over the procedure that reveals the cause.
500
+ // This used to read "local copy has no Prism ownership marker", which is one
501
+ // of several causes and not the common one. On 2026-09-14 dead-link-prevention
502
+ // tripped it with a marker that was present and digest-exact; the real cause
503
+ // was one extra untracked file. The sentence sent the operator to quarantine
504
+ // the directory and rerun `prism connect` (which rewrites host MCP config and
505
+ // wants every host closed) when the fix was deleting a regenerable cache dir.
506
+ // A wrong cause is worse than no cause: it buys a confident wrong repair.
507
+ //
508
+ // Deliberately a PROCEDURE, not an enumeration of causes. Listing all of them
509
+ // is both longer and weaker — a list still leaves the operator guessing, while
510
+ // the file-list-vs-marker diff names the offender outright. Length is not
511
+ // cosmetic here: this block renders in the HEAD, which capNativeStartupText
512
+ // keeps while cutting the tail, so every extra character evicts session
513
+ // context. An enumerated draft measured 858 chars — 21% of the whole 4,000
514
+ // quick budget, against 9% for the wrong-but-short original.
499
515
  const conflictWarning = snapshot.conflicts.length > 0
500
- ? `\n> - ⚠️ **SKILLS NOT UPDATING (local copy has no Prism ownership marker):** ` +
516
+ ? `\n> - ⚠️ **SKILLS NOT UPDATING (on-disk copy differs from the managed one):** ` +
501
517
  `${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
502
- `Each named skill is frozen at whatever version is on disk — updates are ` +
503
- `withheld to protect local edits. To resume updates: move the skill's ` +
504
- `directory out of the native skills folder and rerun \`prism connect\` ` +
505
- `(or a session bootstrap) to reinstall the managed copy.`
518
+ `Each is frozen at whatever version is on disk — updates are withheld to ` +
519
+ `protect local edits. To see why, compare the directory's file list with ` +
520
+ `the \`files\` keys in its \`.prism-managed.json\`. An extra file you do ` +
521
+ `not need? Delete it; the next sync adopts the skill again. A local edit ` +
522
+ `you want to discard? Move the directory out of the native skills folder ` +
523
+ `and rerun \`prism connect\` (or a session bootstrap).`
506
524
  : "";
507
525
  // Durable, not per-run: a later sync reports "unchanged" while the roots stay
508
526
  // frozen from an earlier failed materialization. This line is the difference
@@ -2074,6 +2092,7 @@ export async function collectSkillTriggersOnThisMachine() {
2074
2092
  // __proto__/constructor must be a plain data key, not an inherited read.
2075
2093
  const merged = Object.create(null);
2076
2094
  const localNames = new Set();
2095
+ const localBodies = new Map();
2077
2096
  const errors = [];
2078
2097
  const settings = await getAllSettings();
2079
2098
  const bodies = [];
@@ -2105,8 +2124,12 @@ export async function collectSkillTriggersOnThisMachine() {
2105
2124
  for (const [pattern, names] of Object.entries(local.triggers)) {
2106
2125
  (merged[pattern] ||= []).push(...names);
2107
2126
  }
2108
- for (const name of local.names)
2127
+ for (const name of local.names) {
2109
2128
  localNames.add(name);
2129
+ const body = local.bodies.get(name);
2130
+ if (body)
2131
+ localBodies.set(name, body);
2132
+ }
2110
2133
  errors.push(...local.errors);
2111
2134
  }
2112
2135
  catch (error) {
@@ -2117,7 +2140,7 @@ export async function collectSkillTriggersOnThisMachine() {
2117
2140
  // feature fixes — a skill that is installed and never fires.
2118
2141
  debugLog(`[skill-triggers] ignored trigger in "${error.skill}": ${error.reason}`);
2119
2142
  }
2120
- return Object.keys(merged).length > 0 ? { triggers: merged, localNames } : undefined;
2143
+ return Object.keys(merged).length > 0 ? { triggers: merged, localNames, localBodies } : undefined;
2121
2144
  }
2122
2145
  catch (error) {
2123
2146
  debugLog(`[skill-triggers] collection failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -2170,6 +2193,39 @@ export async function runPromptRouteFromCache(prompt, loaded) {
2170
2193
  },
2171
2194
  });
2172
2195
  }
2196
+ /**
2197
+ * Rebuild task-specific hook context after compaction or a manifest update.
2198
+ *
2199
+ * The names came from this hook session's prior, entitlement-checked route.
2200
+ * They still pass through the current cached entitlement set so a downgrade
2201
+ * cannot resurrect a paid skill from stale hook state. This deliberately uses
2202
+ * routePrompt's normal delivery and budget logic instead of reading native
2203
+ * files in the host hook.
2204
+ */
2205
+ export async function runNamedSkillRouteFromCache(names) {
2206
+ const safeNames = [...new Set(names.filter((name) => NATIVE_SKILL_NAME.test(name)))];
2207
+ if (safeNames.length === 0) {
2208
+ return { names: [], alreadyLoaded: [], overflow: [], text: "No skills supplied — nothing to re-inject." };
2209
+ }
2210
+ const { routePrompt } = await import("./promptRouteHandler.js");
2211
+ const { _setStorage } = await import("./skillRouting.js");
2212
+ _setStorage(async (key, value) => { await setSetting(key, value); }, async (key) => getSetting(key, ""));
2213
+ return routePrompt("restore task-specific skills after compaction", [], {
2214
+ resolvePromptSkillNames: async () => safeNames,
2215
+ collectTriggers: collectSkillTriggersOnThisMachine,
2216
+ entitledNames: async () => {
2217
+ try {
2218
+ const parsed = JSON.parse(await getSetting("skill_manifest:names", "[]"));
2219
+ return new Set(Array.isArray(parsed) ? parsed.filter((name) => typeof name === "string") : []);
2220
+ }
2221
+ catch {
2222
+ return new Set();
2223
+ }
2224
+ },
2225
+ getBody: (name) => getSetting(`skill:${name}`, ""),
2226
+ manifestVersion: async () => undefined,
2227
+ });
2228
+ }
2173
2229
  export async function sessionRoutePromptHandler(args) {
2174
2230
  const input = (typeof args === "object" && args !== null && !Array.isArray(args) ? args : {});
2175
2231
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
@@ -87,7 +87,13 @@ export async function routePrompt(prompt, loaded, deps) {
87
87
  const delivered = [];
88
88
  let budget = MAX_ROUTED_CHARS;
89
89
  for (const name of selected) {
90
- const body = (await deps.getBody(name).catch(() => "")).trim();
90
+ // Select by the authorization source, not by which body happens to be
91
+ // non-empty. Otherwise an unentitled local same-name skill could expose a
92
+ // stale paid body left in the cache after a downgrade.
93
+ const platformAuthorized = entitled.has(name);
94
+ const body = (platformAuthorized
95
+ ? await deps.getBody(name).catch(() => "")
96
+ : scoped?.localBodies?.get(name) ?? "").trim();
91
97
  if (!body) {
92
98
  // Routed but undeliverable is the exact defect this feature exists to
93
99
  // surface. Say so rather than returning a name with nothing behind it.
@@ -182,8 +182,17 @@ export function extractSkillTriggers(skillName, content) {
182
182
  */
183
183
  const MAX_LOCAL_SKILLS = 300;
184
184
  const MAX_LOCAL_FILE_BYTES = 64 * 1024;
185
+ const MANAGED_SKILL_MARKER = ".prism-managed.json";
186
+ const MANAGED_SKILL_INDEX = ".prism-managed-skills.json";
187
+ const MANAGED_SKILL_OWNER = "prism-skill-sync-v1";
188
+ const LOCAL_SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,127}$/;
185
189
  export async function collectLocalSkillTriggers(roots, fs, join) {
186
- const merged = { triggers: Object.create(null), errors: [], names: [] };
190
+ const merged = {
191
+ triggers: Object.create(null),
192
+ errors: [],
193
+ names: [],
194
+ bodies: new Map(),
195
+ };
187
196
  let budget = MAX_LOCAL_SKILLS;
188
197
  const seen = new Set();
189
198
  for (const root of roots) {
@@ -194,6 +203,21 @@ export async function collectLocalSkillTriggers(roots, fs, join) {
194
203
  catch {
195
204
  continue; // root absent — normal
196
205
  }
206
+ const indexedManaged = new Set();
207
+ try {
208
+ const index = JSON.parse(await fs.readFile(join(root, MANAGED_SKILL_INDEX)));
209
+ if (index && typeof index === "object" && !Array.isArray(index)
210
+ && index.owner === MANAGED_SKILL_OWNER
211
+ && Array.isArray(index.skills)) {
212
+ for (const value of index.skills) {
213
+ if (typeof value === "string" && LOCAL_SKILL_NAME.test(value))
214
+ indexedManaged.add(value);
215
+ }
216
+ }
217
+ }
218
+ catch {
219
+ // A missing index is normal for a machine with user-owned skills only.
220
+ }
197
221
  for (const name of entries) {
198
222
  if (budget-- <= 0)
199
223
  break;
@@ -209,8 +233,20 @@ export async function collectLocalSkillTriggers(roots, fs, join) {
209
233
  continue;
210
234
  seen.add(name);
211
235
  const extracted = extractSkillTriggers(name, content);
212
- if (Object.keys(extracted.triggers).length > 0)
236
+ let prismManaged = indexedManaged.has(name);
237
+ try {
238
+ const marker = JSON.parse(await fs.readFile(join(root, name, MANAGED_SKILL_MARKER)));
239
+ prismManaged ||= Boolean(marker && typeof marker === "object" && !Array.isArray(marker)
240
+ && marker.owner === MANAGED_SKILL_OWNER);
241
+ }
242
+ catch {
243
+ // No valid Prism marker means the directory is user-owned local
244
+ // content and remains eligible for the local entitlement exception.
245
+ }
246
+ if (Object.keys(extracted.triggers).length > 0 && !prismManaged) {
213
247
  merged.names.push(name);
248
+ merged.bodies.set(name, content);
249
+ }
214
250
  for (const [pattern, names] of Object.entries(extracted.triggers)) {
215
251
  (merged.triggers[pattern] ||= []).push(...names);
216
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.18.0",
3
+ "version": "20.18.2",
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",
@@ -24,6 +24,7 @@
24
24
  "chmod-bins": "node -e \"['dist/cli.js','dist/server.js','dist/utils/universalImporter.js'].forEach(f => { try { require('fs').chmodSync(f, 0o755); } catch (e) { console.warn('chmod skipped', f, e.message); } })\"",
25
25
  "prepublishOnly": "node scripts/check-no-private-content.mjs && node scripts/check-publish-clean.mjs && npm run build",
26
26
  "lint:dashboard": "node scripts/lint-dashboard-es5.cjs",
27
+ "check:lockfile": "node scripts/check-lockfile-drift.mjs",
27
28
  "start": "node dist/server.js",
28
29
  "test": "vitest run",
29
30
  "test:watch": "vitest",
@@ -78,11 +79,11 @@
78
79
  "overrides": {
79
80
  "@hono/node-server": "^2.0.5",
80
81
  "body-parser": "^2.3.0",
81
- "fast-uri": "^3.1.4",
82
- "hono": "^4.12.27",
82
+ "fast-uri": "^3.1.6",
83
+ "hono": "^4.13.5",
83
84
  "ip-address": "^10.2.0",
84
85
  "protobufjs": "^7.6.5",
85
- "sharp": "^0.35.0",
86
+ "sharp": "^0.35.4",
86
87
  "tar": "^7.5.19",
87
88
  "nanoid": "^3.3.17"
88
89
  },
@@ -108,8 +109,9 @@
108
109
  "p-limit": "^7.3.0",
109
110
  "postcss": "8.5.23",
110
111
  "quickjs-emscripten": "^0.32.0",
111
- "smol-toml": "^1.7.0",
112
- "stream-json": "^2.0.0",
112
+ "smol-toml": "^1.7.1",
113
+ "stream-chain": "^4.2.5",
114
+ "stream-json": "^3.6.0",
113
115
  "tldts": "^7.0.27",
114
116
  "turndown": "^7.2.2",
115
117
  "zod": "^4.3.6"