BMAD-METHOD/tools/cli/lib/agent/compiler.js

470 lines
14 KiB
JavaScript

/**
* 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 = ' <persona>\n';
if (persona.role) {
const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <role>${escapeXml(roleText)}</role>\n`;
}
if (persona.identity) {
const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <identity>${escapeXml(identityText)}</identity>\n`;
}
if (persona.communication_style) {
const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`;
}
if (persona.principles) {
let principlesText;
if (Array.isArray(persona.principles)) {
principlesText = persona.principles.join(' ');
} else {
principlesText = persona.principles.trim().replaceAll(/\n+/g, ' ');
}
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
}
xml += ' </persona>\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 = ' <prompts>\n';
for (const prompt of prompts) {
xml += ` <prompt id="${prompt.id || ''}">\n`;
xml += ` <content>\n`;
// Don't escape prompt content - it's meant to be read as-is
xml += `${prompt.content || ''}\n`;
xml += ` </content>\n`;
xml += ` </prompt>\n`;
}
xml += ' </prompts>\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 = ' <menu>\n';
// Always inject menu display option first
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\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 += ` <item type="multi">${escapeXml(item.multi)}\n`;
xml += buildNestedHandlers(item.triggers);
xml += ` </item>\n`;
}
// Handle legacy format menu items
else if (item.trigger) {
// For legacy items, keep using cmd with *<trigger> 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 += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
}
}
}
// Always inject dismiss last
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
xml += ' </menu>\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 += ` <handler ${attrs.join(' ')}></handler>\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<string>} 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 += `<agent ${agentAttrs.join(' ')}>\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 += '</agent>\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<Object>} { 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 bmad_memory to answers if provided in config
if (options.config && options.config.bmad_memory) {
finalAnswers.bmad_memory = options.config.bmad_memory;
}
// Process templates with answers
const processedYaml = processAgentYaml(agentYaml, finalAnswers);
// Strip install_config from output
const cleanYaml = stripInstallConfig(processedYaml);
// Replace {bmad_memory} in XML content
let xml = await compileToXml(cleanYaml, agentName, targetPath);
if (finalAnswers.bmad_memory) {
xml = xml.replaceAll('{bmad_memory}', finalAnswers.bmad_memory);
}
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('<!-- TTS_INJECTION:agent-tts -->');
if (enableAgentVibes && hasAgentTTS) {
// Replace agent-tts injection marker with TTS rule
content = content.replaceAll(
'<!-- TTS_INJECTION:agent-tts -->',
`- 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 <agent id="..."> 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(/<!-- TTS_INJECTION:agent-tts -->\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,
};