superpowers.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /**
  2. * Superpowers plugin for OpenCode.ai
  3. *
  4. * Dual-compatible with OpenCode V1 and V2.
  5. *
  6. * V1 (opencode): loaded via named export SuperpowersPlugin — provides config
  7. * hook for skills registration and experimental.chat.messages.transform for
  8. * bootstrap injection.
  9. *
  10. * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor.
  11. * setup() registers skills natively via ctx.skill.transform(), and injects
  12. * bootstrap context via ctx.session.hook("context").
  13. *
  14. * No external dependencies — pure JavaScript works in both V1 and V2 without
  15. * installing @opencode-ai/plugin or effect.
  16. */
  17. import path from 'path';
  18. import fs from 'fs';
  19. import { fileURLToPath } from 'url';
  20. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  21. // Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform)
  22. const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
  23. // Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
  24. const extractAndStripFrontmatter = (content) => {
  25. const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  26. if (!match) return { frontmatter: {}, content };
  27. const frontmatterStr = match[1];
  28. const body = match[2];
  29. const frontmatter = {};
  30. for (const line of frontmatterStr.split('\n')) {
  31. const colonIdx = line.indexOf(':');
  32. if (colonIdx > 0) {
  33. const key = line.slice(0, colonIdx).trim();
  34. const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
  35. frontmatter[key] = value;
  36. }
  37. }
  38. return { frontmatter, content: body };
  39. };
  40. // Module-level cache for bootstrap content.
  41. // The SKILL.md file does not change during a session, so reading + parsing it
  42. // once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
  43. // every agent step. See #1202 for the full analysis.
  44. let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
  45. // Helper to generate bootstrap content (cached after first call)
  46. const getBootstrapContent = () => {
  47. // Return cached result on subsequent calls
  48. if (_bootstrapCache !== undefined) return _bootstrapCache;
  49. // Try to load using-superpowers skill
  50. const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
  51. if (!fs.existsSync(skillPath)) {
  52. _bootstrapCache = null;
  53. return null;
  54. }
  55. const fullContent = fs.readFileSync(skillPath, 'utf8');
  56. const { content } = extractAndStripFrontmatter(fullContent);
  57. const toolMapping = `**Tool Mapping for OpenCode:**
  58. When skills request actions, substitute OpenCode equivalents:
  59. - Create or update todos → \`todowrite\`
  60. - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
  61. - Invoke a skill → OpenCode's native \`skill\` tool
  62. - Read files → \`read\`
  63. - Create, edit, or delete files → \`apply_patch\`
  64. - Run shell commands → \`bash\`
  65. - Search files → \`grep\`, \`glob\`
  66. - Fetch a URL → \`webfetch\`
  67. Use OpenCode's native \`skill\` tool to list and load skills.`;
  68. _bootstrapCache = `<EXTREMELY_IMPORTANT>
  69. You have superpowers.
  70. **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. ${content}
  72. ${toolMapping}
  73. </EXTREMELY_IMPORTANT>`;
  74. return _bootstrapCache;
  75. };
  76. /**
  77. * V1 Plugin Function (named export + default.server)
  78. *
  79. * Used by V1 (OpenCode 1.x): discovered via named export scanning.
  80. * Provides: config hook (V1 skills registration) + bootstrap injection
  81. * (experimental.chat.messages.transform).
  82. */
  83. export const SuperpowersPlugin = async ({ client, directory }) => {
  84. return {
  85. // Inject skills path into live config so OpenCode discovers superpowers skills
  86. // without requiring manual symlinks or config file edits.
  87. config: async (config) => {
  88. // V2: skills is a flat array — skip, setup() handles V2 skill registration
  89. if (Array.isArray(config.skills)) return;
  90. // V1: skills is { paths: [...] }
  91. config.skills = config.skills || {};
  92. config.skills.paths = config.skills.paths || [];
  93. if (!config.skills.paths.includes(superpowersSkillsDir)) {
  94. config.skills.paths.push(superpowersSkillsDir);
  95. }
  96. },
  97. // Inject bootstrap into the first user message of each session.
  98. // Using a user message instead of a system message avoids:
  99. // 1. Token bloat from system messages repeated every turn (#750)
  100. // 2. Multiple system messages breaking Qwen and other models (#894)
  101. //
  102. // The hook fires on every agent step (not just every turn) because
  103. // opencode's prompt.ts reloads messages from DB each step. Fresh message
  104. // arrays may need injection again, so getBootstrapContent() must not do
  105. // repeated disk work.
  106. 'experimental.chat.messages.transform': async (_input, output) => {
  107. const bootstrap = getBootstrapContent();
  108. if (!bootstrap || !output.messages.length) return;
  109. const firstUser = output.messages.find(m => m.info.role === 'user');
  110. if (!firstUser || !firstUser.parts.length) return;
  111. // Guard: skip if first user message already contains bootstrap.
  112. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  113. const ref = firstUser.parts[0];
  114. firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
  115. }
  116. };
  117. };
  118. /**
  119. * V2 Setup Function (default.setup)
  120. *
  121. * Called by V2 PluginSupervisor (packages/core/src/plugin/).
  122. * Performs two things:
  123. *
  124. * 1. Registers the skills directory natively via ctx.skill.transform().
  125. * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
  126. * equivalent of V1's experimental.chat.messages.transform.
  127. */
  128. async function setup(ctx) {
  129. // 1. Register skills
  130. await ctx.skill.transform((draft) => {
  131. draft.source({
  132. type: 'directory',
  133. path: superpowersSkillsDir,
  134. });
  135. });
  136. // 2. Inject bootstrap into first user message via V2 session context hook
  137. await ctx.session.hook('context', (event) => {
  138. const bootstrap = getBootstrapContent();
  139. if (!bootstrap || !event.messages || !event.messages.length) return;
  140. const firstUser = event.messages.find(m => m.role === 'user');
  141. if (!firstUser || !firstUser.content || !firstUser.content.length) return;
  142. if (firstUser.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  143. firstUser.content.unshift({ type: 'text', text: bootstrap });
  144. });
  145. }
  146. /**
  147. * Default Export: { id, server, setup }
  148. *
  149. * V2 PluginSupervisor reads { id, setup }.
  150. * V1 reads named export SuperpowersPlugin.
  151. * server() is exported for V1 compatibility.
  152. */
  153. export default {
  154. id: 'superpowers',
  155. server: SuperpowersPlugin,
  156. setup,
  157. };