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 = '\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 += `