/** * Prompt Loader - Loads prompts from markdown files * * Supports: * - Loading prompts from templates/{promptId}/ directory * - Snippet inclusion via {{snippet:name}} syntax * - Conditional blocks via {{#if condition}}...{{/if}} syntax * - Variable interpolation via {{variable}} syntax */ import fs from 'fs'; import path from 'path'; import type { PromptId, LoadedPrompt, SnippetId } from './types'; import { createLogger } from '@/lib/logger'; const log = createLogger('PromptLoader'); /** * Get the prompts directory path */ function getPromptsDir(): string { // In Next.js, use process.cwd() for the project root return path.join(process.cwd(), 'lib', 'prompts'); } /** * Load a snippet by ID */ export function loadSnippet(snippetId: SnippetId): string { const snippetPath = path.join(getPromptsDir(), 'snippets', `${snippetId}.md`); try { return fs.readFileSync(snippetPath, 'utf-8').trim(); } catch { // Fail loud rather than silently shipping `{{snippet:foo}}` to the LLM. // A missing snippet is always a config/typo bug — surface at load time. throw new Error(`Snippet not found: ${snippetId}`); } } /** * Process snippet includes in a template. * Replaces {{snippet:name}} with actual snippet content. */ export function processSnippets(template: string): string { return template.replace(/\{\{snippet:(\w[\w-]*)\}\}/g, (_, snippetId) => { return loadSnippet(snippetId as SnippetId); }); } /** * Process conditional blocks in a template. * Replaces {{#if conditionName}}...{{/if}} with the inner content when the * named condition is truthy, or removes the entire block when it is falsy. * * Blocks do not nest — this is intentional to keep the prompt templating * language simple and reviewable. */ export function processConditionalBlocks( template: string, conditions: Record, ): string { return template.replace( /\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, conditionName: string, content: string) => { return conditions[conditionName] ? content : ''; }, ); } /** * Load a prompt by ID */ export function loadPrompt(promptId: PromptId): LoadedPrompt | null { const promptDir = path.join(getPromptsDir(), 'templates', promptId); try { // Load system.md const systemPath = path.join(promptDir, 'system.md'); let systemPrompt = fs.readFileSync(systemPath, 'utf-8').trim(); systemPrompt = processSnippets(systemPrompt); // Load user.md (optional, may not exist) const userPath = path.join(promptDir, 'user.md'); let userPromptTemplate = ''; try { userPromptTemplate = fs.readFileSync(userPath, 'utf-8').trim(); userPromptTemplate = processSnippets(userPromptTemplate); } catch { // user.md is optional } return { id: promptId, systemPrompt, userPromptTemplate, }; } catch (error) { log.error(`Failed to load prompt ${promptId}:`, error); return null; } } /** * Interpolate variables in a template * Replaces {{variable}} with values from the variables object */ export function interpolateVariables(template: string, variables: Record): string { // `\w+` only matches [A-Za-z0-9_], so kebab-case placeholders like // `{{next-agent}}` pass through unchanged. Convention (per README) is // camelCase; tests in tests/prompts/templates.test.ts scan templates // for non-conforming placeholders. return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { const value = variables[key]; if (value === undefined) return match; if (typeof value === 'object') return JSON.stringify(value, null, 2); return String(value); }); } /** * Build a complete prompt with variables. * * Processing order: * 1. Snippet includes ({{snippet:name}}) — file content spliced in * 2. Conditional blocks ({{#if flag}}...{{/if}}) — gated on `variables` * 3. Variable interpolation ({{varName}}) — values substituted */ export function buildPrompt( promptId: PromptId, variables: Record, ): { system: string; user: string } | null { const prompt = loadPrompt(promptId); if (!prompt) return null; return { system: interpolateVariables( processConditionalBlocks(prompt.systemPrompt, variables), variables, ), user: interpolateVariables( processConditionalBlocks(prompt.userPromptTemplate, variables), variables, ), }; }