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 +61 -3
- package/dist/cli.js +36 -3
- package/dist/postinstall.js +9 -2
- package/dist/promptRouteHostHook.js +218 -98
- package/dist/tools/ledgerHandlers.js +147 -24
- package/dist/utils/skillBudget.js +7 -2
- package/dist/utils/skillDigest.js +363 -0
- package/package.json +1 -1
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—
|
|
583
|
-
lifecycle
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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)')
|
package/dist/postinstall.js
CHANGED
|
@@ -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.
|
|
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 ->
|
|
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
|
|
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 = "
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
225
|
-
*
|
|
226
|
-
* (
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
42
|
-
*
|
|
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.
|
|
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",
|