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.
- package/README.md +74 -5
- package/dist/cli.js +36 -3
- package/dist/postinstall.js +17 -7
- package/dist/promptRouteHostHook.js +223 -114
- package/dist/server.js +5 -3
- 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
|
@@ -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
|
+
}
|