agent updates
This commit is contained in:
370
tools/cli/lib/yaml-xml-builder.js
Normal file
370
tools/cli/lib/yaml-xml-builder.js
Normal file
@@ -0,0 +1,370 @@
|
||||
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
|
||||
const activationBlock = await this.activationBuilder.buildActivation(profile, metadata, agent.critical_actions || []);
|
||||
|
||||
// Start building XML
|
||||
let xml = '<!-- Powered by BMAD-CORE™ -->\n\n';
|
||||
xml += `# ${metadata.title || 'Agent'}\n\n`;
|
||||
|
||||
// Add build metadata as comment
|
||||
if (buildMetadata.includeMetadata) {
|
||||
xml += this.buildMetadataComment(buildMetadata);
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
* @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 = {}) {
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Convert to XML
|
||||
const xml = await this.convertToXml(mergedAgent, buildMetadata);
|
||||
|
||||
// Write output file
|
||||
await fs.ensureDir(path.dirname(outputPath));
|
||||
await fs.writeFile(outputPath, xml, 'utf8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
sourceHash,
|
||||
customizeHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { YamlXmlBuilder };
|
||||
Reference in New Issue
Block a user