146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
/**
|
|
* 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,
|
|
),
|
|
};
|
|
}
|