UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

410 lines (371 loc) 14 kB
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; } }