Skip to content

Commit 5158b49

Browse files
authored
feat(bootstrap): move skill catalog into system prompt (#365)
* docs(plans): add bootstrap message0 skill catalog plan * feat(skills): add systematic skill catalog helper * feat(bootstrap): canonicalize bootstrap in first system message * feat(bootstrap): include bundled skill catalog * feat(skill-tool): use compact skill catalog fallback * fix(bootstrap): preserve malformed marker fragments * test(skills): organize skill catalog imports * fix(bootstrap): address catalog review feedback
1 parent a21cd96 commit 5158b49

8 files changed

Lines changed: 1197 additions & 65 deletions

docs/plans/2026-05-13-001-feat-bootstrap-message0-skill-catalog-plan.md

Lines changed: 288 additions & 0 deletions
Large diffs are not rendered by default.

src/lib/bootstrap.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from 'node:os'
33
import path from 'node:path'
44
import type { SystematicConfig } from './config.js'
55
import { parseFrontmatter } from './frontmatter.js'
6+
import { renderCatalogVerbose } from './skill-catalog.js'
67

78
// Signatures used to identify OpenCode internal agents (title generator,
89
// summarizer, etc.) so bootstrap injection can be skipped. Exported for
@@ -20,21 +21,66 @@ const BOOTSTRAP_MARKER_CLOSE = '</SYSTEMATIC_WORKFLOWS>'
2021

2122
const findBootstrapMarkerBlock = (
2223
entry: string,
23-
): { start: number; end: number } | null => {
24-
const start = entry.indexOf(BOOTSTRAP_MARKER_OPEN)
24+
fromIndex = 0,
25+
): { start: number; closeStart: number; end: number } | null => {
26+
const start = entry.indexOf(BOOTSTRAP_MARKER_OPEN, fromIndex)
2527
if (start === -1) return null
2628
const closeStart = entry.indexOf(
2729
BOOTSTRAP_MARKER_CLOSE,
2830
start + BOOTSTRAP_MARKER_OPEN.length,
2931
)
3032
if (closeStart === -1) return null
31-
return { start, end: closeStart + BOOTSTRAP_MARKER_CLOSE.length }
33+
return { start, closeStart, end: closeStart + BOOTSTRAP_MARKER_CLOSE.length }
34+
}
35+
36+
const removeCompleteBootstrapBlocks = (entry: string): string => {
37+
const segments: string[] = []
38+
let cursor = 0
39+
let block = findBootstrapMarkerBlock(entry, cursor)
40+
let hadNestedBlock = false
41+
42+
while (block !== null) {
43+
const nestedStart = entry.indexOf(
44+
BOOTSTRAP_MARKER_OPEN,
45+
block.start + BOOTSTRAP_MARKER_OPEN.length,
46+
)
47+
48+
if (nestedStart !== -1 && nestedStart < block.closeStart) {
49+
hadNestedBlock = true
50+
segments.push(entry.slice(cursor, nestedStart))
51+
cursor = nestedStart
52+
block = findBootstrapMarkerBlock(entry, cursor)
53+
continue
54+
}
55+
56+
segments.push(entry.slice(cursor, block.start))
57+
cursor = block.end
58+
block = findBootstrapMarkerBlock(entry, cursor)
59+
}
60+
61+
if (cursor === 0) return entry
62+
segments.push(entry.slice(cursor))
63+
const result = segments.join('')
64+
65+
// When nested blocks are removed, previously truncated outer open/close
66+
// markers may now form a complete block. Recurse once to clean up in a
67+
// single call rather than requiring a second transform invocation.
68+
if (hadNestedBlock) {
69+
return removeCompleteBootstrapBlocks(result)
70+
}
71+
72+
return result
3273
}
3374

3475
/**
35-
* Inject bootstrap content into the system prompt array, replacing any
36-
* existing `<SYSTEMATIC_WORKFLOWS>` block. Multi-load idempotency is via
37-
* the marker — most-recently-registered plugin wins under FIFO hook order.
76+
* Inject bootstrap content into the system prompt array, placing exactly one
77+
* canonical block at the end of `output.system[0]`.
78+
*
79+
* All complete `<SYSTEMATIC_WORKFLOWS>…</SYSTEMATIC_WORKFLOWS>` blocks are
80+
* removed from every system entry first, then the current content is appended
81+
* once to `output.system[0]`. Partial/malformed marker fragments are left
82+
* untouched. This makes duplicate registration and prior last-entry placement
83+
* converge to the new canonical location; later invocations win.
3884
*
3985
* Exported for test access — must NOT be re-exported from the plugin entry
4086
* point (src/index.ts) because OpenCode's plugin loader expects a single
@@ -45,20 +91,18 @@ export const applyBootstrapContent = (
4591
output: { system: string[] },
4692
content: string,
4793
): void => {
94+
// Remove every complete marker block from every entry.
4895
for (let i = 0; i < output.system.length; i++) {
49-
const entry = output.system[i]
50-
const block = findBootstrapMarkerBlock(entry)
51-
if (block !== null) {
52-
output.system[i] =
53-
entry.slice(0, block.start) + content + entry.slice(block.end)
54-
return
55-
}
96+
output.system[i] = removeCompleteBootstrapBlocks(output.system[i])
5697
}
57-
if (output.system.length > 0) {
58-
output.system[output.system.length - 1] += `\n\n${content}`
59-
} else {
98+
99+
if (output.system.length === 0) {
60100
output.system.push(content)
101+
return
61102
}
103+
104+
const first = output.system[0]
105+
output.system[0] = first.length > 0 ? `${first}\n\n${content}` : content
62106
}
63107

64108
export interface BootstrapDeps {
@@ -113,6 +157,11 @@ export function getBootstrapContent(
113157
const { body } = parseFrontmatter(fullContent)
114158
const content = body.trim()
115159
const toolMapping = getToolMappingTemplate()
160+
const catalog = renderCatalogVerbose({
161+
bundledSkillsDir,
162+
disabledSkills: config.disabled_skills,
163+
})
164+
const catalogSection = catalog.length > 0 ? `\n\n${catalog}` : ''
116165

117166
return `<SYSTEMATIC_WORKFLOWS>
118167
You have access to structured engineering workflows via the systematic plugin.
@@ -121,6 +170,6 @@ You have access to structured engineering workflows via the systematic plugin.
121170
122171
${content}
123172
124-
${toolMapping}
173+
${toolMapping}${catalogSection}
125174
</SYSTEMATIC_WORKFLOWS>`
126175
}

src/lib/skill-catalog.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { pathToFileURL } from 'node:url'
2+
import { formatSkillCommandName } from './skill-loader.js'
3+
import { findSkillsInDir } from './skills.js'
4+
5+
/**
6+
* Escape XML special characters (&, <, >) in text content.
7+
* Quotes are not escaped because catalog names and descriptions are rendered
8+
* as element text, not attribute values.
9+
*/
10+
export function escapeXml(text: string): string {
11+
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12+
}
13+
14+
export interface CatalogEntry {
15+
name: string
16+
prefixedName: string
17+
description: string
18+
path: string
19+
skillFile: string
20+
}
21+
22+
export interface CatalogOptions {
23+
bundledSkillsDir: string
24+
disabledSkills: string[]
25+
}
26+
27+
/**
28+
* Discovers and filters bundled Systematic skills into catalog entries.
29+
* Excludes disabled skills and skills with disableModelInvocation === true.
30+
* Returns entries sorted by name.
31+
*/
32+
export function buildCatalogEntries(options: CatalogOptions): CatalogEntry[] {
33+
const { bundledSkillsDir, disabledSkills } = options
34+
35+
return findSkillsInDir(bundledSkillsDir)
36+
.filter((s) => !disabledSkills.includes(s.name))
37+
.filter((s) => s.disableModelInvocation !== true)
38+
.sort((a, b) => a.name.localeCompare(b.name))
39+
.map((s) => ({
40+
name: s.name,
41+
prefixedName: formatSkillCommandName(s.name),
42+
description: s.description,
43+
path: s.path,
44+
skillFile: s.skillFile,
45+
}))
46+
}
47+
48+
/**
49+
* Renders discoverable skills as native-style verbose XML for bootstrap content.
50+
* Returns empty string when no skills are available.
51+
*/
52+
export function renderCatalogVerbose(options: CatalogOptions): string {
53+
const entries = buildCatalogEntries(options)
54+
55+
if (entries.length === 0) return ''
56+
57+
const skillLines = entries.flatMap((entry) => [
58+
' <skill>',
59+
` <name>${escapeXml(entry.prefixedName)}</name>`,
60+
` <description>${escapeXml(entry.description)}</description>`,
61+
` <location>${pathToFileURL(entry.path).href}</location>`,
62+
' </skill>',
63+
])
64+
65+
return ['<available_skills>', ...skillLines, '</available_skills>'].join('\n')
66+
}
67+
68+
/**
69+
* Renders discoverable skills as a compact markdown list for tool descriptions.
70+
* Always includes the heading; renders an explicit no-skills message when empty.
71+
*/
72+
export function renderCatalogCompact(options: CatalogOptions): string {
73+
const entries = buildCatalogEntries(options)
74+
75+
const heading = '## Available Systematic Skills'
76+
77+
if (entries.length === 0) {
78+
return `${heading}\n\nNo Systematic skills are currently available.`
79+
}
80+
81+
const bullets = entries
82+
.map((entry) => `- ${entry.prefixedName}: ${entry.description}`)
83+
.join('\n')
84+
85+
return `${heading}\n\n${bullets}`
86+
}

src/lib/skill-tool.ts

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import path from 'node:path'
33
import { pathToFileURL } from 'node:url'
44
import type { ToolDefinition } from '@opencode-ai/plugin'
55
import { tool } from '@opencode-ai/plugin/tool'
6+
import {
7+
buildCatalogEntries,
8+
escapeXml,
9+
renderCatalogCompact,
10+
} from './skill-catalog.js'
611
import {
712
extractSkillBody,
813
formatSkillCommandName,
@@ -27,8 +32,8 @@ export function formatSkillsXml(skills: SkillInfo[]): string {
2732
// Uses space-delimited join with indented XML structure
2833
const skillLines = skills.flatMap((skill) => [
2934
' <skill>',
30-
` <name>${formatSkillCommandName(skill.name)}</name>`,
31-
` <description>${skill.description}</description>`,
35+
` <name>${escapeXml(formatSkillCommandName(skill.name))}</name>`,
36+
` <description>${escapeXml(skill.description)}</description>`,
3237
` <location>${pathToFileURL(skill.path).href}</location>`,
3338
' </skill>',
3439
])
@@ -96,44 +101,23 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
96101
.sort((a, b) => a.name.localeCompare(b.name))
97102
}
98103

99-
const getDiscoverableSkills = (): LoadedSkill[] => {
100-
return getAllSkills().filter((s) => s.disableModelInvocation !== true)
101-
}
102-
103104
const buildDescription = (): string => {
104-
const skills = getDiscoverableSkills()
105+
const catalog = renderCatalogCompact({ bundledSkillsDir, disabledSkills })
105106

106-
if (skills.length === 0) {
107-
return 'Load a skill to get detailed instructions for a specific task. No skills are currently available.'
108-
}
107+
return `Load a specialized skill that provides domain-specific instructions and workflows.
109108
110-
const skillInfos = skills.map((s) => ({
111-
name: s.name,
112-
description: s.description,
113-
path: s.path,
114-
skillFile: s.skillFile,
115-
}))
116-
const systematicXml = formatSkillsXml(skillInfos)
117-
118-
return [
119-
'Load a specialized skill that provides domain-specific instructions and workflows.',
120-
'',
121-
'When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.',
122-
'',
123-
'The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.',
124-
'',
125-
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
126-
'',
127-
'The following skills provide specialized sets of instructions for particular tasks.',
128-
'Invoke this tool to load a skill when a task matches one of the available skills listed below:',
129-
'',
130-
systematicXml,
131-
].join('\n')
109+
When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.
110+
111+
The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.
112+
113+
Tool output includes a \`<skill_content name="...">\` block with the loaded content.
114+
115+
${catalog}`
132116
}
133117

134118
const buildParameterHint = (): string => {
135-
const skills = getDiscoverableSkills()
136-
const examples = skills
119+
const entries = buildCatalogEntries({ bundledSkillsDir, disabledSkills })
120+
const examples = entries
137121
.slice(0, 3)
138122
.map((s) => `'${s.prefixedName}'`)
139123
.join(', ')
@@ -172,9 +156,10 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
172156
const matchedSkill = skills.find((s) => s.name === normalizedName)
173157

174158
if (!matchedSkill) {
175-
const availableSystematic = getDiscoverableSkills().map(
176-
(s) => s.prefixedName,
177-
)
159+
const availableSystematic = buildCatalogEntries({
160+
bundledSkillsDir,
161+
disabledSkills,
162+
}).map((s) => s.prefixedName)
178163
throw new Error(
179164
`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`,
180165
)

0 commit comments

Comments
 (0)