superpowers.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. /**
  2. * Superpowers plugin for OpenCode.ai
  3. *
  4. * Injects superpowers bootstrap context via message transform.
  5. * Auto-registers skills directory via config hook (no symlinks needed).
  6. */
  7. import path from 'path';
  8. import fs from 'fs';
  9. import os from 'os';
  10. import { fileURLToPath } from 'url';
  11. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  12. // Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
  13. const extractAndStripFrontmatter = (content) => {
  14. const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  15. if (!match) return { frontmatter: {}, content };
  16. const frontmatterStr = match[1];
  17. const body = match[2];
  18. const frontmatter = {};
  19. for (const line of frontmatterStr.split('\n')) {
  20. const colonIdx = line.indexOf(':');
  21. if (colonIdx > 0) {
  22. const key = line.slice(0, colonIdx).trim();
  23. const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
  24. frontmatter[key] = value;
  25. }
  26. }
  27. return { frontmatter, content: body };
  28. };
  29. // Normalize a path: trim whitespace, expand ~, resolve to absolute
  30. const normalizePath = (p, homeDir) => {
  31. if (!p || typeof p !== 'string') return null;
  32. let normalized = p.trim();
  33. if (!normalized) return null;
  34. if (normalized.startsWith('~/')) {
  35. normalized = path.join(homeDir, normalized.slice(2));
  36. } else if (normalized === '~') {
  37. normalized = homeDir;
  38. }
  39. return path.resolve(normalized);
  40. };
  41. // Module-level cache for bootstrap content.
  42. // The SKILL.md file does not change during a session, so reading + parsing it
  43. // once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
  44. // every agent step. See #1202 for the full analysis.
  45. let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
  46. export const SuperpowersPlugin = async ({ client, directory }) => {
  47. const homeDir = os.homedir();
  48. const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
  49. const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
  50. const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
  51. // Helper to generate bootstrap content (cached after first call)
  52. const getBootstrapContent = () => {
  53. // Return cached result on subsequent calls
  54. if (_bootstrapCache !== undefined) return _bootstrapCache;
  55. // Try to load using-superpowers skill
  56. const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
  57. if (!fs.existsSync(skillPath)) {
  58. _bootstrapCache = null;
  59. return null;
  60. }
  61. const fullContent = fs.readFileSync(skillPath, 'utf8');
  62. const { content } = extractAndStripFrontmatter(fullContent);
  63. const toolMapping = `**Tool Mapping for OpenCode:**
  64. When skills request actions, substitute OpenCode equivalents:
  65. - Create or update todos → \`todowrite\`
  66. - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
  67. - Invoke a skill → OpenCode's native \`skill\` tool
  68. - Read files → \`read\`
  69. - Create, edit, or delete files → \`apply_patch\`
  70. - Run shell commands → \`bash\`
  71. - Search files → \`grep\`, \`glob\`
  72. - Fetch a URL → \`webfetch\`
  73. Use OpenCode's native \`skill\` tool to list and load skills.`;
  74. _bootstrapCache = `<EXTREMELY_IMPORTANT>
  75. You have superpowers.
  76. **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.**
  77. ${content}
  78. ${toolMapping}
  79. </EXTREMELY_IMPORTANT>`;
  80. return _bootstrapCache;
  81. };
  82. return {
  83. // Inject skills path into live config so OpenCode discovers superpowers skills
  84. // without requiring manual symlinks or config file edits.
  85. // This works because Config.get() returns a cached singleton — modifications
  86. // here are visible when skills are lazily discovered later.
  87. config: async (config) => {
  88. config.skills = config.skills || {};
  89. config.skills.paths = config.skills.paths || [];
  90. if (!config.skills.paths.includes(superpowersSkillsDir)) {
  91. config.skills.paths.push(superpowersSkillsDir);
  92. }
  93. },
  94. // Inject bootstrap into the first user message of each session.
  95. // Using a user message instead of a system message avoids:
  96. // 1. Token bloat from system messages repeated every turn (#750)
  97. // 2. Multiple system messages breaking Qwen and other models (#894)
  98. //
  99. // The hook fires on every agent step (not just every turn) because
  100. // opencode's prompt.ts reloads messages from DB each step. Fresh message
  101. // arrays may need injection again, so getBootstrapContent() must not do
  102. // repeated disk work.
  103. 'experimental.chat.messages.transform': async (_input, output) => {
  104. const bootstrap = getBootstrapContent();
  105. if (!bootstrap || !output.messages.length) return;
  106. const firstUser = output.messages.find(m => m.info.role === 'user');
  107. if (!firstUser || !firstUser.parts.length) return;
  108. // Guard: skip if first user message already contains bootstrap.
  109. // This prevents double injection when OpenCode passes an already
  110. // transformed in-memory message array through the hook again.
  111. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  112. const ref = firstUser.parts[0];
  113. firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
  114. }
  115. };
  116. };