superpowers.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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. // --- Task-subagent (child session) detection --------------------------------
  47. //
  48. // #2160: the bootstrap drives controller workflows (brainstorming, planning,
  49. // approval cycles). Injecting it into task subagent sessions makes workers
  50. // restart design/approval cycles for work the parent already authorised; the
  51. // <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
  52. // is not reliable. Detect child sessions structurally instead: OpenCode task
  53. // sessions are created with a parentID, so when the session carrying the
  54. // message has a parentID we skip bootstrap injection. Skills stay registered
  55. // for every session — workers keep explicit access to execution skills.
  56. // sessionID -> is-child decision. parentID never changes for a session, so
  57. // the result is cached for life and the injection hook (which fires on every
  58. // agent step) pays only one client roundtrip per session.
  59. const _childSessionCache = new Map();
  60. const isChildSession = async (fetchSession, sessionID) => {
  61. if (!sessionID) return false; // unknown session: keep current behavior
  62. if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
  63. let isChild = false;
  64. try {
  65. const result = await fetchSession(sessionID);
  66. // The V1 SDK returns { data: Session } rather than the record itself.
  67. const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
  68. ? result.data
  69. : result;
  70. isChild = Boolean(session && typeof session === 'object' && session.parentID);
  71. } catch (err) {
  72. // Fail open: on lookup errors keep injecting (previous behavior) and do
  73. // not cache, so a transient failure can recover on the next step.
  74. console.error('[superpowers] session lookup failed, treating session as top-level:', err);
  75. return false;
  76. }
  77. _childSessionCache.set(sessionID, isChild);
  78. return isChild;
  79. };
  80. export const SuperpowersPlugin = async ({ client, directory }) => {
  81. const homeDir = os.homedir();
  82. const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
  83. const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
  84. const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
  85. // Helper to generate bootstrap content (cached after first call)
  86. const getBootstrapContent = () => {
  87. // Return cached result on subsequent calls
  88. if (_bootstrapCache !== undefined) return _bootstrapCache;
  89. // Try to load using-superpowers skill
  90. const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
  91. if (!fs.existsSync(skillPath)) {
  92. _bootstrapCache = null;
  93. return null;
  94. }
  95. const fullContent = fs.readFileSync(skillPath, 'utf8');
  96. const { content } = extractAndStripFrontmatter(fullContent);
  97. const toolMapping = `**Tool Mapping for OpenCode:**
  98. When skills request actions, substitute OpenCode equivalents:
  99. - Create or update todos → \`todowrite\`
  100. - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
  101. - Invoke a skill → OpenCode's native \`skill\` tool
  102. - Read files → \`read\`
  103. - Create, edit, or delete files → \`apply_patch\`
  104. - Run shell commands → \`bash\`
  105. - Search files → \`grep\`, \`glob\`
  106. - Fetch a URL → \`webfetch\`
  107. Use OpenCode's native \`skill\` tool to list and load skills.`;
  108. _bootstrapCache = `<EXTREMELY_IMPORTANT>
  109. You have superpowers.
  110. **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.**
  111. ${content}
  112. ${toolMapping}
  113. </EXTREMELY_IMPORTANT>`;
  114. return _bootstrapCache;
  115. };
  116. return {
  117. // Inject skills path into live config so OpenCode discovers superpowers skills
  118. // without requiring manual symlinks or config file edits.
  119. // This works because Config.get() returns a cached singleton — modifications
  120. // here are visible when skills are lazily discovered later.
  121. config: async (config) => {
  122. config.skills = config.skills || {};
  123. config.skills.paths = config.skills.paths || [];
  124. if (!config.skills.paths.includes(superpowersSkillsDir)) {
  125. config.skills.paths.push(superpowersSkillsDir);
  126. }
  127. },
  128. // Inject bootstrap into the first user message of each session.
  129. // Using a user message instead of a system message avoids:
  130. // 1. Token bloat from system messages repeated every turn (#750)
  131. // 2. Multiple system messages breaking Qwen and other models (#894)
  132. //
  133. // The hook fires on every agent step (not just every turn) because
  134. // opencode's prompt.ts reloads messages from DB each step. Fresh message
  135. // arrays may need injection again, so getBootstrapContent() must not do
  136. // repeated disk work.
  137. 'experimental.chat.messages.transform': async (_input, output) => {
  138. const bootstrap = getBootstrapContent();
  139. if (!bootstrap || !output.messages.length) return;
  140. const firstUser = output.messages.find(m => m.info.role === 'user');
  141. if (!firstUser || !firstUser.parts.length) return;
  142. // Guard: skip if first user message already contains bootstrap.
  143. // This prevents double injection when OpenCode passes an already
  144. // transformed in-memory message array through the hook again.
  145. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  146. // #2160: never restart the controller workflow inside task subagent
  147. // (child) sessions. V1 passes no input to this hook (verified in the
  148. // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
  149. // from the message record itself.
  150. if (client && await isChildSession(
  151. (id) => client.session.get({ path: { id } }),
  152. firstUser.info.sessionID,
  153. )) return;
  154. const ref = firstUser.parts[0];
  155. firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
  156. }
  157. };
  158. };