/** * BMAD Agent Compiler * Transforms agent YAML to compiled XML (.md) format * Uses the existing BMAD builder infrastructure for proper formatting */ const yaml = require('yaml'); const fs = require('node:fs'); const path = require('node:path'); const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine'); const { escapeXml } = require('../../../lib/xml-utils'); const { ActivationBuilder } = require('../activation-builder'); const { AgentAnalyzer } = require('../agent-analyzer'); /** * Build frontmatter for agent * @param {Object} metadata - Agent metadata * @param {string} agentName - Final agent name * @returns {string} YAML frontmatter */ function buildFrontmatter(metadata, agentName) { const nameFromFile = agentName.replaceAll('-', ' '); const description = metadata.title || 'BMAD Agent'; return `--- name: "${nameFromFile}" description: "${description}" --- You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. `; } // buildSimpleActivation function removed - replaced by ActivationBuilder for proper fragment loading from src/utility/agent-components/ /** * Build persona XML section * @param {Object} persona - Persona object * @returns {string} Persona XML */ function buildPersonaXml(persona) { if (!persona) return ''; let xml = ' \n'; if (persona.role) { const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); xml += ` ${escapeXml(roleText)}\n`; } if (persona.identity) { const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); xml += ` ${escapeXml(identityText)}\n`; } if (persona.communication_style) { const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); xml += ` ${escapeXml(styleText)}\n`; } if (persona.principles) { let principlesText; if (Array.isArray(persona.principles)) { principlesText = persona.principles.join(' '); } else { principlesText = persona.principles.trim().replaceAll(/\n+/g, ' '); } xml += ` ${escapeXml(principlesText)}\n`; } xml += ' \n'; return xml; } /** * Build prompts XML section * @param {Array} prompts - Prompts array * @returns {string} Prompts XML */ function buildPromptsXml(prompts) { if (!prompts || prompts.length === 0) return ''; let xml = ' \n'; for (const prompt of prompts) { xml += ` \n`; xml += ` \n`; // Don't escape prompt content - it's meant to be read as-is xml += `${prompt.content || ''}\n`; xml += ` \n`; xml += ` \n`; } xml += ' \n'; return xml; } /** * Build menu XML section * Supports both legacy and multi format menu items * Multi items display as a single menu item with nested handlers * @param {Array} menuItems - Menu items * @returns {string} Menu XML */ function buildMenuXml(menuItems) { let xml = ' \n'; // Always inject menu display option first xml += ` [M] Redisplay Menu Options\n`; // Add user-defined menu items if (menuItems && menuItems.length > 0) { for (const item of menuItems) { // Handle multi format menu items with nested handlers if (item.multi && item.triggers && Array.isArray(item.triggers)) { xml += ` ${escapeXml(item.multi)}\n`; xml += buildNestedHandlers(item.triggers); xml += ` \n`; } // Handle legacy format menu items else if (item.trigger) { // For legacy items, keep using cmd with * format let trigger = item.trigger || ''; if (!trigger.startsWith('*')) { trigger = '*' + trigger; } const attrs = [`cmd="${trigger}"`]; // Add handler attributes if (item.workflow) attrs.push(`workflow="${item.workflow}"`); if (item.exec) attrs.push(`exec="${item.exec}"`); if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`); if (item.data) attrs.push(`data="${item.data}"`); if (item.action) attrs.push(`action="${item.action}"`); xml += ` ${escapeXml(item.description || '')}\n`; } } } // Always inject dismiss last xml += ` [D] Dismiss Agent\n`; xml += ' \n'; return xml; } /** * Build nested handlers for multi format menu items * @param {Array} triggers - Triggers array from multi format * @returns {string} Handler XML */ function buildNestedHandlers(triggers) { let xml = ''; for (const triggerGroup of triggers) { for (const [triggerName, execArray] of Object.entries(triggerGroup)) { // Build trigger with * prefix let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName; // Extract the relevant execution data const execData = processExecArray(execArray); // For nested handlers in multi items, we use match attribute for fuzzy matching const attrs = [`match="${escapeXml(execData.description || '')}"`]; // Add handler attributes based on exec data if (execData.route) attrs.push(`exec="${execData.route}"`); if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`); if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`); if (execData.action) attrs.push(`action="${execData.action}"`); if (execData.data) attrs.push(`data="${execData.data}"`); if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`); // Only add type if it's not 'exec' (exec is already implied by the exec attribute) if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`); xml += ` \n`; } } return xml; } /** * Process the execution array from multi format triggers * Extracts relevant data for XML attributes * @param {Array} execArray - Array of execution objects * @returns {Object} Processed execution data */ function processExecArray(execArray) { const result = { description: '', route: null, workflow: null, data: null, action: null, type: null, }; if (!Array.isArray(execArray)) { return result; } for (const exec of execArray) { if (exec.input) { // Use input as description if no explicit description is provided result.description = exec.input; } if (exec.route) { // Determine if it's a workflow or exec based on file extension or context if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) { result.workflow = exec.route; } else { result.route = exec.route; } } if (exec.data !== null && exec.data !== undefined) { result.data = exec.data; } if (exec.action) { result.action = exec.action; } if (exec.type) { result.type = exec.type; } } return result; } /** * Compile agent YAML to proper XML format * @param {Object} agentYaml - Parsed and processed agent YAML * @param {string} agentName - Final agent name (for ID and frontmatter) * @param {string} targetPath - Target path for agent ID * @returns {Promise} Compiled XML string with frontmatter */ async function compileToXml(agentYaml, agentName = '', targetPath = '') { const agent = agentYaml.agent; const meta = agent.metadata; let xml = ''; // Build frontmatter xml += buildFrontmatter(meta, agentName || meta.name || 'agent'); // Start code fence xml += '```xml\n'; // Agent opening tag const agentAttrs = [ `id="${targetPath || meta.id || ''}"`, `name="${meta.name || ''}"`, `title="${meta.title || ''}"`, `icon="${meta.icon || '🤖'}"`, ]; xml += `\n`; // Activation block - use ActivationBuilder for proper fragment loading const activationBuilder = new ActivationBuilder(); const analyzer = new AgentAnalyzer(); const profile = analyzer.analyzeAgentObject(agentYaml); xml += await activationBuilder.buildActivation( profile, meta, agent.critical_actions || [], false, // forWebBundle - set to false for IDE deployment ); // Persona section xml += buildPersonaXml(agent.persona); // Prompts section (if present) if (agent.prompts && agent.prompts.length > 0) { xml += buildPromptsXml(agent.prompts); } // Menu section xml += buildMenuXml(agent.menu || []); // Closing agent tag xml += '\n'; // Close code fence xml += '```\n'; return xml; } /** * Full compilation pipeline * @param {string} yamlContent - Raw YAML string * @param {Object} answers - Answers from install_config questions (or defaults) * @param {string} agentName - Optional final agent name (user's custom persona name) * @param {string} targetPath - Optional target path for agent ID * @param {Object} options - Additional options including config * @returns {Promise} { xml: string, metadata: Object } */ async function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) { // Parse YAML let agentYaml = yaml.parse(yamlContent); // Apply customization merges before template processing // Handle metadata overrides (like name) if (answers.metadata) { // Filter out empty values from metadata const filteredMetadata = filterCustomizationData(answers.metadata); if (Object.keys(filteredMetadata).length > 0) { agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata }; } // Remove from answers so it doesn't get processed as template variables const { metadata, ...templateAnswers } = answers; answers = templateAnswers; } // Extract install_config const installConfig = extractInstallConfig(agentYaml); // Merge defaults with provided answers let finalAnswers = answers; if (installConfig) { const defaults = getDefaultValues(installConfig); finalAnswers = { ...defaults, ...answers }; } // Add agent_sidecar_folder to answers if provided in config if (options.config && options.config.agent_sidecar_folder) { finalAnswers.agent_sidecar_folder = options.config.agent_sidecar_folder; } // Process templates with answers const processedYaml = processAgentYaml(agentYaml, finalAnswers); // Strip install_config from output const cleanYaml = stripInstallConfig(processedYaml); // Replace {agent_sidecar_folder} in XML content let xml = await compileToXml(cleanYaml, agentName, targetPath); if (finalAnswers.agent_sidecar_folder) { xml = xml.replaceAll('{agent_sidecar_folder}', finalAnswers.agent_sidecar_folder); } return { xml, metadata: cleanYaml.agent.metadata, processedYaml: cleanYaml, }; } /** * Filter customization data to remove empty/null values * @param {Object} data - Raw customization data * @returns {Object} Filtered customization data */ function filterCustomizationData(data) { const filtered = {}; for (const [key, value] of Object.entries(data)) { if (value === null || value === undefined || value === '') { continue; // Skip null/undefined/empty values } if (Array.isArray(value)) { if (value.length > 0) { filtered[key] = value; } } else if (typeof value === 'object') { const nested = filterCustomizationData(value); if (Object.keys(nested).length > 0) { filtered[key] = nested; } } else { filtered[key] = value; } } return filtered; } /** * Process TTS injection markers in content * @param {string} content - Content to process * @param {boolean} enableAgentVibes - Whether AgentVibes is enabled * @returns {Object} { content: string, hadInjection: boolean } */ function processTTSInjectionPoints(content, enableAgentVibes) { const hasAgentTTS = content.includes(''); if (enableAgentVibes && hasAgentTTS) { // Replace agent-tts injection marker with TTS rule content = content.replaceAll( '', `- When responding to user messages, speak your responses using TTS: Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response Replace {agent-id} with YOUR agent ID from tag at top of this file Replace {response-text} with the text you just output to the user IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes Run in background (&) to avoid blocking`, ); return { content, hadInjection: true }; } else if (!enableAgentVibes && hasAgentTTS) { // Strip injection markers when disabled content = content.replaceAll(/\n?/g, ''); } return { content, hadInjection: false }; } /** * Compile agent file to .md * @param {string} yamlPath - Path to agent YAML file * @param {Object} options - { answers: {}, outputPath: string, enableAgentVibes: boolean } * @returns {Object} Compilation result */ function compileAgentFile(yamlPath, options = {}) { const yamlContent = fs.readFileSync(yamlPath, 'utf8'); const result = compileAgent(yamlContent, options.answers || {}); // Determine output path let outputPath = options.outputPath; if (!outputPath) { // Default: same directory, same name, .md extension const dir = path.dirname(yamlPath); const basename = path.basename(yamlPath, '.agent.yaml'); outputPath = path.join(dir, `${basename}.md`); } // Process TTS injection points if enableAgentVibes option is provided let xml = result.xml; let ttsInjected = false; if (options.enableAgentVibes !== undefined) { const ttsResult = processTTSInjectionPoints(xml, options.enableAgentVibes); xml = ttsResult.content; ttsInjected = ttsResult.hadInjection; } // Write compiled XML fs.writeFileSync(outputPath, xml, 'utf8'); return { ...result, xml, outputPath, sourcePath: yamlPath, ttsInjected, }; } module.exports = { compileToXml, compileAgent, compileAgentFile, escapeXml, buildFrontmatter, buildPersonaXml, buildPromptsXml, buildMenuXml, filterCustomizationData, };