cmte
Version:
Design by Committee™ except it's just you and LLMs
410 lines (371 loc) • 14 kB
JavaScript
import path from 'path';
import Mustache from 'mustache';
import { readFile } from "../utils/fs.js";
import { logger } from '../utils/logger.js';
import * as llmxml from "../utils/llmxml.js";
import yaml from 'yaml';
// Cache to avoid loading the same files multiple times
const fileContentCache = new Map();
/**
* Represents a file collection for template rendering
*/
/**
* Template renderer that supports file collections and context management
*/
export class TemplateRenderer {
collections = {};
/**
* Creates a new template renderer
* @param context Initial context
* @param templateDir Optional template directory path
* @param useXML Whether to convert templates to XML format
*/
constructor(context = {}, templateDir, useXML = false) {
this.context = context;
this.templateDir = templateDir || process.env.TEMPLATE_DIR || 'templates';
this.useXML = useXML;
logger.debug('Created template renderer', {
templateDir: this.templateDir,
initialContextKeys: Object.keys(context),
useXML: this.useXML
});
}
/**
* Updates the template context
* @param newContext New context values to merge
*/
updateContext(newContext) {
this.context = {
...this.context,
...newContext
};
logger.debug('Updated template context', {
contextKeys: Object.keys(this.context)
});
}
/**
* Registers a file collection for use in templates
* @param name Collection name
* @param files Array of file paths
*/
registerCollection(name, files) {
this.collections[name] = files;
logger.debug(`Registered file collection: ${name}`, {
fileCount: files.length
});
}
/**
* Registers multiple file collections
* @param collections Record of collection names to file arrays
*/
registerCollections(collections) {
for (const [name, files] of Object.entries(collections)) {
this.registerCollection(name, files);
}
}
/**
* Loads a template file from the template directory
* @param templateName Template name/path relative to template dir
* @returns Template content
*/
async loadTemplate(templateName) {
const templatePath = path.join(this.templateDir, templateName);
if (fileContentCache.has(templatePath)) {
logger.debug(`Using cached template: ${templatePath}`);
return fileContentCache.get(templatePath);
}
try {
logger.debug(`Loading template: ${templatePath}`);
const content = await readFile(templatePath);
fileContentCache.set(templatePath, content);
return content;
} catch (error) {
logger.error(`Failed to load template: ${templatePath}`, {
error
});
throw new Error(`Unable to load template ${templateName}: ${error.message}`);
}
}
/**
* Renders a template string with the current context
* @param template Template string
* @param options Optional rendering options
* @returns Rendered content
*/
async render(template, options) {
try {
// First resolve any file collections
const resolvedTemplate = await this.resolveFileCollections(template);
// Then render with Mustache
// Disable HTML escaping as we're working with markdown
Mustache.escape = text => text;
logger.debug('Rendering template with context', {
contextKeys: Object.keys(this.context),
collectionKeys: Object.keys(this.collections),
isLLMResponse: options?.isLLMResponse
});
// Split template into frontmatter and content if it exists
const frontmatterMatch = resolvedTemplate.match(/^(---\n[\s\S]+?\n---\n|{\n[\s\S]+?\n}\n)([\s\S]*)$/);
if (frontmatterMatch) {
const [, frontmatter, content] = frontmatterMatch;
// Parse frontmatter to check for useThinkingOutputVariables
const frontmatterContent = frontmatter.replace(/^---\n/, '').replace(/\n---\n$/, '');
const frontmatterData = yaml.parse(frontmatterContent);
const useThinkingOutputVariables = frontmatterData?.useThinkingOutputVariables || false;
// Render frontmatter with raw context (no escaping)
const renderedFrontmatter = Mustache.render(frontmatter, this.context);
// Create safe context for content rendering
const safeContext = this.createSafeContext(this.context);
// If thinking outputs exist and useThinkingOutputVariables is false, prepend them
let contentToRender = content;
if (this.context.thinking && !useThinkingOutputVariables) {
contentToRender = `# Your previous thoughts:\n\n${this.context.thinking}\n\n${content}`;
}
// Render content with safe context and proper section handling
const renderedContent = options?.isLLMResponse ?
this.escapeMustacheTags(Mustache.render(contentToRender, safeContext)) :
Mustache.render(contentToRender, safeContext);
return renderedFrontmatter + renderedContent;
}
// No frontmatter - create safe context and handle content
const safeContext = this.createSafeContext(this.context);
// If thinking outputs exist, prepend them (no frontmatter means no useThinkingOutputVariables)
let contentToRender = resolvedTemplate;
if (this.context.thinking) {
contentToRender = `# Your previous thoughts:\n\n${this.context.thinking}\n\n${resolvedTemplate}`;
}
// Render with safe context and proper section handling
return options?.isLLMResponse ?
this.escapeMustacheTags(Mustache.render(contentToRender, safeContext)) :
Mustache.render(contentToRender, safeContext);
} catch (error) {
logger.error('Failed to render template', {
error
});
throw new Error(`Template rendering failed: ${error.message}`);
}
}
/**
* Renders a template file with the current context
* @param templateName Name of the template file
* @param options Optional rendering options
* @returns Rendered content
*/
async renderTemplate(templateName, options) {
try {
logger.debug(`Loading template: ${templateName}`);
// Load template content
const templatePath = path.join(this.templateDir, templateName);
const template = await readFile(templatePath);
// Render the template
return this.render(template, options);
} catch (error) {
logger.error(`Failed to render template: ${templateName}`, {
error
});
throw new Error(`Template rendering failed: ${error.message}`);
}
}
/**
* Processes a response from the LLM, converting from XML if needed
* @param response Raw response from LLM
* @param template Optional template to render response with
* @returns Processed response
*/
async processResponse(response, template) {
try {
// First convert from XML if needed
let processedResponse = response;
if (this.useXML) {
logger.debug('Converting response from XML format');
processedResponse = await llmxml.toMarkdown(response);
}
// If a template is provided, render it with the response
if (template) {
logger.debug('Rendering response with template');
const templateContent = await this.loadTemplate(template);
return this.render(templateContent, {
isLLMResponse: true
});
}
return processedResponse;
} catch (error) {
logger.error('Failed to process response', {
error
});
throw new Error(`Response processing failed: ${error.message}`);
}
}
/**
* Resolves file collections in a template string
* @param template Template string with {{collection}} references
* @returns Template with collections resolved to markdown content blocks
*/
async resolveFileCollections(template) {
// Find all variable references in the template
const variableRegex = /\{\{([^}:]+)(?::([^}]+))?\}\}/g;
const variables = [...template.matchAll(variableRegex)].map(match => ({
full: match[0],
name: match[1].trim(),
filter: match[2]?.trim()
}));
let resolvedTemplate = template;
// Process each variable that might be a file collection
for (const variable of variables) {
const {
name,
filter
} = variable;
// Check if this variable refers to a file collection
if (this.collections[name]) {
logger.debug(`Resolving file collection: ${name}${filter ? ' with filter: ' + filter : ''}`);
// Get the matching files (apply filter if any)
const filePaths = filter ? this.applyFilter(this.collections[name], filter) : this.collections[name];
if (filePaths.length === 0) {
const replacement = `<!-- No matching files for ${name}${filter ? ': ' + filter : ''} -->`;
resolvedTemplate = resolvedTemplate.replace(variable.full, replacement);
continue;
}
// Build markdown content for the file collection
const contentParts = [];
// Add a section header for the collection (H1)
contentParts.push(`# ${name}`);
contentParts.push('');
for (const filePath of filePaths) {
try {
const content = await this.loadFileContent(filePath);
const fileName = path.basename(filePath);
// Add a header for each file (H2)
contentParts.push(`## ${fileName}`);
contentParts.push('');
// Determine language for code block based on file extension
const ext = path.extname(fileName).slice(1);
const language = this.getLanguageForExtension(ext);
// Add code block with language identifier when appropriate
contentParts.push('```' + language);
contentParts.push(content);
contentParts.push('```');
contentParts.push('');
} catch (error) {
logger.warn(`Failed to load file for collection: ${filePath}`, {
error
});
contentParts.push(`## ${path.basename(filePath)}`);
contentParts.push('');
contentParts.push(`<!-- Error loading file: ${error.message} -->`);
contentParts.push('');
}
}
const replacement = contentParts.join('\n');
// Replace the variable with the collection content
resolvedTemplate = resolvedTemplate.replace(variable.full, replacement);
}
}
return resolvedTemplate;
}
/**
* Get the language identifier for code blocks based on file extension
* @param extension File extension
* @returns Language identifier for code blocks
*/
getLanguageForExtension(extension) {
const languageMap = {
// Programming languages
'js': 'javascript',
'ts': 'typescript',
'jsx': 'jsx',
'tsx': 'tsx',
'py': 'python',
'java': 'java',
'rb': 'ruby',
'php': 'php',
'go': 'go',
'rs': 'rust',
'c': 'c',
'cpp': 'cpp',
'cs': 'csharp',
'swift': 'swift',
'kt': 'kotlin',
// Markup and config
'html': 'html',
'css': 'css',
'scss': 'scss',
'json': 'json',
'yaml': 'yaml',
'yml': 'yaml',
'xml': 'xml',
'md': 'markdown',
'sh': 'bash',
'bash': 'bash',
'sql': 'sql',
// Default to empty string for unknown extensions
'': ''
};
return languageMap[extension.toLowerCase()] || '';
}
/**
* Applies a filter pattern to a list of file paths
* @param files Array of file paths
* @param filter Filter pattern (glob-like)
* @returns Filtered array of file paths
*/
applyFilter(files, filter) {
// Support basic glob patterns by converting to regex
const regexPattern = filter.replace(/\./g, '\\.') // Escape periods
.replace(/\*\*/g, '.*') // ** becomes .* (any characters)
.replace(/\*/g, '[^/]*') // * becomes [^/]* (any characters except /)
.replace(/\?/g, '[^/]'); // ? becomes [^/] (any single character except /)
const regex = new RegExp(regexPattern);
return files.filter(file => regex.test(file));
}
/**
* Loads content from a file with caching
* @param filePath Path to the file
* @returns File content
*/
async loadFileContent(filePath) {
if (fileContentCache.has(filePath)) {
return fileContentCache.get(filePath);
}
const content = await readFile(filePath);
fileContentCache.set(filePath, content);
return content;
}
/**
* Creates a safe context for template rendering
* @param context Original context object
* @returns Safe context object
*/
createSafeContext(context) {
const safeContext = {};
for (const [key, value] of Object.entries(context)) {
if (typeof value === 'string') {
// Don't escape Mustache tags in normal template rendering
safeContext[key] = value;
} else if (Array.isArray(value)) {
// Handle arrays properly for iteration
safeContext[key] = value.map(item => typeof item === 'object' ? this.createSafeContext(item) : item);
} else if (value && typeof value === 'object') {
// Recursively process nested objects
safeContext[key] = this.createSafeContext(value);
} else {
// Pass through non-string values unchanged
safeContext[key] = value;
}
}
return safeContext;
}
/**
* Escapes mustache tags in a string by adding a zero-width space
* Only used for LLM responses where we want to preserve the tags
* @param text Text containing mustache tags to escape
* @returns Text with escaped mustache tags
*/
escapeMustacheTags(text) {
// Only escape if the text appears to be an LLM response with template tags
if (text.includes('{{') && text.includes('}}')) {
return text.replace(/{{/g, '{{').replace(/}}/g, '}}');
}
return text;
}
}