/** * prompt-optimizer — config-driven system prompt patches. * * Config: .pi/prompt-patches.json (project) or ~/.pi/agent/prompt-patches.json (global). * * Schema: * { * "patches": [ * { "name": "drop bash blanket", "op": "remove", "find": "exact text\n" }, * { "name": "soften rule", "op": "replace", "find": "pattern", "replace": "...", "regex": true, "flags": "gi" }, * { "name": "house rule", "op": "append", "text": "\n\nPrefer rg over grep.\n" } * ] * } * * Behavior: * - session_start (startup/reload) → reads config from disk, sets up fs.watch for live reload, * validates schema (skips invalid patches with a notify warning), warns about regex patches * missing the "g" flag, and reports overall status. * - before_agent_start → applies patches using the in-memory cache (no disk IO per * turn); flushes any watch-triggered notifications before patching. * - /prompt-dump [path] → writes the post-patch prompt (default: ./pi-system-prompt-patched.md). * * Error handling: * - JSON.parse failures are caught, logged to console.error, and surfaced via ctx.ui.notify('error'). * - fs.watch invalidates the cache on file change or deletion and queues notifications so they * are delivered on the next ctx-bearing event (before_agent_start or session_start). * - Patches that fail schema validation are skipped with a notify warning listing each issue. * - Regex patches without the "g" flag emit a warning at session_start (intent is preserved). */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { readFileSync, existsSync, writeFileSync, watch } from "fs"; import type { FSWatcher } from "fs"; import { join, resolve } from "path"; import { homedir } from "os"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Patch = | { name: string; op: "remove"; find: string; regex?: boolean; flags?: string } | { name: string; op: "replace"; find: string; replace: string; regex?: boolean; flags?: string; } | { name: string; op: "append"; text: string }; type PatchConfig = { patches: Patch[] }; type NotifyLevel = "info" | "warning" | "error"; type NotifyFn = (msg: string, level: NotifyLevel) => void; // --------------------------------------------------------------------------- // Module-level cache (avoids disk IO on every before_agent_start turn) // --------------------------------------------------------------------------- /** undefined = not yet initialised; null = no config file found */ let configCache: { path: string; config: PatchConfig } | null | undefined = undefined; let configWatcher: FSWatcher | null = null; /** Notifications queued by the fs.watch callback (no ctx available there). */ const pendingNotifications: Array<{ msg: string; level: NotifyLevel }> = []; function flushNotifications(notify: NotifyFn): void { const items = pendingNotifications.splice(0); for (const { msg, level } of items) { notify(msg, level); } } // --------------------------------------------------------------------------- // Schema validation (hand-rolled, no external deps) // --------------------------------------------------------------------------- type ValidationResult = { config: PatchConfig; warnings: string[] }; function validatePatch(p: unknown): { valid: boolean; reason?: string } { if (typeof p !== "object" || p === null) return { valid: false, reason: "not an object" }; const patch = p as Record; if (typeof patch.name !== "string" || !patch.name) return { valid: false, reason: 'missing or empty "name"' }; if (!["remove", "replace", "append"].includes(patch.op as string)) return { valid: false, reason: `"op" must be remove | replace | append, got "${patch.op}"` }; if (patch.op === "remove") { if (typeof patch.find !== "string") return { valid: false, reason: '"remove" op requires "find" (string)' }; } else if (patch.op === "replace") { if (typeof patch.find !== "string") return { valid: false, reason: '"replace" op requires "find" (string)' }; if (typeof patch.replace !== "string") return { valid: false, reason: '"replace" op requires "replace" (string)' }; } else if (patch.op === "append") { if (typeof patch.text !== "string") return { valid: false, reason: '"append" op requires "text" (string)' }; } return { valid: true }; } function validateConfig(raw: unknown): ValidationResult { if ( typeof raw !== "object" || raw === null || !Array.isArray((raw as Record).patches) ) { return { config: { patches: [] }, warnings: ['top-level structure must be { "patches": [...] }'], }; } const rawPatches = (raw as { patches: unknown[] }).patches; const valid: Patch[] = []; const warnings: string[] = []; for (const p of rawPatches) { const result = validatePatch(p); if (result.valid) { valid.push(p as Patch); } else { const name = typeof p === "object" && p !== null && typeof (p as Record).name === "string" ? `"${(p as Record).name}"` : "(unnamed)"; warnings.push(`${name}: ${result.reason}`); } } return { config: { patches: valid }, warnings }; } // --------------------------------------------------------------------------- // Config file helpers // --------------------------------------------------------------------------- function parseConfigFile( path: string, ): { config: unknown; parseError?: string } { try { return { config: JSON.parse(readFileSync(path, "utf8")) }; } catch (err) { const parseError = err instanceof SyntaxError ? err.message : String(err); return { config: null, parseError }; } } function reloadConfigFile(path: string): void { const { config: raw, parseError } = parseConfigFile(path); if (parseError) { const msg = `prompt-optimizer: JSON parse error in ${path}: ${parseError}`; console.error(`[prompt-optimizer]`, msg); pendingNotifications.push({ msg, level: "error" }); // Keep the previous cache rather than wiping it. return; } const { config, warnings } = validateConfig(raw); configCache = { path, config }; if (warnings.length) { const msg = `prompt-optimizer: config reloaded with validation issues — ${warnings.join("; ")} (${path})`; console.warn(`[prompt-optimizer]`, msg); pendingNotifications.push({ msg, level: "warning" }); } else { const msg = `prompt-optimizer: config reloaded OK (${path})`; console.log(`[prompt-optimizer]`, msg); pendingNotifications.push({ msg, level: "info" }); } } function setupConfigWatch(path: string): void { if (configWatcher) { try { configWatcher.close(); } catch { // ignore } configWatcher = null; } try { configWatcher = watch(path, (eventType: string) => { if (eventType === "rename" && !existsSync(path)) { // File deleted (or renamed away). configCache = null; const msg = `prompt-optimizer: config file deleted (${path}), patches disabled.`; console.warn(`[prompt-optimizer]`, msg); pendingNotifications.push({ msg, level: "warning" }); return; } // Changed or rename-back (editor atomic saves use rename). reloadConfigFile(path); }); } catch { // fs.watch may be unavailable (e.g. some network filesystems); ignore. } } /** * Load config from disk, populate the module-level cache, and set up the * file watcher. Always re-reads from disk — only called from session_start. */ function initConfig(cwd: string): { path: string; config: PatchConfig; parseError?: string; validationWarnings: string[]; } | null { // Tear down existing watcher so we can pick up a new path (e.g. after cwd change). if (configWatcher) { try { configWatcher.close(); } catch { // ignore } configWatcher = null; } configCache = undefined; const candidates = [ join(cwd, ".pi", "prompt-patches.json"), join(homedir(), ".pi", "agent", "prompt-patches.json"), ]; for (const path of candidates) { if (existsSync(path)) { const { config: raw, parseError } = parseConfigFile(path); if (parseError) { // Cache an empty-patch sentinel so before_agent_start skips gracefully. configCache = { path, config: { patches: [] } }; setupConfigWatch(path); return { path, config: { patches: [] }, parseError, validationWarnings: [] }; } const { config, warnings: validationWarnings } = validateConfig(raw); configCache = { path, config }; setupConfigWatch(path); return { path, config, validationWarnings }; } } configCache = null; return null; } /** Return the in-memory cached config (populated by initConfig / fs.watch). */ function getConfig(): { path: string; config: PatchConfig } | null { return configCache ?? null; } // --------------------------------------------------------------------------- // Patch application // --------------------------------------------------------------------------- function applyPatch( prompt: string, patch: Patch, ): { prompt: string; matched: boolean } { if (patch.op === "append") { return { prompt: prompt + patch.text, matched: true }; } const replacement = patch.op === "remove" ? "" : patch.replace; const before = prompt; let next: string; if (patch.regex) { const re = new RegExp(patch.find, patch.flags ?? ""); next = prompt.replace(re, replacement); } else { next = prompt.replace(patch.find, replacement); } return { prompt: next, matched: next !== before }; } function runPatches( prompt: string, patches: Patch[], ): { prompt: string; failures: string[] } { const failures: string[] = []; let working = prompt; for (const patch of patches) { const { prompt: next, matched } = applyPatch(working, patch); if (!matched) failures.push(patch.name); working = next; } return { prompt: working, failures }; } // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { let lastFailures = new Set(); pi.on("session_start", (event, ctx) => { const notify: NotifyFn = (msg, level) => ctx.ui.notify(msg, level); // Flush any notifications queued by a previous fs.watch callback. flushNotifications(notify); const found = initConfig(ctx.cwd ?? process.cwd()); if (!found) { setTimeout( () => notify("prompt-optimizer: no prompt-patches.json found (idle).", "info"), 200, ); return; } // Surface JSON parse errors immediately. if (found.parseError) { setTimeout( () => notify( `prompt-optimizer: JSON parse error in ${found.path}: ${found.parseError}`, "error", ), 200, ); console.error( `[prompt-optimizer] JSON parse error in ${found.path}: ${found.parseError}`, ); return; } // Surface schema validation issues. if (found.validationWarnings.length) { const msg = `⚠️ prompt-optimizer: skipped invalid patches — ${found.validationWarnings.join("; ")} (${found.path})`; setTimeout(() => notify(msg, "warning"), 200); console.warn(`[prompt-optimizer]`, msg); } // Warn about regex patches that lack the "g" flag (only first match replaced). const noGPatches = found.config.patches.filter( (p): p is Extract => (p.op === "remove" || p.op === "replace") && !!p.regex && !(p.flags ?? "").includes("g"), ); if (noGPatches.length) { const names = noGPatches.map((p) => `"${p.name}"`).join(", "); const msg = `⚠️ prompt-optimizer: regex patches without "g" flag (only the first match will be replaced): ${names}`; setTimeout(() => notify(msg, "warning"), 200); console.warn(`[prompt-optimizer]`, msg); } // Run patches to report initial match status. const { failures } = runPatches( ctx.getSystemPrompt(), found.config.patches, ); lastFailures = new Set(failures); const total = found.config.patches.length; const ok = total - failures.length; const msg = failures.length ? `⚠️ prompt-optimizer: ${ok}/${total} patches OK. FAILED: ${failures.join(", ")} (config: ${found.path})` : `prompt-optimizer: ${ok}/${total} patches OK [${event.reason}]`; setTimeout( () => notify(msg, failures.length ? "warning" : "info"), 200, ); }); pi.on("before_agent_start", (event, ctx) => { const notify: NotifyFn = (msg, level) => ctx.ui.notify(msg, level); // Flush any notifications queued by the fs.watch callback. flushNotifications(notify); const found = getConfig(); if (!found) return undefined; const { prompt, failures } = runPatches( event.systemPrompt, found.config.patches, ); const current = new Set(failures); const newlyFailed = [...current].filter((n) => !lastFailures.has(n)); const newlyFixed = [...lastFailures].filter((n) => !current.has(n)); if (newlyFailed.length) notify( `⚠️ prompt-optimizer: patches stopped matching: ${newlyFailed.join(", ")}`, "warning", ); if (newlyFixed.length) notify( `prompt-optimizer: patches recovered: ${newlyFixed.join(", ")}`, "info", ); lastFailures = current; return { systemPrompt: prompt }; }); // --------------------------------------------------------------------------- // Probe-based system prompt capture // --------------------------------------------------------------------------- // Fires a hidden probe turn so ALL before_agent_start handlers run (pi-slim, // skill-toggle, any future extension). Captures the fully-chained prompt // in `turn_start` (after every handler has applied), then aborts before the // LLM call. Works at fresh start — no prior turn needed. let probeDumpPath: string | null = null; pi.on("turn_start", (_event, ctx) => { if (!probeDumpPath) return; const prompt = ctx.getSystemPrompt(); const out = probeDumpPath; probeDumpPath = null; writeFileSync(out, prompt, "utf8"); ctx.ui.notify( `✅ Dumped (${prompt.length} chars) → ${out}`, "info", ); ctx.abort(); }); pi.registerCommand("prompt-dump", { description: "Dump the real system prompt (after all extensions) to a file", handler: async (args, ctx) => { const notify: NotifyFn = (msg, level) => ctx.ui.notify(msg, level); flushNotifications(notify); probeDumpPath = resolve(args.trim() || "pi-system-prompt-patched.md"); pi.sendUserMessage("."); // The probe turn triggers: all before_agent_start handlers chain → // turn_start captures ctx.getSystemPrompt() → writes file → aborts. }, }); }