prism-mcp-server 20.17.2 → 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.
@@ -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
+ }