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 +60 -21
- package/dist/cli.js +40 -1
- package/dist/promptRouteHostHook.js +161 -39
- package/dist/tools/ledgerHandlers.js +40 -2
- package/dist/tools/promptRouteHandler.js +7 -1
- package/dist/tools/scopedSkillTriggers.js +38 -2
- package/package.json +8 -6
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
96
|
+
claude plugin marketplace add dcostenco/prism-coder
|
|
97
|
+
claude plugin install prism-coder@prism
|
|
94
98
|
```
|
|
95
99
|
|
|
96
|
-
**Codex
|
|
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
|
-
|
|
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**
|
|
1440
|
-
- **
|
|
1441
|
-
- **
|
|
1442
|
-
- **
|
|
1443
|
-
- **
|
|
1444
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
1493
|
+
|
|
1494
|
+

|
|
1456
1495
|
|
|
1457
|
-
|
|
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({
|
|
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 = "
|
|
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
|
|
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":
|
|
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=
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
82
|
-
"hono": "^4.
|
|
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.
|
|
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.
|
|
112
|
-
"stream-
|
|
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"
|