394 lines
12 KiB
JavaScript
394 lines
12 KiB
JavaScript
const yaml = require('js-yaml');
|
|
const fs = require('fs-extra');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const { AgentAnalyzer } = require('./agent-analyzer');
|
|
const { ActivationBuilder } = require('./activation-builder');
|
|
|
|
/**
|
|
* Converts agent YAML files to XML format with smart activation injection
|
|
*/
|
|
class YamlXmlBuilder {
|
|
constructor() {
|
|
this.analyzer = new AgentAnalyzer();
|
|
this.activationBuilder = new ActivationBuilder();
|
|
}
|
|
|
|
/**
|
|
* Deep merge two objects (for customize.yaml + agent.yaml)
|
|
* @param {Object} target - Target object
|
|
* @param {Object} source - Source object to merge in
|
|
* @returns {Object} Merged object
|
|
*/
|
|
deepMerge(target, source) {
|
|
const output = { ...target };
|
|
|
|
if (this.isObject(target) && this.isObject(source)) {
|
|
for (const key of Object.keys(source)) {
|
|
if (this.isObject(source[key])) {
|
|
if (key in target) {
|
|
output[key] = this.deepMerge(target[key], source[key]);
|
|
} else {
|
|
output[key] = source[key];
|
|
}
|
|
} else if (Array.isArray(source[key])) {
|
|
// For arrays, append rather than replace (for commands)
|
|
if (Array.isArray(target[key])) {
|
|
output[key] = [...target[key], ...source[key]];
|
|
} else {
|
|
output[key] = source[key];
|
|
}
|
|
} else {
|
|
output[key] = source[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Check if value is an object
|
|
*/
|
|
isObject(item) {
|
|
return item && typeof item === 'object' && !Array.isArray(item);
|
|
}
|
|
|
|
/**
|
|
* Load and merge agent YAML with customization
|
|
* @param {string} agentYamlPath - Path to base agent YAML
|
|
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
* @returns {Object} Merged agent configuration
|
|
*/
|
|
async loadAndMergeAgent(agentYamlPath, customizeYamlPath = null) {
|
|
// Load base agent
|
|
const agentContent = await fs.readFile(agentYamlPath, 'utf8');
|
|
const agentYaml = yaml.load(agentContent);
|
|
|
|
// Load customization if exists
|
|
let merged = agentYaml;
|
|
if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) {
|
|
const customizeContent = await fs.readFile(customizeYamlPath, 'utf8');
|
|
const customizeYaml = yaml.load(customizeContent);
|
|
|
|
if (customizeYaml) {
|
|
// Special handling: persona fields are merged, but only non-empty values override
|
|
if (customizeYaml.persona) {
|
|
const basePersona = merged.agent.persona || {};
|
|
const customPersona = {};
|
|
|
|
// Only copy non-empty customize values
|
|
for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
|
if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
|
customPersona[key] = value;
|
|
}
|
|
}
|
|
|
|
// Merge non-empty customize values over base
|
|
if (Object.keys(customPersona).length > 0) {
|
|
merged.agent.persona = { ...basePersona, ...customPersona };
|
|
}
|
|
}
|
|
|
|
// Merge metadata (only non-empty values)
|
|
if (customizeYaml.agent && customizeYaml.agent.metadata) {
|
|
const nonEmptyMetadata = {};
|
|
for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
|
if (value !== '' && value !== null) {
|
|
nonEmptyMetadata[key] = value;
|
|
}
|
|
}
|
|
merged.agent.metadata = { ...merged.agent.metadata, ...nonEmptyMetadata };
|
|
}
|
|
|
|
// Append menu items (support both 'menu' and legacy 'commands')
|
|
const customMenuItems = customizeYaml.menu || customizeYaml.commands;
|
|
if (customMenuItems) {
|
|
// Determine if base uses 'menu' or 'commands'
|
|
if (merged.agent.menu) {
|
|
merged.agent.menu = [...merged.agent.menu, ...customMenuItems];
|
|
} else if (merged.agent.commands) {
|
|
merged.agent.commands = [...merged.agent.commands, ...customMenuItems];
|
|
} else {
|
|
// Default to 'menu' for new agents
|
|
merged.agent.menu = customMenuItems;
|
|
}
|
|
}
|
|
|
|
// Append critical actions
|
|
if (customizeYaml.critical_actions) {
|
|
merged.agent.critical_actions = [...(merged.agent.critical_actions || []), ...customizeYaml.critical_actions];
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Convert agent YAML to XML
|
|
* @param {Object} agentYaml - Parsed agent YAML object
|
|
* @param {Object} buildMetadata - Metadata about the build (file paths, hashes, etc.)
|
|
* @returns {string} XML content
|
|
*/
|
|
async convertToXml(agentYaml, buildMetadata = {}) {
|
|
const agent = agentYaml.agent;
|
|
const metadata = agent.metadata || {};
|
|
|
|
// Analyze agent to determine needed handlers
|
|
const profile = this.analyzer.analyzeAgentObject(agentYaml);
|
|
|
|
// Build activation block only if not skipped
|
|
let activationBlock = '';
|
|
if (!buildMetadata.skipActivation) {
|
|
activationBlock = await this.activationBuilder.buildActivation(
|
|
profile,
|
|
metadata,
|
|
agent.critical_actions || [],
|
|
buildMetadata.forWebBundle || false, // Pass web bundle flag
|
|
);
|
|
}
|
|
|
|
// Start building XML
|
|
let xml = '<!-- Powered by BMAD-CORE™ -->\n\n';
|
|
xml += `# ${metadata.title || 'Agent'}\n\n`;
|
|
|
|
xml += '```xml\n';
|
|
|
|
// Agent opening tag
|
|
const agentAttrs = [
|
|
`id="${metadata.id || ''}"`,
|
|
`name="${metadata.name || ''}"`,
|
|
`title="${metadata.title || ''}"`,
|
|
`icon="${metadata.icon || '🤖'}"`,
|
|
];
|
|
|
|
// Add localskip attribute if present
|
|
if (metadata.localskip === true) {
|
|
agentAttrs.push('localskip="true"');
|
|
}
|
|
|
|
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
|
|
|
// Activation block (only if not skipped)
|
|
if (activationBlock) {
|
|
xml += activationBlock + '\n';
|
|
}
|
|
|
|
// Persona section
|
|
xml += this.buildPersonaXml(agent.persona);
|
|
|
|
// Prompts section (if exists)
|
|
if (agent.prompts) {
|
|
xml += this.buildPromptsXml(agent.prompts);
|
|
}
|
|
|
|
// Menu section (support both 'menu' and legacy 'commands')
|
|
const menuItems = agent.menu || agent.commands || [];
|
|
xml += this.buildCommandsXml(menuItems);
|
|
|
|
xml += '</agent>\n';
|
|
xml += '```\n';
|
|
|
|
return xml;
|
|
}
|
|
|
|
/**
|
|
* Build metadata comment
|
|
*/
|
|
buildMetadataComment(metadata) {
|
|
const lines = ['<!-- BUILD-META', ` source: ${metadata.sourceFile || 'unknown'} (hash: ${metadata.sourceHash || 'unknown'})`];
|
|
|
|
if (metadata.customizeFile) {
|
|
lines.push(` customize: ${metadata.customizeFile} (hash: ${metadata.customizeHash || 'unknown'})`);
|
|
}
|
|
|
|
lines.push(` built: ${new Date().toISOString()}`, ` builder-version: ${metadata.builderVersion || '1.0.0'}`, '-->\n');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build persona XML section
|
|
*/
|
|
buildPersonaXml(persona) {
|
|
if (!persona) return '';
|
|
|
|
let xml = ' <persona>\n';
|
|
|
|
if (persona.role) {
|
|
xml += ` <role>${this.escapeXml(persona.role)}</role>\n`;
|
|
}
|
|
|
|
if (persona.identity) {
|
|
xml += ` <identity>${this.escapeXml(persona.identity)}</identity>\n`;
|
|
}
|
|
|
|
if (persona.communication_style) {
|
|
xml += ` <communication_style>${this.escapeXml(persona.communication_style)}</communication_style>\n`;
|
|
}
|
|
|
|
if (persona.principles) {
|
|
// Principles can be array or string
|
|
let principlesText;
|
|
if (Array.isArray(persona.principles)) {
|
|
principlesText = persona.principles.join(' ');
|
|
} else {
|
|
principlesText = persona.principles;
|
|
}
|
|
xml += ` <principles>${this.escapeXml(principlesText)}</principles>\n`;
|
|
}
|
|
|
|
xml += ' </persona>\n';
|
|
|
|
return xml;
|
|
}
|
|
|
|
/**
|
|
* Build prompts XML section
|
|
*/
|
|
buildPromptsXml(prompts) {
|
|
if (!prompts || prompts.length === 0) return '';
|
|
|
|
let xml = ' <prompts>\n';
|
|
|
|
for (const prompt of prompts) {
|
|
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
|
xml += ` <![CDATA[\n`;
|
|
xml += ` ${prompt.content || ''}\n`;
|
|
xml += ` ]]>\n`;
|
|
xml += ` </prompt>\n`;
|
|
}
|
|
|
|
xml += ' </prompts>\n';
|
|
|
|
return xml;
|
|
}
|
|
|
|
/**
|
|
* Build menu XML section (renamed from commands for clarity)
|
|
* Auto-injects *help and *exit, adds * prefix to all triggers
|
|
*/
|
|
buildCommandsXml(menuItems) {
|
|
let xml = ' <menu>\n';
|
|
|
|
// Always inject *help first
|
|
xml += ` <item cmd="*help">Show numbered menu</item>\n`;
|
|
|
|
// Add user-defined menu items with * prefix
|
|
if (menuItems && menuItems.length > 0) {
|
|
for (const item of menuItems) {
|
|
// Build command attributes - add * prefix if not present
|
|
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['validate-workflow']) attrs.push(`validate-workflow="${item['validate-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(' ')}>${this.escapeXml(item.description || '')}</item>\n`;
|
|
}
|
|
}
|
|
|
|
// Always inject *exit last
|
|
xml += ` <item cmd="*exit">Exit with confirmation</item>\n`;
|
|
|
|
xml += ' </menu>\n';
|
|
|
|
return xml;
|
|
}
|
|
|
|
/**
|
|
* Escape XML special characters
|
|
*/
|
|
escapeXml(text) {
|
|
if (!text) return '';
|
|
return text
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
/**
|
|
* Calculate file hash for build tracking
|
|
*/
|
|
async calculateFileHash(filePath) {
|
|
if (!(await fs.pathExists(filePath))) {
|
|
return null;
|
|
}
|
|
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Build agent XML from YAML files and return as string (for in-memory use)
|
|
* @param {string} agentYamlPath - Path to agent YAML
|
|
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
* @param {Object} options - Build options
|
|
* @returns {Promise<string>} XML content as string
|
|
*/
|
|
async buildFromYaml(agentYamlPath, customizeYamlPath = null, options = {}) {
|
|
// Load and merge YAML files
|
|
const mergedAgent = await this.loadAndMergeAgent(agentYamlPath, customizeYamlPath);
|
|
|
|
// Calculate hashes for build tracking
|
|
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
|
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
|
|
|
// Build metadata
|
|
const buildMetadata = {
|
|
sourceFile: path.basename(agentYamlPath),
|
|
sourceHash,
|
|
customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null,
|
|
customizeHash,
|
|
builderVersion: '1.0.0',
|
|
includeMetadata: options.includeMetadata !== false,
|
|
skipActivation: options.skipActivation === true,
|
|
forWebBundle: options.forWebBundle === true,
|
|
};
|
|
|
|
// Convert to XML and return
|
|
return await this.convertToXml(mergedAgent, buildMetadata);
|
|
}
|
|
|
|
/**
|
|
* Build agent XML from YAML files
|
|
* @param {string} agentYamlPath - Path to agent YAML
|
|
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
* @param {string} outputPath - Path to write XML file
|
|
* @param {Object} options - Build options
|
|
*/
|
|
async buildAgent(agentYamlPath, customizeYamlPath, outputPath, options = {}) {
|
|
// Use buildFromYaml to get XML content
|
|
const xml = await this.buildFromYaml(agentYamlPath, customizeYamlPath, options);
|
|
|
|
// Write output file
|
|
await fs.ensureDir(path.dirname(outputPath));
|
|
await fs.writeFile(outputPath, xml, 'utf8');
|
|
|
|
// Calculate hashes for return value
|
|
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
|
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
|
|
|
return {
|
|
success: true,
|
|
outputPath,
|
|
sourceHash,
|
|
customizeHash,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { YamlXmlBuilder };
|