File size: 4,463 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | /**
* 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, unknown>,
): 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, unknown>): 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<string, unknown>,
): { 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,
),
};
}
|