preamble-hoist.ts
116 lines · scripts/resolvers/preamble-hoist.ts at 3f79d81
1/**
2 * Shared-preamble hoist (#3199, GSTA-96).
3 *
4 * Tier 2+ skills used to inline the whole preamble (~27K chars) in every
5 * SKILL.md, so each invocation paid for it again. Its skill-independent
6 * sections now render once per host into `sections/preamble.md` beside each
7 * tier 2+ SKILL.md. The SKILL.md keeps a pointer to that file and every
8 * preamble section whose text is this skill's own: the start command, any
9 * section that names the skill or has a skill-specific variant, and tier 3's
10 * additions. The pointer asks for a Read unless this skill's own earlier Read
11 * of that file, headed by its id, is still in context, so a session loads it
12 * once and text that merely carries the id never stands in for it.
13 *
14 * A section is hoisted only when this skill's render of it is byte-identical
15 * to the shared render, so no skill's instructions change; they move.
16 * Tier 1 skills keep their short preamble inline.
17 */
18import { createHash } from 'node:crypto';
19import * as path from 'node:path';
20import type { TemplateContext } from './types';
21import { preambleSections } from './preamble';
23/** A skill name no generator special-cases; a section that mentions it names the skill. */
24const SHARED_SKILL = 'gstack-shared-preamble';
25const SHARED_TIER = 2;
26export const SHARED_PREAMBLE_FILE = 'preamble.md';
28export function hoistsPreamble(ctx: TemplateContext): boolean {
29 return (ctx.preambleTier ?? 0) >= SHARED_TIER;
30}
32/** Split rendered markdown at headings of one level (`## ` or `### `) outside code fences. */
33function splitSections(text: string, marker = '## '): string[] {
34 const sections: string[] = [];
35 let current: string[] = [];
36 let fence: string | null = null;
37 for (const line of text.split('\n')) {
38 const [, delimiter, info] = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line) ?? [];
39 if (delimiter) {
40 if (!fence) fence = delimiter;
41 else if (delimiter[0] === fence[0] && delimiter.length >= fence.length && !info.trim()) fence = null;
42 } else if (!fence && line.startsWith(marker) && current.some(l => l.trim())) {
43 sections.push(current.join('\n').trim());
44 current = [];
45 }
46 current.push(line);
47 }
48 if (current.some(l => l.trim())) sections.push(current.join('\n').trim());
49 return sections;
50}
52function sharedSections(ctx: TemplateContext): string[] {
53 const canonical = preambleSections({ ...ctx, skillName: SHARED_SKILL, preambleTier: SHARED_TIER, interactive: undefined });
54 // A unit without its own heading stays inline: in the shared file it would read as part of the
55 // section above it, which a skill may replace.
56 return canonical.flatMap(part => splitSections(part)).filter(section => section.startsWith('## ') && !section.includes(SHARED_SKILL));
57}
59function sharedId(ctx: TemplateContext, shared: string[]): string {
60 return createHash('sha256').update(`${ctx.installRoot ?? ''}\n${shared.join('\n\n')}`).digest('hex').slice(0, 12);
61}
63const headingOf = (section: string) => section.split('\n', 1)[0]!;
65/**
66 * A skill-specific variant of a shared section, cut down to the `### `
67 * subsections that differ plus any it adds after the shared ones, when it
68 * keeps the shared heading, intro and subsection headings in order.
69 * Otherwise the whole section stays inline.
70 */
71function variantSubsections(own: string, shared: string): string | null {
72 const ownParts = splitSections(own, '### ');
73 const sharedParts = splitSections(shared, '### ');
74 if (ownParts.length < 2 || ownParts.length < sharedParts.length || ownParts[0] !== sharedParts[0]) return null;
75 if (sharedParts.some((part, i) => headingOf(part) !== headingOf(ownParts[i]!))) return null;
76 const changed = ownParts.slice(1, sharedParts.length).filter((part, i) => part !== sharedParts[i + 1]);
77 const added = ownParts.slice(sharedParts.length);
78 if (changed.length === 0 && added.length === 0) return null;
79 const note = added.length
80 ? 'Only these subsections replace the shared ones or, under a new heading, add to its end; the rest of this section is shared.'
81 : 'Only these subsections replace the shared ones; the rest of this section is shared.';
82 return [`${headingOf(own)}\n\n${note}`, ...changed, ...added].join('\n\n');
83}
85/** Where the installed SKILL.md finds its copy of the shared file. */
86function sharedPath(ctx: TemplateContext): string {
87 if (ctx.host === 'claude') return `\`${ctx.paths.skillRoot}/${path.basename(path.dirname(ctx.tmplPath))}/sections/${SHARED_PREAMBLE_FILE}\``;
88 const installed = ctx.skillName.startsWith('gstack-') ? ctx.skillName : `gstack-${ctx.skillName}`;
89 return `\`sections/${SHARED_PREAMBLE_FILE}\` relative to the installed \`${installed}\` SKILL.md directory`;
90}
92/** {{PREAMBLE}}: the full preamble for tier 1, else the pointer plus this skill's own sections. */
93export function generateSkillPreamble(ctx: TemplateContext): string {
94 const own = preambleSections(ctx);
95 if (!hoistsPreamble(ctx)) return own.join('\n\n');
96 const shared = sharedSections(ctx);
97 const hoisted = new Set(shared);
98 const sharedByHeading = new Map(shared.map(section => [headingOf(section), section]));
99 const inline = own.flatMap(part => splitSections(part)).filter(section => !hoisted.has(section)).map(section => {
100 const base = sharedByHeading.get(headingOf(section));
101 return (base && variantSubsections(section, base)) ?? section;
102 });
103 const pointer = `## Shared preamble (load first)
105This skill follows gstack shared preamble \`${sharedId(ctx, shared)}\` (AskUserQuestion format, voice, writing, completeness and completion rules). If your Read of that file, headed by this id, is still in context, do not read it again; otherwise Read ${sharedPath(ctx)} in full before the command below. A section or subsection here with a shared heading replaces the shared one.`;
106 return [pointer, ...inline].join('\n\n');
107}
109/** Body of `sections/preamble.md` for a tier 2+ skill; identical for every such skill on a host. */
110export function generateSharedPreambleFile(ctx: TemplateContext): string {
111 const shared = sharedSections(ctx);
112 return `# gstack shared preamble ${sharedId(ctx, shared)}
114${shared.join('\n\n')}
115`;
116}