prism-mcp-server 20.17.3 → 20.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,9 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
42
42
  offline last-good recovery.
43
43
  - **Hook-free startup** — MCP metadata and native instructions request Prism's
44
44
  startup context without requiring lifecycle hooks or a Prism-owned launcher.
45
+ Where a host offers hooks (Claude Code, Codex), `prism connect` adds two
46
+ small ones on top: mid-session prompt routing, and a post-compaction
47
+ re-injection of the protected-floor digest.
45
48
  - **Safe escalation and observability** — inference outcomes are explicit,
46
49
  reserved content remains fail-closed, and local/cloud usage is recorded for
47
50
  review.
@@ -138,6 +141,58 @@ or by re-enabling after each run.
138
141
  <details>
139
142
  <summary>Release history (optional)</summary>
140
143
 
144
+ ## What's New in v20.18.0
145
+
146
+ ### The protected floor rides the bootstrap — and survives compaction
147
+
148
+ - **`session_bootstrap` now inlines a digest of the protected floor** on
149
+ paid tiers at standard and deep depth: one inert line per rule, derived
150
+ from each skill's first paragraph (or its pinned `digest:`), plus its
151
+ section map, ~5.6K chars for the full floor. Small-context hosts such as
152
+ Codex were re-reading the sixteen SKILL.md files every session (median 8
153
+ re-reads / 36KB, worst 823 / 5.1MB in rollout logs) because the bootstrap
154
+ only *named* them. The digest is paid for on top of the context budget, so
155
+ the ledger/handoff share at every depth is byte-identical to before. Quick
156
+ depth stays names-only — the opt-out.
157
+ - **A second hook re-injects the digest after compaction.** `prism connect`
158
+ registers the prism-route script on `SessionStart` matched to `compact`
159
+ (Claude Code and Codex); it runs `prism floor-digest`, which applies the
160
+ same tier and depth decisions as the bootstrap, and adds nothing on
161
+ startup/resume/clear. Hosts without hooks (Gemini, Cursor) still get the
162
+ digest on every bootstrap. **Codex: UNVERIFIED against a live compaction.**
163
+ The hook is registered and the script accepts the documented payload
164
+ spellings, but no Codex compaction has been observed end-to-end; on a
165
+ payload it does not recognise it re-injects nothing rather than something
166
+ wrong. If the digest comes from a generation whose skill files never
167
+ finished syncing, the re-injected block carries the same STALE warning the
168
+ bootstrap shows.
169
+ - **Codex trust is reported honestly after a hook rewrite.** Approvals are
170
+ keyed by definition hash, so a rewritten `hooks.json` — this release adds
171
+ the SessionStart entry — voids prior trust: connect prints AWAITING TRUST
172
+ for both entries instead of a green ✓ for a hook Codex would silently skip,
173
+ and trust recorded for an older hook version is never mistaken for current.
174
+ - **A host config that no longer parses is left alone.** If
175
+ `~/.claude/settings.json` or `~/.codex/hooks.json` exists but is not valid
176
+ JSON, connect keeps it byte-identical, registers no hook there, and says so
177
+ — it no longer replaces the file with a minimal hooks-only one. The npm
178
+ postinstall notice says the same, instead of asking you to trust a Codex
179
+ hook it never registered.
180
+ - Fixed: a symptom-routed skill whose closing frontmatter fence is its last
181
+ line was inlined with its YAML (triggers included) as if it were the rule.
182
+ - Fixed: a digest line that crosses into a new section keeps that section's
183
+ heading in front of it — whatever opened the section (an H2, a mid-document
184
+ H1, an empty heading, a setext underline) — so "Delegate to" / "Do NOT
185
+ delegate" rules cannot read with the wrong polarity once joined. A skill
186
+ file on disk is used only when it digests: empty, cut off inside its
187
+ frontmatter, or frontmatter-only files fall back to the stored copy instead
188
+ of rendering YAML as the rule.
189
+ - Fixed: on Windows, a Codex hook approval is recognised even though Codex
190
+ stores the path with escaped backslashes.
191
+ - The publish gate now refuses release notes that run *ahead* of the package
192
+ version (CHANGELOG, README, translated READMEs) — reading the newest
193
+ version on the heading line, so a `v20.17.3 – v20.18.0` range counts as
194
+ 20.18.0, and surviving a typographic apostrophe or a BOM.
195
+
141
196
  ## What's New in v20.17.3
142
197
 
143
198
  - **A plugin install can no longer enable the prompt-routing hook.** The
@@ -579,8 +634,8 @@ the host's final verification responsibility are unchanged.
579
634
  materializes entitled packages in the native `~/.agents/skills` directory
580
635
  before the command exits. Codex therefore sees the current skillset on its
581
636
  first launch instead of requiring a second restart. Prism rechecks the same
582
- snapshot at MCP startup, session load, and every five minutes—without host
583
- lifecycle hooks.
637
+ snapshot at MCP startup, session load, and every five minutes—skill delivery
638
+ never depends on a host lifecycle hook.
584
639
 
585
640
  On the first user turn, Prism's native skill, MCP metadata, and managed host
586
641
  instructions request one `session_bootstrap({})` call. Prism then uses the
@@ -613,7 +668,10 @@ sections from `~/CLAUDE.md`, preserves every other instruction, and installs a
613
668
  small ownership-marked native block that selects `session_bootstrap({})` on the
614
669
  first turn. User hooks, custom instruction sections, and near matches remain
615
670
  untouched; native skills and server-side reminders preserve those Prism
616
- features without host lifecycle hooks. Because hosts expose no native
671
+ features without depending on host lifecycle hooks. On Claude Code and Codex
672
+ connect additionally registers the prism-route script twice: on every prompt
673
+ (mid-session skill routing) and on `SessionStart` matched to `compact` only
674
+ (post-compaction protected-floor digest). Because hosts expose no native
617
675
  session-end callback, handoff at shutdown is instruction-driven rather than a
618
676
  guaranteed lifecycle event.
619
677
 
package/dist/cli.js CHANGED
@@ -348,10 +348,16 @@ program
348
348
  const { ensurePromptRouteHook } = await import('./promptRouteHostHook.js');
349
349
  for (const r of ensurePromptRouteHook({ hosts: hookHosts, mode: 'explicit' })) {
350
350
  const state = r.script === 'unchanged' && r.config === 'unchanged' ? 'up to date' : 'installed';
351
- if (r.host === 'codex' && r.codexApproval === 'pending-or-unknown') {
351
+ if (r.config === 'skipped-unparseable') {
352
+ // The file was left byte-identical on purpose: replacing
353
+ // it would have wiped the operator's own settings along
354
+ // with the syntax error.
355
+ console.log(`⚠ ${r.host}: ${r.configPath} is not valid JSON — prism-route hooks NOT registered; fix the file and re-run prism connect`);
356
+ }
357
+ else if (r.host === 'codex' && r.codexApproval === 'pending-or-unknown') {
352
358
  // Codex silently skips untrusted hooks — a green "installed"
353
359
  // here would be the "configured and inert" lie.
354
- console.log(`⚠ codex: prism-route hook ${state}, AWAITING TRUST — run codex, then /hooks, and trust the entry ending prism-route/on_prompt.py`);
360
+ console.log(`⚠ codex: prism-route hook ${state}, AWAITING TRUST — run codex, then /hooks, and trust BOTH entries ending prism-route/on_prompt.py (UserPromptSubmit and SessionStart)`);
355
361
  }
356
362
  else if (r.host === 'codex' && r.codexApproval === 'state-present-unverifiable') {
357
363
  // Approvals are keyed by definition hash, whose algorithm is
@@ -362,7 +368,7 @@ program
362
368
  console.log(`− codex: prism-route hook ${state}; trust state exists but is not verifiable from here — confirm once in /hooks`);
363
369
  }
364
370
  else {
365
- console.log(`✓ ${r.host}: prism-route prompt hook ${state} (${r.scriptPath})`);
371
+ console.log(`✓ ${r.host}: prism-route hooks ${state} — prompt routing + post-compaction floor (${r.scriptPath})`);
366
372
  }
367
373
  }
368
374
  }
@@ -540,6 +546,33 @@ program
540
546
  process.exit(0);
541
547
  }
542
548
  });
549
+ // ── floor-digest ──────────────────────────────────────────────
550
+ // Called by the same hook on SessionStart with source "compact": the host
551
+ // just discarded the bootstrap along with the rest of the transcript, and
552
+ // this is the protected floor coming back in one line per rule. Same
553
+ // contract as route-prompt — exit 0, one JSON line, cached DB and the local
554
+ // skills root only. Fires once per compaction, never per prompt.
555
+ program
556
+ .command('floor-digest')
557
+ .description('Print the protected-floor digest as {names, text} JSON. Used by the prism-route host hook after a context compaction.')
558
+ .action(async () => {
559
+ try {
560
+ const { renderProtectedFloorDigestForHook } = await import('./tools/ledgerHandlers.js');
561
+ const result = await renderProtectedFloorDigestForHook();
562
+ const payload = JSON.stringify({ names: result.names, text: result.text });
563
+ await new Promise((resolveWrite) => process.stdout.write(payload + '\n', () => resolveWrite()));
564
+ }
565
+ catch {
566
+ await new Promise((resolveWrite) => process.stdout.write('{"names":[],"text":""}\n', () => resolveWrite()));
567
+ }
568
+ finally {
569
+ try {
570
+ await closeStorage();
571
+ }
572
+ catch { /* exit anyway */ }
573
+ process.exit(0);
574
+ }
575
+ });
543
576
  program
544
577
  .command('load <project>')
545
578
  .description('Load session context for a project (same output as session_load_context MCP tool)')
@@ -23,9 +23,16 @@ try {
23
23
  // approval impossible to miss. Approval is per hook-version, not per
24
24
  // release: it recurs only when the hook script itself changes.
25
25
  const codex = results.find((r) => r.host === "codex");
26
- if (codex && codex.codexApproval === "pending-or-unknown") {
26
+ if (codex && codex.config === "skipped-unparseable") {
27
+ // Nothing was registered, so there is nothing to trust: telling the
28
+ // operator to press t in /hooks would send them looking for an entry
29
+ // that does not exist. The file was left byte-identical on purpose.
30
+ console.error(`\n[prism] Codex ${codex.configPath} is not valid JSON — prism-route hooks NOT registered.\n` +
31
+ "[prism] Fix the file, then run: prism connect --host codex\n");
32
+ }
33
+ else if (codex && codex.codexApproval === "pending-or-unknown") {
27
34
  console.error("\n[prism] Codex hook installed but NOT yet trusted — Codex silently skips it until you approve it once:\n" +
28
- "[prism] codex -> /hooks -> entry ending prism-route/on_prompt.py -> press t\n");
35
+ "[prism] codex -> /hooks -> BOTH entries ending prism-route/on_prompt.py (UserPromptSubmit + SessionStart) -> press t\n");
29
36
  }
30
37
  }
31
38
  catch {
@@ -1,5 +1,6 @@
1
1
  /**
2
- * prism-route — self-installing UserPromptSubmit hook for Claude Code + Codex.
2
+ * prism-route — self-installing UserPromptSubmit + SessionStart(compact) hook
3
+ * for Claude Code + Codex.
3
4
  *
4
5
  * WHY A HOST HOOK. An MCP server never sees the user's prompt; the protocol
5
6
  * carries only what a tool call carries. session_route_prompt (the MCP tool)
@@ -8,6 +9,17 @@
8
9
  * prompt regardless of model behaviour, on both hosts, which is what the
9
10
  * operator requires ("i need automatic").
10
11
  *
12
+ * WHY A SECOND EVENT. Compaction discards the bootstrap with the rest of the
13
+ * transcript, and nothing the MCP server can do brings it back — the server
14
+ * is never told a compaction happened. Both hosts run SessionStart hooks
15
+ * with source "compact" before the next model request (Claude Code:
16
+ * matcher "compact"; Codex hooks reference, verified 2026-09-01: "SessionStart
17
+ * hooks that match source: compact run before the next model request"). The
18
+ * same script answers that event with `prism floor-digest` — the protected
19
+ * floor, one line per rule — so the model does not spend the rest of the
20
+ * session re-reading SKILL.md files it can no longer see. Once per
21
+ * compaction, zero per-prompt cost.
22
+ *
11
23
  * WHY SELF-INSTALLING. The previous generation of prism hooks was provisioned
12
24
  * by a bootstrap script once, then hand-maintained per machine — which is why
13
25
  * this machine has them and the other team machines do not. This module is
@@ -28,7 +40,11 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
28
40
  import { homedir } from "node:os";
29
41
  import { dirname, join, resolve } from "node:path";
30
42
  /** Bump to force the on-disk script to be rewritten on the next ensure. */
31
- export const PROMPT_ROUTE_HOOK_VERSION = "3";
43
+ export const PROMPT_ROUTE_HOOK_VERSION = "4";
44
+ /** SessionStart matcher: only the post-compaction fire carries the digest.
45
+ * The script re-checks `source` itself, so a host that ignores matchers on
46
+ * this event still injects nothing at startup/resume. */
47
+ const SESSION_START_MATCHER = "compact";
32
48
  const MARKER_FILE = ".prism-managed.json";
33
49
  const SCRIPT_FILE = "on_prompt.py";
34
50
  const HOOK_DIR = "prism-route";
@@ -66,6 +82,9 @@ export const PROMPT_ROUTE_HOOK_SCRIPT = `#!/usr/bin/env python3
66
82
 
67
83
  Routes every user prompt through the on-device skill matcher via
68
84
  'prism route-prompt'. Injects newly matched skill bodies as context.
85
+ On SessionStart with source "compact" (the host just discarded the
86
+ transcript, bootstrap included) it re-injects the protected-floor digest
87
+ via 'prism floor-digest' instead.
69
88
  Managed by prism; edits are overwritten on version bumps.
70
89
  """
71
90
  import json
@@ -76,16 +95,106 @@ import subprocess
76
95
  import sys
77
96
 
78
97
 
79
- def emit(extra=None):
98
+ def emit(extra=None, event="UserPromptSubmit"):
80
99
  out = {"continue": True, "suppressOutput": True}
81
100
  if extra:
82
101
  out["hookSpecificOutput"] = {
83
- "hookEventName": "UserPromptSubmit",
102
+ "hookEventName": event,
84
103
  "additionalContext": extra,
85
104
  }
86
105
  print(json.dumps(out))
87
106
 
88
107
 
108
+ def run_cli_json(cli, args, stdin_text=""):
109
+ """Run a prism CLI subcommand; return its last JSON stdout line or None."""
110
+ try:
111
+ result = subprocess.run(
112
+ [cli] + args,
113
+ input=stdin_text,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=10,
117
+ )
118
+ except Exception:
119
+ return None
120
+ if result.returncode != 0:
121
+ return None
122
+ # Parse the LAST line that is JSON: wrappers hooked into node via
123
+ # NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
124
+ # CLI's own output, and one polluted line must not kill routing.
125
+ for line in reversed(result.stdout.strip().splitlines()):
126
+ line = line.strip()
127
+ if line.startswith("{"):
128
+ try:
129
+ data = json.loads(line)
130
+ except Exception:
131
+ continue
132
+ return data if isinstance(data, dict) else None
133
+ return None
134
+
135
+
136
+ def session_state_path(payload):
137
+ session = str(
138
+ payload.get("session_id")
139
+ or payload.get("sessionId")
140
+ or payload.get("conversation_id")
141
+ or "default"
142
+ )
143
+ session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
144
+ state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
145
+ return state_dir, os.path.join(state_dir, session + ".json")
146
+
147
+
148
+ def payload_field(payload, *keys):
149
+ """First non-empty string among alternative spellings of one field.
150
+
151
+ Claude Code documents snake_case keys; Codex's payloads are described as
152
+ Claude-compatible, not Claude-identical, which is why the prompt lookup
153
+ below already hedges three ways. The SessionStart detection used to read
154
+ exactly one spelling, so a Codex payload spelled hookEventName/trigger
155
+ would fall to the prompt path and pass through with no digest and no
156
+ signal (round-11 review).
157
+ """
158
+ for key in keys:
159
+ value = payload.get(key)
160
+ if isinstance(value, str) and value.strip():
161
+ return value.strip()
162
+ return ""
163
+
164
+
165
+ def event_name(payload):
166
+ name = payload_field(payload, "hook_event_name", "hookEventName", "event_name", "eventName", "event")
167
+ return re.sub(r"[^a-z]", "", name.lower())
168
+
169
+
170
+ def on_session_start(payload):
171
+ # Only the post-compaction fire: at startup/resume the bootstrap carries
172
+ # the digest itself, and a second copy would be pure cost. "compact",
173
+ # "compaction", "compacted" all mean the transcript was just discarded.
174
+ source = payload_field(payload, "source", "trigger", "reason").lower()
175
+ if not source.startswith("compact"):
176
+ emit(event="SessionStart")
177
+ 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
+ state_dir, state_path = session_state_path(payload)
181
+ try:
182
+ os.remove(state_path)
183
+ except Exception:
184
+ pass
185
+ cli = find_cli()
186
+ if not cli:
187
+ emit(event="SessionStart")
188
+ return
189
+ 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")
196
+
197
+
89
198
  def find_cli():
90
199
  override = os.environ.get("PRISM_ROUTE_CLI")
91
200
  if override and os.path.exists(override):
@@ -111,6 +220,12 @@ def main():
111
220
  payload = json.loads(raw) if raw.strip() else {}
112
221
  except Exception:
113
222
  payload = {}
223
+ if not isinstance(payload, dict):
224
+ payload = {}
225
+
226
+ if event_name(payload) == "sessionstart":
227
+ on_session_start(payload)
228
+ return
114
229
 
115
230
  prompt = str(
116
231
  payload.get("prompt")
@@ -127,16 +242,7 @@ def main():
127
242
  # stretch, and the CLI caps identically on its side.
128
243
  prompt = prompt[:100_000]
129
244
 
130
- session = str(
131
- payload.get("session_id")
132
- or payload.get("sessionId")
133
- or payload.get("conversation_id")
134
- or "default"
135
- )
136
- session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
137
-
138
- state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
139
- state_path = os.path.join(state_dir, session + ".json")
245
+ state_dir, state_path = session_state_path(payload)
140
246
  loaded = []
141
247
  try:
142
248
  with open(state_path) as fh:
@@ -151,34 +257,8 @@ def main():
151
257
  emit()
152
258
  return
153
259
 
154
- try:
155
- result = subprocess.run(
156
- [cli, "route-prompt", "--loaded", ",".join(loaded)],
157
- input=prompt,
158
- capture_output=True,
159
- text=True,
160
- timeout=10,
161
- )
162
- except Exception:
163
- emit()
164
- return
165
- if result.returncode != 0:
166
- emit()
167
- return
168
-
169
- # Parse the LAST line that is JSON: wrappers hooked into node via
170
- # NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
171
- # CLI's own output, and one polluted line must not kill routing.
172
- data = None
173
- for line in reversed(result.stdout.strip().splitlines()):
174
- line = line.strip()
175
- if line.startswith("{"):
176
- try:
177
- data = json.loads(line)
178
- break
179
- except Exception:
180
- continue
181
- if not isinstance(data, dict):
260
+ data = run_cli_json(cli, ["route-prompt", "--loaded", ",".join(loaded)], prompt)
261
+ if data is None:
182
262
  emit()
183
263
  return
184
264
  names = [n for n in (data.get("names") or []) if isinstance(n, str)]
@@ -221,22 +301,37 @@ function hostSpecs(homeDir, env) {
221
301
  /**
222
302
  * Coarse Codex approval detection. Codex persists hook approvals as a
223
303
  * [hooks.state] table in config.toml keyed by definition hash; the hashing
224
- * algorithm is not public, so the only honest signals are "a state section
225
- * exists and mentions our hook path" (detected) or anything else
226
- * (pending-or-unknown). Never treat unknown as approved.
304
+ * algorithm is not public, so the only honest signal is the state section
305
+ * naming the EXACT command we register `… --v<version>` — once per event
306
+ * (two hooks, two approvals). The version-agnostic path alone is not
307
+ * evidence: a v3 approval names the same script, and Codex will skip the
308
+ * v4 definition it never saw. Matching the bare path let a stale approval
309
+ * read as "detected" from the second `prism connect` after every hook
310
+ * bump (round-8 review). Never treat unknown as approved.
227
311
  */
228
- function detectCodexApproval(codexRoot) {
312
+ function detectCodexApproval(codexRoot, wantedCommand) {
229
313
  try {
230
- const toml = readFileSync(join(codexRoot, "config.toml"), "utf8");
231
- const hasState = /\[hooks\.state/.test(toml);
232
- if (hasState && toml.includes(COMMAND_SIGNATURE))
314
+ // A Windows path lands in a TOML basic string with its backslashes
315
+ // ESCAPED (`C:\\Users\\…`); one-for-one replacement turned that into
316
+ // `C://Users//…` and the approval never matched (round-10 review). A
317
+ // run of backslashes is one separator.
318
+ const toml = readFileSync(join(codexRoot, "config.toml"), "utf8").replace(/\\+/g, "/");
319
+ if (!/\[hooks\.state/.test(toml))
320
+ return "pending-or-unknown";
321
+ const wanted = wantedCommand.replace(/\\+/g, "/");
322
+ const exact = toml.split(wanted).length - 1;
323
+ if (exact >= 2)
233
324
  return "detected";
234
- // Approvals are keyed by definition hash (algorithm not public). Once ANY
235
- // trust state exists we cannot distinguish ours from here and claiming
236
- // AWAITING TRUST after the operator pressed t would be a false alarm
237
- // against their own action. Distinct state, distinct wording.
238
- if (hasState)
239
- return "state-present-unverifiable";
325
+ // Our script is named, but not under the current definition: positive
326
+ // evidence that the trust on file is for an OLDER hook version, which
327
+ // Codex will not honour for this one.
328
+ if (exact === 0 && toml.includes(COMMAND_SIGNATURE))
329
+ return "pending-or-unknown";
330
+ // Trust state exists (one of our two entries, or hash-only entries that
331
+ // never name a command) and we cannot distinguish ours from here.
332
+ // Claiming AWAITING TRUST after the operator pressed t would be a false
333
+ // alarm against their own action. Distinct state, distinct wording.
334
+ return "state-present-unverifiable";
240
335
  }
241
336
  catch { /* unreadable = no evidence */ }
242
337
  return "pending-or-unknown";
@@ -288,60 +383,78 @@ function ensureRegistered(configPath, scriptPath, host) {
288
383
  // alone, not stripped).
289
384
  const wantsLimit = host === "codex";
290
385
  let config = {};
291
- let originalText;
292
- try {
293
- originalText = readFileSync(configPath, "utf8");
294
- const parsed = JSON.parse(originalText);
295
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
386
+ if (existsSync(configPath)) {
387
+ // An existing file we cannot parse is the operator's file with a syntax
388
+ // slip in it (model, env, permissions…) — "replace with {hooks}" would
389
+ // be a silent wipe. Register nothing; the caller reports it.
390
+ try {
391
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
392
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
393
+ return "skipped-unparseable";
296
394
  config = parsed;
297
395
  }
298
- }
299
- catch {
300
- /* missing or unreadable — create minimal */
396
+ catch {
397
+ return "skipped-unparseable";
398
+ }
301
399
  }
302
400
  const hooks = (config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
303
401
  ? config.hooks
304
402
  : {});
305
- const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
306
403
  const wanted = hookCommand(scriptPath);
307
- let stale = false;
308
- for (const entry of entries) {
309
- if (!entry || typeof entry !== "object")
310
- continue;
311
- const inner = entry.hooks;
312
- if (!Array.isArray(inner))
313
- continue;
314
- for (const h of inner) {
315
- if (!h || typeof h !== "object")
404
+ let updated = false;
405
+ let registered = false;
406
+ // One script, two events. Each event is converged independently so a
407
+ // machine registered under an older release (UserPromptSubmit only) gains
408
+ // the SessionStart entry on refresh, and a hand-removed entry is not
409
+ // resurrected by the other event still being current — the disabled marker
410
+ // is the opt-out for both.
411
+ const converge = (event, matcher) => {
412
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
413
+ let found = false;
414
+ for (const entry of entries) {
415
+ if (!entry || typeof entry !== "object")
316
416
  continue;
317
- // Normalize separators: on Windows join() registers a backslash path,
318
- // and a forward-slash signature would never match — so every ensure
319
- // would re-register a duplicate entry.
320
- const command = String(h.command ?? "");
321
- if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
417
+ const inner = entry.hooks;
418
+ if (!Array.isArray(inner))
322
419
  continue;
323
- const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
324
- if (command === wanted && limitCurrent)
325
- return "unchanged";
326
- // Same hook, older definition: UPDATE it in place. This is what makes a
327
- // refresh visible to Codex's definition-hash and on Claude it is a
328
- // harmless argv change.
329
- h.command = wanted;
330
- if (wantsLimit)
331
- h.additionalContextLimit = 0;
332
- stale = true;
420
+ for (const h of inner) {
421
+ if (!h || typeof h !== "object")
422
+ continue;
423
+ // Normalize separators: on Windows join() registers a backslash path,
424
+ // and a forward-slash signature would never match so every ensure
425
+ // would re-register a duplicate entry.
426
+ const command = String(h.command ?? "");
427
+ if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
428
+ continue;
429
+ found = true;
430
+ const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
431
+ if (command === wanted && limitCurrent)
432
+ continue;
433
+ // Same hook, older definition: UPDATE it in place. This is what makes a
434
+ // refresh visible to Codex's definition-hash — and on Claude it is a
435
+ // harmless argv change.
436
+ h.command = wanted;
437
+ if (wantsLimit)
438
+ h.additionalContextLimit = 0;
439
+ updated = true;
440
+ }
333
441
  }
334
- }
335
- if (!stale) {
336
- entries.push({
337
- matcher: "*",
338
- hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
339
- });
340
- }
341
- hooks.UserPromptSubmit = entries;
442
+ if (!found) {
443
+ entries.push({
444
+ matcher,
445
+ hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
446
+ });
447
+ registered = true;
448
+ }
449
+ hooks[event] = entries;
450
+ };
451
+ converge("UserPromptSubmit", "*");
452
+ converge("SessionStart", SESSION_START_MATCHER);
453
+ if (!updated && !registered)
454
+ return "unchanged";
342
455
  config.hooks = hooks;
343
456
  writeAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
344
- return stale ? "updated" : "registered";
457
+ return registered ? "registered" : "updated";
345
458
  }
346
459
  /**
347
460
  * Idempotently install the prism-route hook for both hosts.
@@ -371,7 +484,14 @@ export function ensurePromptRouteHook(options = {}) {
371
484
  // Never report a green "registered" as if it were active: Codex
372
485
  // SILENTLY SKIPS untrusted hooks, and "installed but inert" is the
373
486
  // exact failure class this feature exists to end.
374
- result.codexApproval = detectCodexApproval(spec.root);
487
+ //
488
+ // Approvals are keyed by definition hash, so a hooks.json we just
489
+ // rewrote (new entry, changed command) carries definitions the
490
+ // operator has never trusted — whatever config.toml says about the
491
+ // OLD ones. Only an untouched config can inherit prior trust.
492
+ result.codexApproval = config === "unchanged"
493
+ ? detectCodexApproval(spec.root, hookCommand(result.scriptPath))
494
+ : "pending-or-unknown";
375
495
  }
376
496
  results.push(result);
377
497
  }
@@ -32,6 +32,7 @@ import { getLLMProvider } from "../utils/llm/factory.js";
32
32
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
33
33
  import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
34
34
  import { MATERIALIZED_GENERATION_KEY } from "../skillManifestSync.js";
35
+ import { renderFloorDigestBlock, skillDigestFromSource, splitSkillFrontmatter } from "../utils/skillDigest.js";
35
36
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
36
37
  import { resolveProject } from "../utils/projectResolver.js";
37
38
  import { getUpdateNotice } from "../updateNotice.js";
@@ -196,15 +197,10 @@ function effectiveNativeBudget(level, requestedMaxChars) {
196
197
  * both "no body" and "frontmatter only".
197
198
  */
198
199
  function stripSkillFrontmatter(raw) {
199
- const text = (raw ?? "").trim();
200
- if (!text.startsWith("---"))
201
- return text;
202
- // Closing fence must be its own line; a body line of "---" mid-document is
203
- // not a terminator, so anchor on the newline pair.
204
- const end = text.indexOf("\n---", 3);
205
- if (end === -1)
206
- return text; // unterminated frontmatter — inline as-is
207
- return text.slice(text.indexOf("\n", end + 1) + 1).trim();
200
+ // One fence parser for every inline path. The local copy this replaced
201
+ // returned the WHOLE document — frontmatter included — for a file whose
202
+ // closing fence is its last line (no newline after it, indexOf → -1).
203
+ return splitSkillFrontmatter(raw).body;
208
204
  }
209
205
  /**
210
206
  * Room reserved for the trailing <prism_session /> facts line so it never
@@ -215,11 +211,32 @@ function stripSkillFrontmatter(raw) {
215
211
  * skill block this whole fix exists to deliver.
216
212
  */
217
213
  const SESSION_FACTS_RESERVE = 256;
214
+ /**
215
+ * Project/session-context budget per depth. This also bounds the standalone
216
+ * session_load_context / `prism load` output, which render no System Ready
217
+ * block — so the floor digest is NOT folded in here: the bootstrap adds the
218
+ * digest's own length on top (see startupMaxChars), and the context share at
219
+ * every depth stays exactly what it was before the digest existed.
220
+ */
218
221
  const NATIVE_STARTUP_MAX_CHARS = {
219
222
  quick: 4_000,
220
223
  standard: 8_000,
221
224
  deep: 30_000,
222
225
  };
226
+ /**
227
+ * Protected-floor digest allowance per depth (chars), paid on top of the
228
+ * context budget above. The bootstrap names the floor; the digest carries
229
+ * one line of each rule so the model knows what is in force without
230
+ * re-reading SKILL.md — measured 2026-09-01 on Codex rollout logs: median 8
231
+ * re-reads / 36KB per session, 823 / 5.1MB in the worst. quick stays
232
+ * names-only: sixteen useful lines are ~5.6K, more than the whole quick
233
+ * budget, and a partial digest would make the omitted rules look absent.
234
+ */
235
+ const NATIVE_FLOOR_DIGEST_MAX_CHARS = {
236
+ quick: 0,
237
+ standard: 6_000,
238
+ deep: 6_000,
239
+ };
223
240
  const NATIVE_CONTEXT_LIMITS = {
224
241
  quick: {
225
242
  warnings: [2, 200],
@@ -276,6 +293,10 @@ function parseNativeSkillNames(value) {
276
293
  return [];
277
294
  }
278
295
  }
296
+ /** The dashboard's default_context_depth, validated; anything else is standard. */
297
+ function nativeDepthFromSetting(value) {
298
+ return ["quick", "standard", "deep"].includes(value) ? value : "standard";
299
+ }
279
300
  async function resolveNativeSkillManifestSnapshot(syncResult) {
280
301
  const { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
281
302
  const [storedNamesValue, storedTierValue, committedGeneration, materializedGeneration] = await Promise.all([
@@ -368,10 +389,102 @@ function escapeNativeMarkdown(value) {
368
389
  function sanitizeNativeIdentity(value) {
369
390
  return value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").replace(/\s+/g, " ").trim();
370
391
  }
392
+ /**
393
+ * The protected floor's rules, one line each, for the names in `names`.
394
+ * Bodies come from the canonical skills root — what the host itself reads —
395
+ * with the manifest DB as fallback for a body committed but not yet on disk
396
+ * (validated-partial). Returns null when the budget cannot carry every name.
397
+ *
398
+ * Shared by the bootstrap block and the `prism floor-digest` CLI so a rule
399
+ * digested one way at startup is never digested another way after a
400
+ * compaction.
401
+ */
402
+ async function buildProtectedFloorDigest(names, options) {
403
+ if (names.length === 0 || options.maxChars <= 0)
404
+ return null;
405
+ const { readNativeSkillBody, resolveCanonicalSkillsDir } = await import("../skillManifestSync.js");
406
+ const entries = await Promise.all(names.map(async (name) => {
407
+ // The file on disk wins only when it digests. A truncated
408
+ // materialization — zero bytes, whitespace, a header cut off before its
409
+ // closing fence, a fenced header with no body — is not a body, and the
410
+ // DB copy the fallback exists for must win over it; before, any
411
+ // non-empty file shadowed the committed rule, and one cut mid-header
412
+ // rendered its YAML as the rule in force.
413
+ const onDisk = await readNativeSkillBody(name);
414
+ const source = skillDigestFromSource(onDisk) !== null
415
+ ? onDisk
416
+ : (await getSetting(`skill:${name}`, "")) || onDisk || "";
417
+ return { name, source };
418
+ }));
419
+ return renderFloorDigestBlock(entries, {
420
+ maxChars: options.maxChars,
421
+ linePrefix: options.linePrefix,
422
+ skillsRoot: resolveCanonicalSkillsDir(),
423
+ });
424
+ }
425
+ /**
426
+ * Protected floor ∩ entitled manifest, in floor order — the set the digest
427
+ * covers. prism-startup (free-tier chrome whose whole body is "call
428
+ * session_bootstrap") never gets a digest line because it is not on
429
+ * REQUIRED_PROTECTED_SKILL_NAMES at all — tests/skill-routing.test.ts pins
430
+ * the free and protected lists disjoint. An earlier `!free.has(name)` filter
431
+ * here re-stated that in code no input could ever exercise (round-11 review).
432
+ */
433
+ async function entitledProtectedFloorNames(provisionedNames) {
434
+ const { REQUIRED_PROTECTED_SKILL_NAMES } = await import("./skillRouting.js");
435
+ const provisioned = new Set(provisionedNames);
436
+ return REQUIRED_PROTECTED_SKILL_NAMES.filter((name) => provisioned.has(name));
437
+ }
438
+ /**
439
+ * The post-compaction payload for the prism-route host hook (SessionStart
440
+ * with source "compact"): the same digest the bootstrap rendered, without
441
+ * the blockquote chrome, from the last-synced manifest — cache only, never a
442
+ * portal round-trip, exactly like route-prompt.
443
+ *
444
+ * Same entitlement and depth decisions as the bootstrap, through the same
445
+ * code: the manifest snapshot re-filters the committed names by the stored
446
+ * tier (a free-tier DB can hold paid names — the resolver never trusts the
447
+ * list alone), and quick depth is names-only here too. Round-7 review found
448
+ * this path reading the raw names: bootstrap said "free, 1 skill" while the
449
+ * hook injected sixteen paid rules from the same database.
450
+ */
451
+ export async function renderProtectedFloorDigestForHook() {
452
+ // No sync on this path — "unchanged" is what the committed state is.
453
+ const snapshot = await resolveNativeSkillManifestSnapshot({
454
+ status: "unchanged", installed: [], updated: [], pruned: [], conflicts: [],
455
+ });
456
+ const depth = nativeDepthFromSetting(await getSetting("default_context_depth", "standard"));
457
+ const names = await entitledProtectedFloorNames(snapshot.names);
458
+ const block = await buildProtectedFloorDigest(names, {
459
+ maxChars: NATIVE_FLOOR_DIGEST_MAX_CHARS[depth],
460
+ linePrefix: "",
461
+ });
462
+ if (!block)
463
+ return { names: [], text: "" };
464
+ // Post-compaction is exactly when the bootstrap's STALE line is no longer
465
+ // on screen, so a digest rendered from a generation that never reached the
466
+ // skill roots must say so itself (round-11 review; the bootstrap copy of
467
+ // this warning is undeliveredWarning below).
468
+ const staleLine = snapshot.undeliveredGeneration
469
+ ? `⚠️ Skill files are STALE: the entitlement DB is at generation ` +
470
+ `\`${snapshot.undeliveredGeneration.slice(0, 12)}…\` but its files never finished reaching ` +
471
+ `the skill roots, so this digest may be older than the rules in force. Run a sync ` +
472
+ `(restart the host or \`prism connect\`).\n`
473
+ : "";
474
+ return {
475
+ names,
476
+ text: `Prism: context was compacted. The protected floor below is still in force for the rest of this session.\n${staleLine}${block}`,
477
+ };
478
+ }
371
479
  async function buildNativeSystemReadyBlock(snapshot, depth) {
372
480
  const { REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
373
481
  const provisioned = new Set(snapshot.names);
374
482
  const coreSkills = REQUIRED_NATIVE_SKILL_NAMES.filter((name) => provisioned.has(name));
483
+ // Directly under the names line, inside the blockquote: the digest is part
484
+ // of the same fact ("these rules are in force"), and the head of this block
485
+ // is what capped depths keep.
486
+ const floorDigest = await buildProtectedFloorDigest(await entitledProtectedFloorNames(snapshot.names), { maxChars: NATIVE_FLOOR_DIGEST_MAX_CHARS[depth], linePrefix: "> " });
487
+ const floorDigestLines = floorDigest ? `${floorDigest}\n` : "";
375
488
  const coreSkillSet = new Set(REQUIRED_NATIVE_SKILL_NAMES);
376
489
  const superSkills = snapshot.names.filter((name) => name.endsWith("-super-skill"));
377
490
  const superSkillAliases = superSkills.map((name) => `${name.slice(0, -"-super-skill".length)} (${name})`);
@@ -407,37 +520,41 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
407
520
  `reaching the skill roots. Agents are reading older skills. Run a sync ` +
408
521
  `(restart the host or \`prism connect\`) and report this if it persists.`
409
522
  : "";
523
+ const wrap = (text) => ({ text, floorDigestChars: floorDigestLines.length });
410
524
  if (snapshot.source === "validated-partial") {
411
- return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
525
+ return wrap(`> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
412
526
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
413
527
  `> - 📦 **Entitled skills (materialization incomplete):** ${snapshot.names.length}\n` +
414
528
  `> - 📚 **Core/protected entitlements:** ${formatBoundedSkillNames(coreSkills, "entitled")}\n` +
529
+ floorDigestLines +
415
530
  `> - 🧩 **Super-skill entitlements:** ${formatBoundedSkillNames(superSkillAliases, "entitled")}\n` +
416
531
  `> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
417
532
  `> - 🧠 **Context depth:** ${depth}\n` +
418
533
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
419
534
  conflictWarning +
420
- freeTierUpgradeLine(snapshot.tier);
535
+ freeTierUpgradeLine(snapshot.tier));
421
536
  }
422
537
  if (snapshot.source === "tier-fallback") {
423
- return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
538
+ return wrap(`> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
424
539
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
425
540
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
541
+ floorDigestLines +
426
542
  `> - 🧠 **Context depth:** ${depth}\n` +
427
543
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
428
544
  conflictWarning +
429
- freeTierUpgradeLine(snapshot.tier);
545
+ freeTierUpgradeLine(snapshot.tier));
430
546
  }
431
- return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
547
+ return wrap(`> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
432
548
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
433
549
  `> - 📦 **Provisioned skills:** ${snapshot.names.length}\n` +
434
550
  `> - 📚 **Core/protected skills provisioned:** ${formatBoundedSkillNames(coreSkills, "provisioned")}\n` +
551
+ floorDigestLines +
435
552
  `> - 🧩 **Super-skills provisioned:** ${formatBoundedSkillNames(superSkillAliases, "provisioned")}\n` +
436
553
  `> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
437
554
  `> - 🧠 **Context depth:** ${depth}\n` +
438
555
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
439
556
  conflictWarning +
440
- freeTierUpgradeLine(snapshot.tier);
557
+ freeTierUpgradeLine(snapshot.tier));
441
558
  }
442
559
  /**
443
560
  * The paid funnel's one startup line. Before 2026-08-05 the upgrade_url was
@@ -500,8 +617,12 @@ export function sliceCodepointSafe(text, end) {
500
617
  const last = cut.charCodeAt(cut.length - 1);
501
618
  return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
502
619
  }
503
- export function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
504
- const maxChars = effectiveNativeBudget(level, requestedMaxChars);
620
+ export function capNativeStartupText(text, level, requestedMaxChars, suffix = "", additiveChars = 0) {
621
+ // additiveChars rides on top of the depth budget: the protected-floor
622
+ // digest is paid for by its own allowance (NATIVE_FLOOR_DIGEST_MAX_CHARS)
623
+ // on every bootstrap path, so it never displaces the session or the
624
+ // System Ready tail — the projects path adds it the same way.
625
+ const maxChars = effectiveNativeBudget(level, requestedMaxChars) + Math.max(0, additiveChars);
505
626
  if (text.length + suffix.length <= maxChars)
506
627
  return text + suffix;
507
628
  const marker = `\n\n… Additional ${level} context omitted to keep native startup within its display budget.`;
@@ -2088,9 +2209,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2088
2209
  getSetting("agent_name", ""),
2089
2210
  getSetting("default_role", ""),
2090
2211
  ]);
2091
- const depth = ["quick", "standard", "deep"].includes(configuredDepth)
2092
- ? configuredDepth
2093
- : "standard";
2212
+ const depth = nativeDepthFromSetting(configuredDepth);
2094
2213
  const projects = [...new Set(configuredProjects.split(",").map((project) => project.trim()).filter(Boolean))];
2095
2214
  const configuredGreetingName = sanitizeNativeIdentity(agentName);
2096
2215
  const greetingName = configuredGreetingName
@@ -2098,7 +2217,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2098
2217
  : "developer";
2099
2218
  const role = sanitizeNativeIdentity(defaultRole) || "global";
2100
2219
  const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
2101
- const systemReadyBlock = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
2220
+ const { text: systemReadyBlock, floorDigestChars } = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
2102
2221
  // First run = the dashboard has never been touched: no agent identity AND no
2103
2222
  // projects. Measured 2026-08-05: a brand-new free-tier install was greeted
2104
2223
  // with "Welcome back", three "Not loaded" rows, a warning, and three
@@ -2159,7 +2278,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2159
2278
  return {
2160
2279
  content: [{
2161
2280
  type: "text",
2162
- text: capNativeStartupText(firstRunText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth, first_run: true })}`),
2281
+ text: capNativeStartupText(firstRunText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth, first_run: true })}`, floorDigestChars),
2163
2282
  }],
2164
2283
  isError: false,
2165
2284
  };
@@ -2172,12 +2291,16 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2172
2291
  return {
2173
2292
  content: [{
2174
2293
  type: "text",
2175
- text: capNativeStartupText(noProjectsText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth })}`),
2294
+ text: capNativeStartupText(noProjectsText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth })}`, floorDigestChars),
2176
2295
  }],
2177
2296
  isError: false,
2178
2297
  };
2179
2298
  }
2180
- const startupMaxChars = NATIVE_STARTUP_MAX_CHARS[depth];
2299
+ // The digest is additive: the project/session share is computed against
2300
+ // the same budget it had before the digest existed, at every depth. Round-7
2301
+ // review measured deep losing 5,630 chars of ledger/handoff per bootstrap
2302
+ // when only standard had been raised to cover it.
2303
+ const startupMaxChars = NATIVE_STARTUP_MAX_CHARS[depth] + floorDigestChars;
2181
2304
  let renderedProjectCount = projects.length;
2182
2305
  let omittedProjectsText = "";
2183
2306
  let perProjectMaxChars = 0;
@@ -38,8 +38,13 @@ function render(e) {
38
38
  * skills inline even when the budget is already blown (assembleSkillBlock), and
39
39
  * the repo-measured v26 floor is ~39k chars on its own — so the ceiling here is
40
40
  * roughly 87k - 39k - memory. `standard` matches the 8,400-char tranche the
41
- * existing v26 shape test already treats as the standard budget (60% of 14k
42
- * tokens); `deep` doubles it and still leaves headroom for deep history.
41
+ * existing v26 shape test already treats as the standard budget — the 60%
42
+ * share of a 4k-token response at 3.5 chars/token, i.e. exactly
43
+ * `resolveSkillBudgetChars(4_000)` below (~2,400 tokens, NOT 8,400: an
44
+ * earlier version of this comment called it "60% of 14k tokens", which is
45
+ * the same arithmetic in tokens instead of chars — 14k tokens would be a
46
+ * 29,400-char tranche); `deep` doubles it and still leaves headroom for
47
+ * deep history.
43
48
  *
44
49
  * `quick` is deliberately near-nothing — it is the setting a caller picks to
45
50
  * minimize context, and before this it still inlined the full skill payload,
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Protected-floor digest — the context-augmented half of skill delivery.
3
+ *
4
+ * The native bootstrap NAMES the protected floor and never carries its rules
5
+ * (bodies live on disk under the skills root). Measured 2026-09-01 across
6
+ * Codex rollout logs: the model re-reads SKILL.md files to recover them —
7
+ * median 8 reads / 36KB per session, 823 reads / 5.1MB across 498
8
+ * compactions in the worst — each a tool round trip on a host whose
9
+ * per-result cap is 10K tokens. On Gemini and Cursor there is no prompt hook
10
+ * at all, so the bootstrap is the only prism channel they ever get.
11
+ *
12
+ * A digest is not the rule. It is enough of the rule that the model knows
13
+ * what is in force and where the full text lives, in ~350 chars instead of
14
+ * ~3,000. Authors pin the wording with a `digest:` frontmatter key; without
15
+ * one it is derived from the body — the leading substantive paragraphs,
16
+ * each new section's heading kept in front of its first unit, then the
17
+ * section headings as a map — so it works on every skill that exists today
18
+ * with no pipeline change.
19
+ *
20
+ * Pure functions, no I/O: the bootstrap and the `prism floor-digest` CLI
21
+ * (post-compaction re-injection via the host hook) render through the same
22
+ * code so the two channels cannot drift.
23
+ */
24
+ /** Per-skill ceiling. 16 floor skills × 360 ≈ 5.8K chars — under the 6K
25
+ * block budget and far under Claude Code's 10K-char hook context cap. */
26
+ export const SKILL_DIGEST_MAX_CHARS = 360;
27
+ /** Below this a digest is a name with punctuation; render names only. */
28
+ export const SKILL_DIGEST_MIN_CHARS = 80;
29
+ /** Ceiling on the raw source a digest is derived from. Real floor bodies are
30
+ * 1.3–5.2K chars and the digest only ever uses the head; unbounded input is
31
+ * regex food (a 128KB body measured 27s of bootstrap CPU before this cap). */
32
+ export const SKILL_DIGEST_SOURCE_MAX_CHARS = 64_000;
33
+ /**
34
+ * Split a SKILL.md into its frontmatter text and body — the one fence parser
35
+ * for every inline path (the bootstrap's symptom-skill inlining strips
36
+ * through this too). The closing fence must open a line, so a body line of
37
+ * "---" is not a terminator.
38
+ */
39
+ export function splitSkillFrontmatter(raw) {
40
+ const text = (raw ?? "").trim();
41
+ if (!text.startsWith("---"))
42
+ return { frontmatter: "", body: text };
43
+ const end = text.indexOf("\n---", 3);
44
+ if (end === -1)
45
+ return { frontmatter: "", body: text };
46
+ const frontmatter = text.slice(3, end).trim();
47
+ // A closing fence on the last line has no newline after it: the body is
48
+ // empty, not the whole document.
49
+ const bodyStart = text.indexOf("\n", end + 1);
50
+ const body = bodyStart === -1 ? "" : text.slice(bodyStart + 1).trim();
51
+ return { frontmatter, body };
52
+ }
53
+ /**
54
+ * The author-pinned digest, when present. Supports the YAML shapes a skill
55
+ * author will actually type: `digest: text`, `digest: "quoted"`,
56
+ * `digest: 'quoted'`, and the block scalars `digest: >` / `digest: |` with
57
+ * indented continuation lines. Anything else (nested maps, anchors) is
58
+ * ignored rather than misread — the derived digest takes over.
59
+ */
60
+ export function readFrontmatterDigest(frontmatter) {
61
+ const lines = frontmatter.split("\n");
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const m = /^digest:\s*(.*)$/.exec(lines[i]);
64
+ if (!m)
65
+ continue;
66
+ const inline = m[1].trim();
67
+ if (inline === ">" || inline === "|" || inline === ">-" || inline === "|-") {
68
+ const parts = [];
69
+ for (let j = i + 1; j < lines.length; j++) {
70
+ const line = lines[j];
71
+ if (line.trim() === "") {
72
+ if (parts.length)
73
+ parts.push("");
74
+ continue;
75
+ }
76
+ if (!/^\s/.test(line))
77
+ break; // dedented = next key
78
+ parts.push(line.trim());
79
+ }
80
+ const joined = parts.join(" ").replace(/\s+/g, " ").trim();
81
+ return joined || null;
82
+ }
83
+ let value = inline;
84
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
85
+ value = value.slice(1, -1);
86
+ }
87
+ value = value.replace(/\s+/g, " ").trim();
88
+ return value || null;
89
+ }
90
+ return null;
91
+ }
92
+ /** slice() that never ends on a lone high surrogate. */
93
+ function sliceCodepointSafe(text, end) {
94
+ const cut = text.slice(0, Math.max(0, end));
95
+ const last = cut.charCodeAt(cut.length - 1);
96
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
97
+ }
98
+ function truncate(text, maxChars) {
99
+ if (text.length <= maxChars)
100
+ return text;
101
+ if (maxChars <= 1)
102
+ return "";
103
+ return sliceCodepointSafe(text, maxChars - 1).trimEnd() + "…";
104
+ }
105
+ /**
106
+ * One inert display line. Markdown emphasis is dropped (it renders as noise
107
+ * inside a list item), wiki-links keep their target name, and EVERY angle
108
+ * bracket becomes a guillemet — a host's markdown/HTML renderer eats `<x>`
109
+ * as an unknown tag (observed: a placeholder rendered as
110
+ * knowledge_search("")), and this text is model context: a body could
111
+ * otherwise carry a forged `<prism_session … />` line, which the server
112
+ * instructions tell the model to reuse. Brackets are replaced one by one,
113
+ * not as matched spans: a span rule let a tag split across two paragraphs,
114
+ * list items, or headings re-form when the assembler joined them (a
115
+ * verifier's repro), and CommonMark lets an inline tag span one line ending,
116
+ * so even two adjacent block lines could re-form one. With no `<` or `>` in
117
+ * any unit there is nothing to re-form; "› 60 min" still reads. The
118
+ * wiki-link class excludes `[` so a run of unclosed brackets cannot go
119
+ * quadratic.
120
+ */
121
+ function inertLine(text) {
122
+ return text
123
+ .replace(/\[\[([^\[\]]+)\]\]/g, "$1")
124
+ .replace(/\*\*|__/g, "")
125
+ .replace(/(^|\s)[*_](\S[^*_]*\S)[*_](?=\s|[.,;:!?]|$)/g, "$1$2")
126
+ .replace(/</g, "‹")
127
+ .replace(/>/g, "›")
128
+ .replace(/[\u200b-\u200d\ufeff]/g, "")
129
+ .replace(/\s+/g, " ")
130
+ .trim();
131
+ }
132
+ const HEADING = /^(#{1,6})\s+(.*)$/;
133
+ const LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/;
134
+ const BLOCKQUOTE_MARKER = /^(?:>\s*)+/;
135
+ /**
136
+ * Derive a digest from a body: the leading substantive units — paragraphs
137
+ * and individual list items (headings, tables, fences and rules skipped) —
138
+ * as many as fit, then the section headings as a map when room remains. The
139
+ * first unit is always present, truncated if it alone exceeds the cap. List
140
+ * items are units of their own because a floor skill's rules are usually a
141
+ * numbered list: folded into one paragraph they never fit and the digest
142
+ * degenerated to the one-line intro (pre-push-audit: 160 of 360 chars).
143
+ *
144
+ * A unit that opens a new section carries its heading (`Do NOT delegate:
145
+ * Tasks requiring …`). Without it the join reads as one continuous
146
+ * instruction, and a heading is where a skill's polarity flips: measured
147
+ * on this machine, local-inference-first digested to "…your first action is
148
+ * prism_infer. … Tasks requiring current conversation context" — the first
149
+ * bullet of its "Do NOT delegate" section, presented as a thing to route.
150
+ */
151
+ export function deriveSkillDigest(body, maxChars = SKILL_DIGEST_MAX_CHARS) {
152
+ return assembleSkillDigest(parseSkillDigestBody(body), maxChars);
153
+ }
154
+ /** Label for a heading whose text is nothing once inert (`## **`). The
155
+ * boundary still has to show in the digest — dropping it would join the
156
+ * next unit onto the previous section. */
157
+ const UNTITLED_SECTION = "(untitled)";
158
+ function parseSkillDigestBody(body) {
159
+ const units = [];
160
+ const headings = [];
161
+ let current = [];
162
+ let currentIsList = false;
163
+ let section = null;
164
+ let headingSeen = false;
165
+ let inFence = false;
166
+ const flush = () => {
167
+ if (current.length) {
168
+ const text = inertLine(current.join(" "));
169
+ if (text)
170
+ units.push({ text, section });
171
+ }
172
+ current = [];
173
+ currentIsList = false;
174
+ };
175
+ const openSection = (level, rawText) => {
176
+ // The first heading of the document, when it is an H1 that precedes
177
+ // every unit, is the title (the name says it) and owns nothing. Any
178
+ // other heading — including a LATER H1 — owns the units that follow
179
+ // it: an H1 or H4 "Never" flips polarity as surely as an H2 does, so a
180
+ // level is never a reason to reset the section to the intro. H2 is the
181
+ // map; H3 only when a skill has no H2 at all
182
+ // (absence-of-evidence-protocol is all H3); a mid-document H1 maps as
183
+ // an H2.
184
+ const text = inertLine(rawText);
185
+ const isTitle = level === 1 && !headingSeen && units.length === 0;
186
+ headingSeen = true;
187
+ if (isTitle) {
188
+ section = null;
189
+ return;
190
+ }
191
+ section = text || UNTITLED_SECTION;
192
+ if (level <= 3)
193
+ headings.push(`${Math.max(level, 2)}:${text}`);
194
+ };
195
+ for (const rawLine of body.split("\n")) {
196
+ const line = rawLine.trim().replace(BLOCKQUOTE_MARKER, "");
197
+ if (line.startsWith("```") || line.startsWith("~~~")) {
198
+ inFence = !inFence;
199
+ flush();
200
+ continue;
201
+ }
202
+ if (inFence)
203
+ continue;
204
+ if (line === "") {
205
+ flush();
206
+ continue;
207
+ }
208
+ const heading = HEADING.exec(line);
209
+ if (heading) {
210
+ flush();
211
+ openSection(heading[1].length, heading[2]);
212
+ continue;
213
+ }
214
+ // Setext: a lone paragraph line underlined with `===` (H1) or `---`
215
+ // (H2) is a heading, not a paragraph followed by a rule.
216
+ if (current.length === 1 && !currentIsList && /^(?:=+|-+)$/.test(line)) {
217
+ const text = current[0];
218
+ current = [];
219
+ openSection(line.startsWith("=") ? 1 : 2, text);
220
+ continue;
221
+ }
222
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line) || line.startsWith("|")) {
223
+ flush();
224
+ continue;
225
+ }
226
+ if (LIST_MARKER.test(line)) {
227
+ flush();
228
+ currentIsList = true;
229
+ }
230
+ current.push(line.replace(LIST_MARKER, ""));
231
+ }
232
+ flush();
233
+ const h2 = headings.filter((h) => h.startsWith("2:")).map((h) => h.slice(2));
234
+ const map = (h2.length ? h2 : headings.map((h) => h.slice(2))).filter(Boolean);
235
+ return { pinned: null, units, map };
236
+ }
237
+ /** Fit parsed material to a cap. Null when there is nothing to say — which
238
+ * does not depend on the cap, so a block can count digestible entries
239
+ * before it knows each one's share. */
240
+ export function assembleSkillDigest(parsed, maxChars = SKILL_DIGEST_MAX_CHARS) {
241
+ if (!parsed)
242
+ return null;
243
+ if (parsed.pinned)
244
+ return truncate(parsed.pinned, maxChars);
245
+ const { units, map } = parsed;
246
+ if (units.length === 0 && map.length === 0)
247
+ return null;
248
+ let out = "";
249
+ let section = null;
250
+ for (const unit of units) {
251
+ // The heading is spent only where the section changes, so a run of
252
+ // bullets under one heading pays for it once.
253
+ const text = unit.section && unit.section !== section ? `${unit.section}: ${unit.text}` : unit.text;
254
+ if (!out) {
255
+ out = truncate(text, maxChars);
256
+ section = unit.section;
257
+ continue;
258
+ }
259
+ if (out.length + 1 + text.length > maxChars)
260
+ break;
261
+ out = `${out} ${text}`;
262
+ section = unit.section;
263
+ }
264
+ if (map.length) {
265
+ const sections = `Sections: ${map.join(" · ")}.`;
266
+ if (!out)
267
+ out = truncate(sections, maxChars);
268
+ else if (out.length + 1 + sections.length <= maxChars)
269
+ out = `${out} ${sections}`;
270
+ else if (maxChars - out.length - 1 >= 40)
271
+ out = `${out} ${truncate(sections, maxChars - out.length - 1)}`;
272
+ }
273
+ // Units were made inert one by one; the joins above are a new string, so
274
+ // it is made inert as a whole too (a `*` closing across a join, say).
275
+ // Every rule in inertLine shortens or preserves length, so this cannot
276
+ // push the digest back over maxChars.
277
+ return inertLine(out) || null;
278
+ }
279
+ /** A document that opens a frontmatter fence and never closes it — a
280
+ * materialization cut off mid-header. `splitSkillFrontmatter` hands the
281
+ * whole document back as the body in that case (right for the inline
282
+ * path, which shows the file as it is); for a one-line digest that would
283
+ * present the YAML — name, description, the on-device `prompt_triggers`
284
+ * patterns — as the rule in force. It is not a body. */
285
+ export function hasUnclosedFrontmatter(raw) {
286
+ const text = (raw ?? "").trim();
287
+ // The opener must be followed by a `key:`-shaped line — the first
288
+ // non-whitespace text after it decides, blank lines are skipped: a body
289
+ // that opens with a horizontal rule and then a heading or `Rule one.` is
290
+ // still a body. A body whose first text after the rule happens to be
291
+ // key-shaped (`Note: …`, with or without a blank line between) reads as an
292
+ // unclosed header — a deliberate false positive that fails toward "no
293
+ // digest", never toward presenting YAML as a rule (round-11/12 review).
294
+ return /^---\r?\n\s*[A-Za-z_][\w-]*\s*:/.test(text) && text.indexOf("\n---", 3) === -1;
295
+ }
296
+ /** Parse a raw SKILL.md once: frontmatter `digest:` when pinned, else the
297
+ * body's material. Null for no source, or for a truncated one. */
298
+ export function parseSkillDigest(raw) {
299
+ if (!raw)
300
+ return null;
301
+ // The cap is applied first so a fence that closes beyond it reads as
302
+ // unclosed — the same slice is what gets split.
303
+ const source = raw.slice(0, SKILL_DIGEST_SOURCE_MAX_CHARS);
304
+ if (hasUnclosedFrontmatter(source))
305
+ return null;
306
+ const { frontmatter, body } = splitSkillFrontmatter(source);
307
+ const pinned = readFrontmatterDigest(frontmatter);
308
+ if (pinned)
309
+ return { pinned: inertLine(pinned), units: [], map: [] };
310
+ return parseSkillDigestBody(body);
311
+ }
312
+ /** Frontmatter `digest:` when pinned, else derived from the body. */
313
+ export function skillDigestFromSource(raw, maxChars = SKILL_DIGEST_MAX_CHARS) {
314
+ return assembleSkillDigest(parseSkillDigest(raw), maxChars);
315
+ }
316
+ const HEADER_LEAD = "📖 Protected floor — rules in force this session";
317
+ /** Line tail for a name whose body says nothing digestible (not on this
318
+ * machine, or all fences and tables). The rule is still in force — the
319
+ * server named it — so the line says why it is bare instead of looking like
320
+ * a skill with nothing to say. */
321
+ export const SKILL_DIGEST_UNAVAILABLE = "digest unavailable; read the full skill";
322
+ /**
323
+ * Render the block, or null when the budget cannot carry a useful digest for
324
+ * every digestible name — a floor whose lines were squeezed below
325
+ * SKILL_DIGEST_MIN_CHARS would be names with punctuation, so the caller keeps
326
+ * its name-only line instead. A name whose body cannot be digested at any
327
+ * budget still renders, marked SKILL_DIGEST_UNAVAILABLE, so an absent rule is
328
+ * never mistaken for a silent one. The per-skill cap is derived from the
329
+ * budget BEFORE digesting, so the derivation fits its rules and section map
330
+ * to the room it actually has instead of being cut a second time; one long
331
+ * body cannot starve the rest; the result never exceeds maxChars.
332
+ */
333
+ export function renderFloorDigestBlock(entries, options) {
334
+ if (entries.length === 0 || options.maxChars <= 0)
335
+ return null;
336
+ const prefix = options.linePrefix ?? "";
337
+ const root = options.skillsRoot ? ` (full text: ${options.skillsRoot}/‹name›/SKILL.md)` : "";
338
+ const header = `${prefix}- ${HEADER_LEAD}${root}:`;
339
+ // Parse each body exactly once; the share is derived from how many can say
340
+ // anything at all, which the assembler decides independently of the cap.
341
+ const parsed = entries.map((e) => parseSkillDigest(e.source));
342
+ const digestible = parsed.map((p) => assembleSkillDigest(p) !== null);
343
+ const withBody = digestible.filter(Boolean).length;
344
+ if (withBody === 0)
345
+ return null;
346
+ // Every char that is not digest: the per-line chrome, the newline, and the
347
+ // full marker line for a name that gets no digest at all.
348
+ const chrome = entries.reduce((sum, e, i) => sum + `${prefix} - ${e.name} — `.length + (digestible[i] ? 0 : SKILL_DIGEST_UNAVAILABLE.length) + 1, 0);
349
+ const perSkill = Math.min(SKILL_DIGEST_MAX_CHARS, Math.floor((options.maxChars - header.length - 1 - chrome) / withBody));
350
+ if (perSkill < SKILL_DIGEST_MIN_CHARS)
351
+ return null;
352
+ const lines = [header];
353
+ entries.forEach((entry, index) => {
354
+ const digest = digestible[index] ? assembleSkillDigest(parsed[index], perSkill) : null;
355
+ lines.push(`${prefix} - ${entry.name} — ${digest ?? SKILL_DIGEST_UNAVAILABLE}`);
356
+ });
357
+ // The bound is the arithmetic above: header + chrome + withBody × perSkill
358
+ // ≤ maxChars, and a digest never exceeds its cap. There is deliberately no
359
+ // post-hoc `length <= maxChars ? block : null` guard — one turned a
360
+ // budgeting slip into a silently missing floor that the budget sweep could
361
+ // not see (the mutant survived); the sweep asserts the bound directly.
362
+ return lines.join("\n");
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.17.3",
3
+ "version": "20.18.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",