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 +60 -21
- package/dist/cli.js +40 -1
- package/dist/config.js +13 -4
- package/dist/promptRouteHostHook.js +161 -39
- package/dist/skillManifestSync.js +113 -0
- package/dist/tools/ledgerHandlers.js +63 -7
- 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
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 = "
|
|
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:
|
|
@@ -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 (
|
|
516
|
+
? `\n> - ⚠️ **SKILLS NOT UPDATING (on-disk copy differs from the managed one):** ` +
|
|
501
517
|
`${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
|
|
502
|
-
`Each
|
|
503
|
-
`
|
|
504
|
-
`
|
|
505
|
-
`
|
|
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
|
-
|
|
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.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.
|
|
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"
|