|
1 | 1 | /** |
2 | 2 | * Superpowers plugin for OpenCode.ai |
3 | 3 | * |
4 | | - * Provides custom tools for loading and discovering skills, |
5 | | - * with prompt generation for agent configuration. |
| 4 | + * Injects superpowers bootstrap context via system prompt transform. |
| 5 | + * Skills are discovered via OpenCode's native skill tool from symlinked directory. |
6 | 6 | */ |
7 | 7 |
|
8 | 8 | import path from 'path'; |
9 | 9 | import fs from 'fs'; |
10 | 10 | import os from 'os'; |
11 | 11 | import { fileURLToPath } from 'url'; |
12 | | -import { tool } from '@opencode-ai/plugin/tool'; |
13 | | -import * as skillsCore from '../../lib/skills-core.js'; |
14 | 12 |
|
15 | 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
16 | 14 |
|
| 15 | +// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap) |
| 16 | +const extractAndStripFrontmatter = (content) => { |
| 17 | + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); |
| 18 | + if (!match) return { frontmatter: {}, content }; |
| 19 | + |
| 20 | + const frontmatterStr = match[1]; |
| 21 | + const body = match[2]; |
| 22 | + const frontmatter = {}; |
| 23 | + |
| 24 | + for (const line of frontmatterStr.split('\n')) { |
| 25 | + const colonIdx = line.indexOf(':'); |
| 26 | + if (colonIdx > 0) { |
| 27 | + const key = line.slice(0, colonIdx).trim(); |
| 28 | + const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, ''); |
| 29 | + frontmatter[key] = value; |
| 30 | + } |
| 31 | + } |
| 32 | + |
| 33 | + return { frontmatter, content: body }; |
| 34 | +}; |
| 35 | + |
17 | 36 | // Normalize a path: trim whitespace, expand ~, resolve to absolute |
18 | 37 | const normalizePath = (p, homeDir) => { |
19 | 38 | if (!p || typeof p !== 'string') return null; |
20 | 39 | let normalized = p.trim(); |
21 | 40 | if (!normalized) return null; |
22 | | - // Expand ~ to home directory |
23 | 41 | if (normalized.startsWith('~/')) { |
24 | 42 | normalized = path.join(homeDir, normalized.slice(2)); |
25 | 43 | } else if (normalized === '~') { |
26 | 44 | normalized = homeDir; |
27 | 45 | } |
28 | | - // Resolve to absolute path |
29 | 46 | return path.resolve(normalized); |
30 | 47 | }; |
31 | 48 |
|
32 | 49 | export const SuperpowersPlugin = async ({ client, directory }) => { |
33 | 50 | const homeDir = os.homedir(); |
34 | | - const projectSkillsDir = path.join(directory, '.opencode/skills'); |
35 | | - // Derive superpowers skills dir from plugin location (works for both symlinked and local installs) |
36 | 51 | const superpowersSkillsDir = path.resolve(__dirname, '../../skills'); |
37 | | - // Respect OPENCODE_CONFIG_DIR if set, otherwise fall back to default |
38 | 52 | const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir); |
39 | 53 | const configDir = envConfigDir || path.join(homeDir, '.config/opencode'); |
40 | | - const personalSkillsDir = path.join(configDir, 'skills'); |
41 | 54 |
|
42 | 55 | // Helper to generate bootstrap content |
43 | | - const getBootstrapContent = (compact = false) => { |
44 | | - const usingSuperpowersPath = skillsCore.resolveSkillPath('using-superpowers', superpowersSkillsDir, personalSkillsDir); |
45 | | - if (!usingSuperpowersPath) return null; |
46 | | - |
47 | | - const fullContent = fs.readFileSync(usingSuperpowersPath.skillFile, 'utf8'); |
48 | | - const content = skillsCore.stripFrontmatter(fullContent); |
| 56 | + const getBootstrapContent = () => { |
| 57 | + // Try to load using-superpowers skill |
| 58 | + const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md'); |
| 59 | + if (!fs.existsSync(skillPath)) return null; |
49 | 60 |
|
50 | | - const toolMapping = compact |
51 | | - ? `**Tool Mapping:** TodoWrite->update_plan, Task->@mention, Skill->use_skill |
| 61 | + const fullContent = fs.readFileSync(skillPath, 'utf8'); |
| 62 | + const { content } = extractAndStripFrontmatter(fullContent); |
52 | 63 |
|
53 | | -**Skills naming (priority order):** project: > personal > superpowers:` |
54 | | - : `**Tool Mapping for OpenCode:** |
| 64 | + const toolMapping = `**Tool Mapping for OpenCode:** |
55 | 65 | When skills reference tools you don't have, substitute OpenCode equivalents: |
56 | 66 | - \`TodoWrite\` → \`update_plan\` |
57 | 67 | - \`Task\` tool with subagents → Use OpenCode's subagent system (@mention) |
58 | | -- \`Skill\` tool → \`use_skill\` custom tool |
| 68 | +- \`Skill\` tool → OpenCode's native \`skill\` tool |
59 | 69 | - \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools |
60 | 70 |
|
61 | | -**Skills naming (priority order):** |
62 | | -- Project skills: \`project:skill-name\` (in .opencode/skills/) |
63 | | -- Personal skills: \`skill-name\` (in ${configDir}/skills/) |
64 | | -- Superpowers skills: \`superpowers:skill-name\` |
65 | | -- Project skills override personal, which override superpowers when names match`; |
| 71 | +**Skills location:** |
| 72 | +Superpowers skills are in \`${configDir}/skills/superpowers/\` |
| 73 | +Use OpenCode's native \`skill\` tool to list and load skills.`; |
66 | 74 |
|
67 | 75 | return `<EXTREMELY_IMPORTANT> |
68 | 76 | You have superpowers. |
69 | 77 |
|
70 | | -**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the use_skill tool to load "using-superpowers" - that would be redundant. Use use_skill only for OTHER skills.** |
| 78 | +**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.** |
71 | 79 |
|
72 | 80 | ${content} |
73 | 81 |
|
74 | 82 | ${toolMapping} |
75 | 83 | </EXTREMELY_IMPORTANT>`; |
76 | 84 | }; |
77 | 85 |
|
78 | | - // Helper to inject bootstrap via session.prompt |
79 | | - const injectBootstrap = async (sessionID, compact = false) => { |
80 | | - const bootstrapContent = getBootstrapContent(compact); |
81 | | - if (!bootstrapContent) return false; |
82 | | - |
83 | | - try { |
84 | | - await client.session.prompt({ |
85 | | - path: { id: sessionID }, |
86 | | - body: { |
87 | | - noReply: true, |
88 | | - parts: [{ type: "text", text: bootstrapContent, synthetic: true }] |
89 | | - } |
90 | | - }); |
91 | | - return true; |
92 | | - } catch (err) { |
93 | | - return false; |
94 | | - } |
95 | | - }; |
96 | | - |
97 | 86 | return { |
98 | | - tool: { |
99 | | - use_skill: tool({ |
100 | | - description: 'Load and read a specific skill to guide your work. Skills contain proven workflows, mandatory processes, and expert techniques.', |
101 | | - args: { |
102 | | - skill_name: tool.schema.string().describe('Name of the skill to load (e.g., "superpowers:brainstorming", "my-custom-skill", or "project:my-skill")') |
103 | | - }, |
104 | | - execute: async (args, context) => { |
105 | | - const { skill_name } = args; |
106 | | - |
107 | | - // Resolve with priority: project > personal > superpowers |
108 | | - // Check for project: prefix first |
109 | | - const forceProject = skill_name.startsWith('project:'); |
110 | | - const actualSkillName = forceProject ? skill_name.replace(/^project:/, '') : skill_name; |
111 | | - |
112 | | - let resolved = null; |
113 | | - |
114 | | - // Try project skills first (if project: prefix or no prefix) |
115 | | - if (forceProject || !skill_name.startsWith('superpowers:')) { |
116 | | - const projectPath = path.join(projectSkillsDir, actualSkillName); |
117 | | - const projectSkillFile = path.join(projectPath, 'SKILL.md'); |
118 | | - if (fs.existsSync(projectSkillFile)) { |
119 | | - resolved = { |
120 | | - skillFile: projectSkillFile, |
121 | | - sourceType: 'project', |
122 | | - skillPath: actualSkillName |
123 | | - }; |
124 | | - } |
125 | | - } |
126 | | - |
127 | | - // Fall back to personal/superpowers resolution |
128 | | - if (!resolved && !forceProject) { |
129 | | - resolved = skillsCore.resolveSkillPath(skill_name, superpowersSkillsDir, personalSkillsDir); |
130 | | - } |
131 | | - |
132 | | - if (!resolved) { |
133 | | - return `Error: Skill "${skill_name}" not found.\n\nRun find_skills to see available skills.`; |
134 | | - } |
135 | | - |
136 | | - const fullContent = fs.readFileSync(resolved.skillFile, 'utf8'); |
137 | | - const { name, description } = skillsCore.extractFrontmatter(resolved.skillFile); |
138 | | - const content = skillsCore.stripFrontmatter(fullContent); |
139 | | - const skillDirectory = path.dirname(resolved.skillFile); |
140 | | - |
141 | | - const skillHeader = `# ${name || skill_name} |
142 | | -# ${description || ''} |
143 | | -# Supporting tools and docs are in ${skillDirectory} |
144 | | -# ============================================`; |
145 | | - |
146 | | - // Insert as user message with noReply for persistence across compaction |
147 | | - try { |
148 | | - await client.session.prompt({ |
149 | | - path: { id: context.sessionID }, |
150 | | - body: { |
151 | | - agent: context.agent, |
152 | | - noReply: true, |
153 | | - parts: [ |
154 | | - { type: "text", text: `Loading skill: ${name || skill_name}`, synthetic: true }, |
155 | | - { type: "text", text: `${skillHeader}\n\n${content}`, synthetic: true } |
156 | | - ] |
157 | | - } |
158 | | - }); |
159 | | - } catch (err) { |
160 | | - // Fallback: return content directly if message insertion fails |
161 | | - return `${skillHeader}\n\n${content}`; |
162 | | - } |
163 | | - |
164 | | - return `Launching skill: ${name || skill_name}`; |
165 | | - } |
166 | | - }), |
167 | | - find_skills: tool({ |
168 | | - description: 'List all available skills in the project, personal, and superpowers skill libraries.', |
169 | | - args: {}, |
170 | | - execute: async (args, context) => { |
171 | | - const projectSkills = skillsCore.findSkillsInDir(projectSkillsDir, 'project', 3); |
172 | | - const personalSkills = skillsCore.findSkillsInDir(personalSkillsDir, 'personal', 3); |
173 | | - const superpowersSkills = skillsCore.findSkillsInDir(superpowersSkillsDir, 'superpowers', 3); |
174 | | - |
175 | | - // Priority: project > personal > superpowers |
176 | | - const allSkills = [...projectSkills, ...personalSkills, ...superpowersSkills]; |
177 | | - |
178 | | - if (allSkills.length === 0) { |
179 | | - return `No skills found. Install superpowers skills to ${superpowersSkillsDir}/ or add personal skills to ${personalSkillsDir}/`; |
180 | | - } |
181 | | - |
182 | | - let output = 'Available skills:\n\n'; |
183 | | - |
184 | | - for (const skill of allSkills) { |
185 | | - let namespace; |
186 | | - switch (skill.sourceType) { |
187 | | - case 'project': |
188 | | - namespace = 'project:'; |
189 | | - break; |
190 | | - case 'personal': |
191 | | - namespace = ''; |
192 | | - break; |
193 | | - default: |
194 | | - namespace = 'superpowers:'; |
195 | | - } |
196 | | - const skillName = skill.name || path.basename(skill.path); |
197 | | - |
198 | | - output += `${namespace}${skillName}\n`; |
199 | | - if (skill.description) { |
200 | | - output += ` ${skill.description}\n`; |
201 | | - } |
202 | | - output += ` Directory: ${skill.path}\n\n`; |
203 | | - } |
204 | | - |
205 | | - return output; |
206 | | - } |
207 | | - }) |
208 | | - }, |
209 | | - event: async ({ event }) => { |
210 | | - // Extract sessionID from various event structures |
211 | | - const getSessionID = () => { |
212 | | - return event.properties?.info?.id || |
213 | | - event.properties?.sessionID || |
214 | | - event.session?.id; |
215 | | - }; |
216 | | - |
217 | | - // Inject bootstrap at session creation (before first user message) |
218 | | - if (event.type === 'session.created') { |
219 | | - const sessionID = getSessionID(); |
220 | | - if (sessionID) { |
221 | | - await injectBootstrap(sessionID, false); |
222 | | - } |
223 | | - } |
224 | | - |
225 | | - // Re-inject bootstrap after context compaction (compact version to save tokens) |
226 | | - if (event.type === 'session.compacted') { |
227 | | - const sessionID = getSessionID(); |
228 | | - if (sessionID) { |
229 | | - await injectBootstrap(sessionID, true); |
230 | | - } |
| 87 | + // Use system prompt transform to inject bootstrap (fixes #226 agent reset bug) |
| 88 | + 'experimental.chat.system.transform': async (_input, output) => { |
| 89 | + const bootstrap = getBootstrapContent(); |
| 90 | + if (bootstrap) { |
| 91 | + (output.system ||= []).push(bootstrap); |
231 | 92 | } |
232 | 93 | } |
233 | 94 | }; |
|
0 commit comments