prism-mcp-server 20.18.0 → 20.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -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:
@@ -2074,6 +2074,7 @@ export async function collectSkillTriggersOnThisMachine() {
2074
2074
  // __proto__/constructor must be a plain data key, not an inherited read.
2075
2075
  const merged = Object.create(null);
2076
2076
  const localNames = new Set();
2077
+ const localBodies = new Map();
2077
2078
  const errors = [];
2078
2079
  const settings = await getAllSettings();
2079
2080
  const bodies = [];
@@ -2105,8 +2106,12 @@ export async function collectSkillTriggersOnThisMachine() {
2105
2106
  for (const [pattern, names] of Object.entries(local.triggers)) {
2106
2107
  (merged[pattern] ||= []).push(...names);
2107
2108
  }
2108
- for (const name of local.names)
2109
+ for (const name of local.names) {
2109
2110
  localNames.add(name);
2111
+ const body = local.bodies.get(name);
2112
+ if (body)
2113
+ localBodies.set(name, body);
2114
+ }
2110
2115
  errors.push(...local.errors);
2111
2116
  }
2112
2117
  catch (error) {
@@ -2117,7 +2122,7 @@ export async function collectSkillTriggersOnThisMachine() {
2117
2122
  // feature fixes — a skill that is installed and never fires.
2118
2123
  debugLog(`[skill-triggers] ignored trigger in "${error.skill}": ${error.reason}`);
2119
2124
  }
2120
- return Object.keys(merged).length > 0 ? { triggers: merged, localNames } : undefined;
2125
+ return Object.keys(merged).length > 0 ? { triggers: merged, localNames, localBodies } : undefined;
2121
2126
  }
2122
2127
  catch (error) {
2123
2128
  debugLog(`[skill-triggers] collection failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -2170,6 +2175,39 @@ export async function runPromptRouteFromCache(prompt, loaded) {
2170
2175
  },
2171
2176
  });
2172
2177
  }
2178
+ /**
2179
+ * Rebuild task-specific hook context after compaction or a manifest update.
2180
+ *
2181
+ * The names came from this hook session's prior, entitlement-checked route.
2182
+ * They still pass through the current cached entitlement set so a downgrade
2183
+ * cannot resurrect a paid skill from stale hook state. This deliberately uses
2184
+ * routePrompt's normal delivery and budget logic instead of reading native
2185
+ * files in the host hook.
2186
+ */
2187
+ export async function runNamedSkillRouteFromCache(names) {
2188
+ const safeNames = [...new Set(names.filter((name) => NATIVE_SKILL_NAME.test(name)))];
2189
+ if (safeNames.length === 0) {
2190
+ return { names: [], alreadyLoaded: [], overflow: [], text: "No skills supplied — nothing to re-inject." };
2191
+ }
2192
+ const { routePrompt } = await import("./promptRouteHandler.js");
2193
+ const { _setStorage } = await import("./skillRouting.js");
2194
+ _setStorage(async (key, value) => { await setSetting(key, value); }, async (key) => getSetting(key, ""));
2195
+ return routePrompt("restore task-specific skills after compaction", [], {
2196
+ resolvePromptSkillNames: async () => safeNames,
2197
+ collectTriggers: collectSkillTriggersOnThisMachine,
2198
+ entitledNames: async () => {
2199
+ try {
2200
+ const parsed = JSON.parse(await getSetting("skill_manifest:names", "[]"));
2201
+ return new Set(Array.isArray(parsed) ? parsed.filter((name) => typeof name === "string") : []);
2202
+ }
2203
+ catch {
2204
+ return new Set();
2205
+ }
2206
+ },
2207
+ getBody: (name) => getSetting(`skill:${name}`, ""),
2208
+ manifestVersion: async () => undefined,
2209
+ });
2210
+ }
2173
2211
  export async function sessionRoutePromptHandler(args) {
2174
2212
  const input = (typeof args === "object" && args !== null && !Array.isArray(args) ? args : {});
2175
2213
  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.1",
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"