/** * Dynamic System Prompt Assembler * * Reads the base system prompt, evaluates conditions, and applies matching * fragments (replace / remove / append) to produce the final prompt. * * See DYNAMIC-PROMPTS.md for the full playbook. */ import fs from 'fs'; import path from 'path'; import { log } from '../../shared/logger.js'; import { conditions, type ConditionResult } from './prompt-conditions.js'; const FRAGMENTS_FILE = path.join(import.meta.dirname, 'prompt-fragments.json'); /** * One base system prompt per harness. The dynamic-fragment machinery * (markers + prompt-fragments.json + prompt-conditions.ts) is shared across * all three — each file carries the same `` markers, so a * fragment applies regardless of which harness is active. Copy the markers * into every file when adding a new dynamic section (see DYNAMIC-PROMPTS.md). */ export type Harness = 'claude' | 'codex' | 'pi'; const DEFAULT_PROMPT_FILENAME = 'bloby-system-prompt.txt'; const PROMPT_FILENAMES: Record = { claude: 'bloby-system-prompt.txt', codex: 'bloby-system-prompt-codex.txt', pi: 'bloby-system-prompt-pi.txt', }; /** Absolute path to the base prompt file for a given harness. */ function promptFilePath(harness: Harness): string { const filename = PROMPT_FILENAMES[harness] ?? DEFAULT_PROMPT_FILENAME; return path.join(import.meta.dirname, filename); } // ── Types ──────────────────────────────────────────────────────────────────── export interface PromptFragment { id: string; description: string; /** Which block this fragment acts on */ target: string; /** replace = swap marker content, remove = delete marker block, append = add after marker (or end) */ action: 'replace' | 'remove' | 'append'; /** Lower number = evaluated first. First matching fragment per target wins for replace/remove. */ priority: number; /** The text to insert (supports {{variable}} interpolation). Not used for 'remove'. */ content?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── /** Read and parse the fragments JSON file */ function loadFragments(): PromptFragment[] { try { const raw = fs.readFileSync(FRAGMENTS_FILE, 'utf-8'); return JSON.parse(raw); } catch (err) { log.warn('Could not load prompt-fragments.json — using base prompt only'); return []; } } /** * Read the base system prompt for `harness` with $BOT / $HUMAN replacement. * If the harness-specific file is missing or empty, fall back to the Claude * prompt (`bloby-system-prompt.txt`) so a not-yet-created codex/pi file never * collapses the agent to the minimal stub. */ function readBasePrompt(botName = 'Bloby', humanName = 'Human', harness: Harness = 'claude'): string { const primary = promptFilePath(harness); const fallback = path.join(import.meta.dirname, DEFAULT_PROMPT_FILENAME); const candidates = primary === fallback ? [primary] : [primary, fallback]; for (const file of candidates) { try { const raw = fs.readFileSync(file, 'utf-8').trim(); if (!raw) continue; if (file === fallback && primary !== fallback) { log.warn(`[prompt] "${harness}" prompt (${path.basename(primary)}) missing/empty — falling back to ${DEFAULT_PROMPT_FILENAME}`); } return raw.replace(/\$BOT/g, botName).replace(/\$HUMAN/g, humanName); } catch { // try next candidate } } log.warn('System prompt file not found — using minimal fallback'); return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`; } /** Interpolate {{variable}} placeholders in content using vars map */ function interpolate(content: string, vars: Record): string { return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`); } /** * Regex to match a dynamic marker block: * * ...content... * */ function markerRegex(target: string): RegExp { return new RegExp( `\\n?([\\s\\S]*?)`, ); } // ── Main ───────────────────────────────────────────────────────────────────── /** * Assemble the final system prompt by evaluating all fragment conditions * and applying the matching ones to the base prompt. */ export async function assembleSystemPrompt( botName = 'Bloby', humanName = 'Human', harness: Harness = 'claude', ): Promise { let prompt = readBasePrompt(botName, humanName, harness); const fragments = loadFragments(); if (!fragments.length) { log.info('[prompt] No fragments found — using base prompt only'); return prompt; } log.info(`[prompt] Harness "${harness}" (${PROMPT_FILENAMES[harness] ?? DEFAULT_PROMPT_FILENAME}) — evaluating ${fragments.length} fragment(s)...`); // Sort by priority (lower = first) const sorted = [...fragments].sort((a, b) => a.priority - b.priority); // Evaluate all conditions in parallel const results = await Promise.all( sorted.map(async (frag): Promise<{ fragment: PromptFragment; result: ConditionResult }> => { const condFn = conditions[frag.id]; if (!condFn) { log.warn(`No condition registered for fragment "${frag.id}" — skipping`); return { fragment: frag, result: false }; } try { return { fragment: frag, result: await condFn() }; } catch (err) { log.warn(`Condition "${frag.id}" threw — skipping: ${err}`); return { fragment: frag, result: false }; } }), ); // Track which targets have already been handled (for replace/remove — first match wins) const handledTargets = new Set(); for (const { fragment, result } of results) { if (result === false) { log.info(`[prompt] SKIP "${fragment.id}" (condition false)`); continue; } const { target, action, content } = fragment; // For replace/remove, only the first matching fragment per target wins if ((action === 'replace' || action === 'remove') && handledTargets.has(target)) { log.info(`[prompt] SKIP "${fragment.id}" (target "${target}" already handled)`); continue; } const vars = typeof result === 'object' ? result : {}; const regex = markerRegex(target); switch (action) { case 'replace': { if (!content) break; const finalContent = interpolate(content, vars); if (regex.test(prompt)) { prompt = prompt.replace(regex, finalContent); handledTargets.add(target); const varsInfo = Object.keys(vars).length ? ` vars=${JSON.stringify(vars)}` : ''; log.info(`[prompt] REPLACE [${target}] ← "${fragment.id}"${varsInfo}`); } else { log.warn(`[prompt] Marker "" not found in base prompt`); } break; } case 'remove': { if (regex.test(prompt)) { prompt = prompt.replace(regex, ''); handledTargets.add(target); log.info(`[prompt] REMOVE [${target}] by "${fragment.id}"`); } break; } case 'append': { if (!content) break; const finalContent = interpolate(content, vars); // If target specified, append after the closing marker; otherwise append to end if (target) { const closingMarker = ``; const idx = prompt.indexOf(closingMarker); if (idx !== -1) { const insertAt = idx + closingMarker.length; prompt = prompt.slice(0, insertAt) + '\n\n' + finalContent + prompt.slice(insertAt); } else { prompt += '\n\n' + finalContent; } } else { prompt += '\n\n' + finalContent; } log.info(`[prompt] APPEND [${target || 'end'}] ← "${fragment.id}"`); break; } } } // Clean up any remaining unhandled dynamic markers (leave their default content) // No action needed — unmatched markers keep their original content between the tags. // Strip the marker comments themselves so they don't leak into the prompt. prompt = prompt.replace(/\n?/g, ''); return prompt; }