import * as fs from "node:fs"; import * as path from "node:path"; import { getProjectDir, getProjectPromptsDir, getPromptsDir, logger, parseFrontmatter, prompt, } from "@oh-my-pi/pi-utils"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; /** * Represents a prompt template loaded from a markdown file */ export interface PromptTemplate { name: string; description: string; content: string; source: string; // e.g., "(user)", "(project)", "(project:frontend)" } prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => { try { return jtdToTypeScript(schema); } catch { return "unknown"; } }); /** * Render a subagent output schema inside the `yield` tool's `{ data: … }` * argument shape so the model sees the exact call it must make, not just the * user-facing payload. Without this the LLM pattern-matches on the bare * interface and puts schema fields at the top level of the call, tripping * schema validation repeatedly. */ prompt.registerHelper("renderYieldSchema", (schema: unknown): string => { let ts: string; try { ts = jtdToTypeScript(schema); } catch { ts = "unknown"; } const lines = ts.split("\n"); const [first, ...rest] = lines; const body = rest.length === 0 ? first : `${first}\n${rest.map(l => ` ${l}`).join("\n")}`; return `{\n data: ${body};\n}`; }); const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/; const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/; /** * Keep the check source-level and cheap: if the template text contains any explicit * inline-arg placeholder syntax, do not append the fallback text again. */ export function templateUsesInlineArgPlaceholders(templateSource: string): boolean { return INLINE_ARG_SHELL_PATTERN.test(templateSource) || INLINE_ARG_TEMPLATE_PATTERN.test(templateSource); } export function appendInlineArgsFallback( rendered: string, argsText: string, usesInlineArgPlaceholders: boolean, ): string { if (argsText.length === 0 || usesInlineArgPlaceholders) return rendered; if (rendered.length === 0) return argsText; return `${rendered}\n\n${argsText}`; } /** * Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates */ async function loadTemplatesFromDir( dir: string, source: "user" | "project", subdir: string = "", ): Promise { const templates: PromptTemplate[] = []; try { const glob = new Bun.Glob("**/*"); const entries = []; for await (const entry of glob.scan({ cwd: dir, absolute: false, onlyFiles: false })) { entries.push(entry); } // Group by path depth to process directories before deeply nested files entries.sort((a, b) => a.split("/").length - b.split("/").length); for (const entry of entries) { const fullPath = path.join(dir, entry); const file = Bun.file(fullPath); try { const stat = await file.exists(); if (!stat) continue; if (entry.endsWith(".md")) { const rawContent = await file.text(); const { frontmatter, body } = parseFrontmatter(rawContent, { source: fullPath }); const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension // Build source string based on subdirectory structure const entryDir = entry.includes("/") ? entry.split("/").slice(0, -1).join(":") : ""; const fullSubdir = subdir && entryDir ? `${subdir}:${entryDir}` : entryDir || subdir; let sourceStr: string; if (source === "user") { sourceStr = fullSubdir ? `(user:${fullSubdir})` : "(user)"; } else { sourceStr = fullSubdir ? `(project:${fullSubdir})` : "(project)"; } // Get description from frontmatter or first non-empty line let description = String(frontmatter.description || ""); if (!description) { const firstLine = body.split("\n").find(line => line.trim()); if (firstLine) { // Truncate if too long description = firstLine.slice(0, 60); if (firstLine.length > 60) description += "..."; } } // Append source to description description = description ? `${description} ${sourceStr}` : sourceStr; templates.push({ name, description, content: body, source: sourceStr, }); } } catch (error) { logger.warn("Failed to load prompt template", { path: fullPath, error: String(error) }); } } } catch (error) { if (!fs.existsSync(dir)) { return []; } logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) }); } return templates; } export interface LoadPromptTemplatesOptions { /** Working directory for project-local templates. Default: getProjectDir() */ cwd?: string; /** Agent config directory for global templates. Default: from getPromptsDir() */ agentDir?: string; } /** * Load all prompt templates from: * 1. Global: agentDir/prompts/ * 2. Project: cwd/.omp/prompts/ */ export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): Promise { const resolvedCwd = options.cwd ?? getProjectDir(); const resolvedAgentDir = options.agentDir ?? getPromptsDir(); const templates: PromptTemplate[] = []; // 1. Load global templates from agentDir/prompts/ // Note: if agentDir is provided, it should be the agent dir, not the prompts dir const globalPromptsDir = options.agentDir ? path.join(options.agentDir, "prompts") : resolvedAgentDir; templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user"))); // 2. Load project templates from cwd/.omp/prompts/ const projectPromptsDir = getProjectPromptsDir(resolvedCwd); templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project"))); return templates; } /** * Expand a prompt template if it matches a template name. * Returns the expanded content or the original text if not a template. */ export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { if (!text.startsWith("/")) return text; const spaceIndex = text.indexOf(" "); const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const template = templates.find(t => t.name === templateName); if (template) { const args = parseCommandArgs(argsString); const argsText = args.join(" "); const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(template.content); const substituted = substituteArgs(template.content, args); const rendered = prompt.render(substituted, { args, ARGUMENTS: argsText, arguments: argsText }); return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders); } return text; }