diff --git a/bun.lock b/bun.lock index 9d103d406..1847e4fa1 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,7 @@ "version": "13.19.0", "dependencies": { "beautiful-mermaid": "^1.1", + "handlebars": "^4.7.9", "winston": "^3.19", "winston-daily-rotate-file": "^5.0", }, diff --git a/docs/slash-command-internals.md b/docs/slash-command-internals.md index 585439c68..5165efb5f 100644 --- a/docs/slash-command-internals.md +++ b/docs/slash-command-internals.md @@ -160,7 +160,7 @@ The Extensions dashboard also loads `slash-commands` capability and displays act If text still starts with `/`, attempt markdown command expansion. 4. **Prompt templates** (`expandPromptTemplate`) Applied after slash/custom processing. -5. **Delivery** +5. **Delivery** - idle: prompt is sent immediately to agent - streaming: prompt is queued as steer/follow-up depending on `streamingBehavior` @@ -177,7 +177,7 @@ This is why slash command expansion sits before prompt-template expansion, and w - if matched, applies: - positional replacement: `$1`, `$2`, ... - aggregate replacement: `$ARGUMENTS` and `$@` - - then template rendering via `renderPromptTemplate` with `{ args, ARGUMENTS, arguments }` + - then template rendering via `prompt.render` with `{ args, ARGUMENTS, arguments }` - if no match, returns original text unchanged ### `parseCommandArgs` caveats diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 552523c08..06350ca71 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Simplified chunk edit operations: removed `append_child`, `prepend_child`, `append_sibling`, `prepend_sibling`, and `replace_body` ops in favor of unified `replace`, `before`, `after`, `prepend`, and `append` with region targeting (`@container`, `@prologue`, `@body`, `@epilogue`) @@ -41,6 +40,9 @@ ### Changed +- Moved prompt formatting and rendering utilities from `coding-agent` to `pi-utils` package; `renderPromptTemplate()` and `formatPromptContent()` now accessed via `prompt.render()` and `prompt.format()` from `@oh-my-pi/pi-utils` +- Moved `parseFrontmatter()` utility from `coding-agent` to `pi-utils` package; now imported from `@oh-my-pi/pi-utils` instead of local utils +- Consolidated prompt template handling: `TemplateContext` type now available as `prompt.TemplateContext` from `@oh-my-pi/pi-utils` - DAP initialization now advertises support for `runInTerminal` and `startDebugging` reverse requests, and memory references - Debug tool schema expanded with new parameters for instruction/data breakpoints, memory operations, and custom requests - DAP session state now tracks instruction and data breakpoints separately from source breakpoints @@ -78,6 +80,8 @@ ### Removed +- Deleted `src/utils/prompt-format.ts` module; prompt formatting logic moved to `pi-utils` +- Deleted `src/utils/frontmatter.ts` module; frontmatter parsing logic moved to `pi-utils` - Removed `waitForChildProcess` utility (child process termination now handled by native `killTree` from pi-natives) - `grep-chunk.md` (folded into unified grep template) - `startMacAppearanceObserver` export (use `MacAppearanceObserver.start()`) diff --git a/packages/coding-agent/scripts/format-prompts.ts b/packages/coding-agent/scripts/format-prompts.ts index 1037e8968..6973d4089 100644 --- a/packages/coding-agent/scripts/format-prompts.ts +++ b/packages/coding-agent/scripts/format-prompts.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { prompt } from "@oh-my-pi/pi-utils"; /** * Format prompt files (mixed XML + Markdown + Handlebars). * @@ -14,7 +15,6 @@ * 9. Bold RFC 2119 keywords (MUST, SHOULD, MAY, etc.) in prompt content */ import { Glob } from "bun"; -import { formatPromptContent } from "../src/utils/prompt-format"; const PROMPTS_DIR = new URL("../src/prompts/", import.meta.url).pathname; const COMMIT_PROMPTS_DIR = new URL("../src/commit/prompts/", import.meta.url).pathname; @@ -42,7 +42,7 @@ async function main() { for (const fullPath of files) { const original = await Bun.file(fullPath).text(); - const formatted = formatPromptContent(original, PROMPT_FORMAT_OPTIONS); + const formatted = prompt.format(original, PROMPT_FORMAT_OPTIONS); if (original !== `${formatted}\n`) { if (check) { diff --git a/packages/coding-agent/src/autoresearch/index.ts b/packages/coding-agent/src/autoresearch/index.ts index 6348ca0c2..0e2b06d35 100644 --- a/packages/coding-agent/src/autoresearch/index.ts +++ b/packages/coding-agent/src/autoresearch/index.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; -import { renderPromptTemplate } from "../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import type { ExtensionContext, ExtensionFactory } from "../extensibility/extensions"; import commandResumeTemplate from "./command-resume.md" with { type: "text" }; import { pathMatchesContractPath } from "./contract"; @@ -214,7 +214,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => { dashboard.updateWidget(ctx, runtime); await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]); api.sendUserMessage( - renderPromptTemplate(commandResumeTemplate, { + prompt.render(commandResumeTemplate, { autoresearch_md_path: autoresearchMdPath, branch_status_line: branchResult.created ? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.` @@ -303,7 +303,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => { api.sendMessage( { customType: "autoresearch-resume", - content: renderPromptTemplate(resumeMessageTemplate, { + content: prompt.render(resumeMessageTemplate, { autoresearch_md_path: autoresearchMdPath, has_ideas: fs.existsSync(ideasPath), has_pending_run: Boolean(pendingRun), @@ -347,7 +347,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => { }); const hasAutoresearchMd = fs.existsSync(autoresearchMdPath); return { - systemPrompt: renderPromptTemplate(promptTemplate, { + systemPrompt: prompt.render(promptTemplate, { base_system_prompt: event.systemPrompt, has_goal: goal.trim().length > 0, goal, diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index 1b533188b..43c35a380 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -1,10 +1,10 @@ import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { Markdown } from "@oh-my-pi/pi-tui"; +import { prompt } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" }; import type { ModelRegistry } from "../../config/model-registry"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import type { Settings } from "../../config/settings"; import { getMarkdownTheme } from "../../modes/theme/theme"; import { createAgentSession } from "../../sdk"; @@ -36,8 +36,8 @@ export interface ExistingChangelogEntries { } export async function runCommitAgentSession(input: CommitAgentInput): Promise { - const typesDescription = renderPromptTemplate(typesDescriptionPrompt); - const systemPrompt = renderPromptTemplate(agentSystemPrompt, { + const typesDescription = prompt.render(typesDescriptionPrompt); + const systemPrompt = prompt.render(agentSystemPrompt, { types_description: typesDescription, }); const state: CommitAgentState = { diffText: input.diffText }; @@ -149,7 +149,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise 0 ? input.changelogTargets.join("\n") : undefined, existing_changelog_entries: input.existingChangelogEntries, @@ -158,7 +158,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise 0; - await session.prompt(prompt, { + await session.prompt(agentUserMessage, { attribution: "agent", expandPromptTemplates: false, }); diff --git a/packages/coding-agent/src/commit/agentic/index.ts b/packages/coding-agent/src/commit/agentic/index.ts index 92a2e0915..2756c61cb 100644 --- a/packages/coding-agent/src/commit/agentic/index.ts +++ b/packages/coding-agent/src/commit/agentic/index.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils"; +import { $env, getProjectDir, isEnoent, prompt } from "@oh-my-pi/pi-utils"; import { applyChangelogProposals } from "../../commit/changelog"; import { detectChangelogBoundaries } from "../../commit/changelog/detect"; import { parseUnreleasedSection } from "../../commit/changelog/parse"; @@ -8,7 +8,6 @@ import { formatCommitMessage } from "../../commit/message"; import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection"; import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types"; import { ModelRegistry } from "../../config/model-registry"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { Settings } from "../../config/settings"; import { discoverAuthStorage, discoverContextFiles } from "../../sdk"; import * as git from "../../utils/git"; @@ -305,8 +304,8 @@ async function confirmSplitCommitPlan(plan: SplitCommitPlan): Promise { } const rl = createInterface({ input: process.stdin, output: process.stdout }); try { - const prompt = renderPromptTemplate(splitConfirmPrompt, { count: plan.commits.length }); - const answer = await rl.question(prompt); + const splitConfirmQuestion = prompt.render(splitConfirmPrompt, { count: plan.commits.length }); + const answer = await rl.question(splitConfirmQuestion); return ["y", "yes"].includes(answer.trim().toLowerCase()); } finally { rl.close(); diff --git a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts index 514406c7a..0826eac74 100644 --- a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts +++ b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts @@ -1,9 +1,9 @@ +import { prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import analyzeFilePrompt from "../../../commit/agentic/prompts/analyze-file.md" with { type: "text" }; import type { CommitAgentState } from "../../../commit/agentic/state"; import type { NumstatEntry } from "../../../commit/types"; import type { ModelRegistry } from "../../../config/model-registry"; -import { renderPromptTemplate } from "../../../config/prompt-templates"; import type { Settings } from "../../../config/settings"; import type { CustomTool, CustomToolContext } from "../../../extensibility/custom-tools/types"; import type { AuthStorage } from "../../../session/auth-storage"; @@ -66,7 +66,7 @@ export function createAnalyzeFileTool(options: { const numstat = options.state.overview?.numstat ?? []; const tasks = params.files.map((file, index) => { const relatedFiles = formatRelatedFiles(params.files, file, numstat); - const prompt = renderPromptTemplate(analyzeFilePrompt, { + const assignment = prompt.render(analyzeFilePrompt, { file, goal: params.goal, related_files: relatedFiles, @@ -74,7 +74,7 @@ export function createAnalyzeFileTool(options: { return { id: `AnalyzeFile${index + 1}`, description: `Analyze ${file}`, - assignment: prompt, + assignment, }; }); const taskParams: TaskParams = { diff --git a/packages/coding-agent/src/commit/analysis/conventional.ts b/packages/coding-agent/src/commit/analysis/conventional.ts index 654ef636e..639b138ec 100644 --- a/packages/coding-agent/src/commit/analysis/conventional.ts +++ b/packages/coding-agent/src/commit/analysis/conventional.ts @@ -1,11 +1,11 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" }; import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" }; import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; @@ -76,7 +76,7 @@ export async function generateConventionalAnalysis({ stat, diff, }: ConventionalAnalysisInput): Promise { - const prompt = renderPromptTemplate(analysisUserPrompt, { + const userContent = prompt.render(analysisUserPrompt, { context_files: contextFiles && contextFiles.length > 0 ? contextFiles : undefined, user_context: userContext, types_description: typesDescription, @@ -89,8 +89,8 @@ export async function generateConventionalAnalysis({ const response = await completeSimple( model, { - systemPrompt: renderPromptTemplate(analysisSystemPrompt), - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + systemPrompt: prompt.render(analysisSystemPrompt), + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], tools: [ConventionalAnalysisTool], }, { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts index fe2bbb94b..482a002eb 100644 --- a/packages/coding-agent/src/commit/analysis/summary.ts +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -1,11 +1,11 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" }; import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" }; import type { CommitSummary } from "../../commit/types"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall } from "../utils"; @@ -44,7 +44,7 @@ export async function generateSummary({ userContext, }: SummaryInput): Promise { const systemPrompt = renderSummaryPrompt({ commitType, scope, maxChars }); - const userPrompt = renderPromptTemplate(summaryUserPrompt, { + const userPrompt = prompt.render(summaryUserPrompt, { user_context: userContext, details: details.join("\n"), stat, @@ -73,7 +73,7 @@ function renderSummaryPrompt({ maxChars: number; }): string { const scopePrefix = scope ? `(${scope})` : ""; - return renderPromptTemplate(summarySystemPrompt, { + return prompt.render(summarySystemPrompt, { commit_type: commitType, scope_prefix: scopePrefix, chars: String(maxChars), diff --git a/packages/coding-agent/src/commit/changelog/generate.ts b/packages/coding-agent/src/commit/changelog/generate.ts index a3564fef9..4285fb23b 100644 --- a/packages/coding-agent/src/commit/changelog/generate.ts +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -1,11 +1,11 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { type TSchema, Type } from "@sinclair/typebox"; import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" }; import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" }; import { CHANGELOG_CATEGORIES, type ChangelogCategory, type ChangelogGenerationResult } from "../../commit/types"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../utils"; @@ -48,7 +48,7 @@ export async function generateChangelogEntries({ stat, diff, }: ChangelogPromptInput): Promise { - const prompt = renderPromptTemplate(changelogUserPrompt, { + const userContent = prompt.render(changelogUserPrompt, { changelog_path: changelogPath, is_package_changelog: isPackageChangelog, existing_entries: existingEntries, @@ -58,8 +58,8 @@ export async function generateChangelogEntries({ const response = await completeSimple( model, { - systemPrompt: renderPromptTemplate(changelogSystemPrompt), - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + systemPrompt: prompt.render(changelogSystemPrompt), + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], tools: [changelogTool], }, { apiKey, maxTokens: 1200, reasoning: toReasoningEffort(thinkingLevel) }, diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts index a7eff08ea..339790f97 100644 --- a/packages/coding-agent/src/commit/map-reduce/map-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -1,11 +1,11 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" }; import fileObserverUserPrompt from "../../commit/prompts/file-observer-user.md" with { type: "text" }; import type { FileDiff, FileObservation } from "../../commit/types"; import { isExcludedFile } from "../../commit/utils/exclusions"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { toReasoningEffort } from "../../thinking"; import { truncateToTokenLimit } from "./utils"; @@ -38,7 +38,7 @@ export async function runMapPhase({ config, }: MapPhaseInput): Promise { const filtered = files.filter(file => !isExcludedFile(file.filename)); - const systemPrompt = renderPromptTemplate(fileObserverSystemPrompt); + const systemPrompt = prompt.render(fileObserverSystemPrompt); const maxFileTokens = config?.maxFileTokens ?? MAX_FILE_TOKENS; const maxConcurrency = config?.maxConcurrency ?? MAX_CONCURRENCY; const timeoutMs = config?.timeoutMs ?? MAP_PHASE_TIMEOUT_MS; @@ -56,14 +56,14 @@ export async function runMapPhase({ const contextHeader = generateContextHeader(filtered, file.filename); const truncated = truncateToTokenLimit(file.content, maxFileTokens); - const prompt = renderPromptTemplate(fileObserverUserPrompt, { + const userContent = prompt.render(fileObserverUserPrompt, { filename: file.filename, diff: truncated, context_header: contextHeader, }); const request = { systemPrompt, - messages: [{ role: "user", content: prompt, timestamp: Date.now() }] as Message[], + messages: [{ role: "user", content: userContent, timestamp: Date.now() }] as Message[], }; const response = await withRetry( diff --git a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts index ab3f98484..f03e0a439 100644 --- a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts @@ -1,11 +1,11 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" }; import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" }; import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils"; @@ -67,7 +67,7 @@ export async function runReducePhase({ scopeCandidates, typesDescription, }: ReducePhaseInput): Promise { - const prompt = renderPromptTemplate(reduceUserPrompt, { + const userContent = prompt.render(reduceUserPrompt, { types_description: typesDescription, observations: observations.flatMap(obs => obs.observations.map(line => `- ${obs.file}: ${line}`)).join("\n"), stat, @@ -76,8 +76,8 @@ export async function runReducePhase({ const response = await completeSimple( model, { - systemPrompt: renderPromptTemplate(reduceSystemPrompt), - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + systemPrompt: prompt.render(reduceSystemPrompt), + messages: [{ role: "user", content: userContent, timestamp: Date.now() }], tools: [ReduceTool], }, { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) }, diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 517cc1fcb..4062f2ad6 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -1,9 +1,8 @@ import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; -import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { getProjectDir, logger, prompt } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../config/model-registry"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { Settings } from "../config/settings"; import { discoverAuthStorage } from "../sdk"; import { loadProjectContextFiles } from "../system-prompt"; @@ -26,7 +25,7 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types"; const SUMMARY_MAX_CHARS = 72; const RECENT_COMMITS_COUNT = 8; -const TYPES_DESCRIPTION = renderPromptTemplate(typesDescriptionPrompt); +const TYPES_DESCRIPTION = prompt.render(typesDescriptionPrompt); /** * Execute the omp commit pipeline for staged changes. @@ -236,7 +235,7 @@ async function generateSummaryWithRetry(input: { } function buildRetryContext(base: string | undefined, errors: string[]): string { - return renderPromptTemplate(summaryRetryPrompt, { + return prompt.render(summaryRetryPrompt, { base_context: base, errors: errors.join("; "), }); diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 6c5958445..99ee17783 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -1,13 +1,17 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { type ChunkAnchorStyle, formatAnchor } from "@oh-my-pi/pi-natives"; -import { getProjectDir, getProjectPromptsDir, getPromptsDir, logger } from "@oh-my-pi/pi-utils"; -import Handlebars from "handlebars"; +import { + getProjectDir, + getProjectPromptsDir, + getPromptsDir, + logger, + parseFrontmatter, + prompt, +} from "@oh-my-pi/pi-utils"; import { computeLineHash } from "../edit/modes/hashline"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; -import { parseFrontmatter } from "../utils/frontmatter"; -import { formatPromptContent } from "../utils/prompt-format"; /** * Represents a prompt template loaded from a markdown file @@ -19,215 +23,7 @@ export interface PromptTemplate { source: string; // e.g., "(user)", "(project)", "(project:frontend)" } -export interface TemplateContext extends Record { - args?: string[]; - ARGUMENTS?: string; - arguments?: string; -} - -const handlebars = Handlebars.create(); - -handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string { - const args = this.args ?? []; - const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10); - if (!Number.isFinite(parsedIndex)) return ""; - const zeroBased = parsedIndex - 1; - if (zeroBased < 0) return ""; - return args[zeroBased] ?? ""; -}); - -/** - * {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}} - * Renders an array with customizable prefix, suffix, and join separator. - * Note: Use \n in join for newlines (will be unescaped automatically). - */ -handlebars.registerHelper( - "list", - function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string { - if (!Array.isArray(context) || context.length === 0) return ""; - const prefix = (options.hash.prefix as string) ?? ""; - const suffix = (options.hash.suffix as string) ?? ""; - const rawSeparator = (options.hash.join as string) ?? "\n"; - const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t"); - return context.map(item => `${prefix}${options.fn(item)}${suffix}`).join(separator); - }, -); - -/** - * {{join array ", "}} - * Joins an array with a separator (default: ", "). - */ -handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => { - if (!Array.isArray(context)) return ""; - const sep = typeof separator === "string" ? separator : ", "; - return context.join(sep); -}); - -/** - * {{default value "fallback"}} - * Returns the value if truthy, otherwise returns the fallback. - */ -handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue); - -/** - * {{pluralize count "item" "items"}} - * Returns "1 item" or "5 items" based on count. - */ -handlebars.registerHelper( - "pluralize", - (count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`, -); - -/** - * {{#when value "==" compare}}...{{else}}...{{/when}} - * Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <= - */ -handlebars.registerHelper( - "when", - function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string { - const ops: Record boolean> = { - "==": (a, b) => a === b, - "===": (a, b) => a === b, - "!=": (a, b) => a !== b, - "!==": (a, b) => a !== b, - ">": (a, b) => (a as number) > (b as number), - "<": (a, b) => (a as number) < (b as number), - ">=": (a, b) => (a as number) >= (b as number), - "<=": (a, b) => (a as number) <= (b as number), - }; - const fn = ops[operator]; - if (!fn) return options.inverse(this); - return fn(lhs, rhs) ? options.fn(this) : options.inverse(this); - }, -); - -/** - * {{#ifAny a b c}}...{{else}}...{{/ifAny}} - * True if any argument is truthy. - */ -handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string { - const options = args.pop() as Handlebars.HelperOptions; - return args.some(Boolean) ? options.fn(this) : options.inverse(this); -}); - -/** - * {{#ifAll a b c}}...{{else}}...{{/ifAll}} - * True if all arguments are truthy. - */ -handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string { - const options = args.pop() as Handlebars.HelperOptions; - return args.every(Boolean) ? options.fn(this) : options.inverse(this); -}); - -/** - * {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}} - * Generates a markdown table from an array of objects. - */ -handlebars.registerHelper( - "table", - function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string { - if (!Array.isArray(context) || context.length === 0) return ""; - const headersStr = options.hash.headers as string | undefined; - const headers = headersStr?.split("|") ?? []; - const separator = headers.map(() => "---").join(" | "); - const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : ""; - const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n"); - return headerRow + rows; - }, -); - -/** - * {{#codeblock lang="diff"}}...{{/codeblock}} - * Wraps content in a fenced code block. - */ -handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string { - const lang = (options.hash.lang as string) ?? ""; - const content = options.fn(this).trim(); - return `\`\`\`${lang}\n${content}\n\`\`\``; -}); - -/** - * {{#xml "tag"}}content{{/xml}} - * Wraps content in XML-style tags. Returns empty string if content is empty. - */ -handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string { - const content = options.fn(this).trim(); - if (!content) return ""; - return `<${tag}>\n${content}\n`; -}); - -/** - * {{escapeXml value}} - * Escapes XML special characters: & < > " - */ -handlebars.registerHelper("escapeXml", (value: unknown): string => { - if (value == null) return ""; - return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -}); - -/** - * {{len array}} - * Returns the length of an array or string. - */ -handlebars.registerHelper("len", (value: unknown): number => { - if (Array.isArray(value)) return value.length; - if (typeof value === "string") return value.length; - return 0; -}); - -/** - * {{add a b}} - * Adds two numbers. - */ -handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0)); - -/** - * {{sub a b}} - * Subtracts b from a. - */ -handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0)); - -/** - * {{#has collection item}}...{{else}}...{{/has}} - * Checks if an array includes an item or if a Set/Map has a key. - */ -handlebars.registerHelper( - "has", - function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string { - let found = false; - if (Array.isArray(collection)) { - found = collection.includes(item); - } else if (collection instanceof Set) { - found = collection.has(item); - } else if (collection instanceof Map) { - found = collection.has(item); - } else if (collection && typeof collection === "object") { - if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") { - found = item in collection; - } - } - return found ? options.fn(this) : options.inverse(this); - }, -); - -/** - * {{includes array item}} - * Returns true if array includes item. For use in other helpers. - */ -handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => { - if (Array.isArray(collection)) return collection.includes(item); - if (collection instanceof Set) return collection.has(item); - if (collection instanceof Map) return collection.has(item); - return false; -}); - -/** - * {{not value}} - * Returns logical NOT of value. For use in subexpressions. - */ -handlebars.registerHelper("not", (value: unknown): boolean => !value); - -handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => { +prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => { try { return jtdToTypeScript(schema); } catch { @@ -235,7 +31,7 @@ handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => { } }); -handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value)); +prompt.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value)); /** * Renders a section separator: @@ -248,7 +44,7 @@ export function sectionSeparator(name: string): string { return `\n\n═══════════${name}═══════════\n`; } -handlebars.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name))); +prompt.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name))); function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } { const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10); @@ -262,7 +58,7 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t * {{href lineNum "content"}} — compute a real hashline ref for prompt examples. * Returns `"lineNum#hash"` using the actual hash algorithm. */ -handlebars.registerHelper("href", (lineNum: unknown, content: unknown): string => { +prompt.registerHelper("href", (lineNum: unknown, content: unknown): string => { const { ref } = formatHashlineRef(lineNum, content); return JSON.stringify(ref); }); @@ -271,7 +67,7 @@ handlebars.registerHelper("href", (lineNum: unknown, content: unknown): string = * {{hline lineNum "content"}} — format a full read-style line with prefix. * Returns `"lineNum#hash:content"`. */ -handlebars.registerHelper("hline", (lineNum: unknown, content: unknown): string => { +prompt.registerHelper("hline", (lineNum: unknown, content: unknown): string => { const { ref, text } = formatHashlineRef(lineNum, content); return `${ref}:${text}`; }); @@ -280,7 +76,7 @@ handlebars.registerHelper("hline", (lineNum: unknown, content: unknown): string * {{anchor name checksum}} — render a branch anchor tag using the current anchor style. * Style is resolved from the template context (`anchorStyle`) or defaults to "full". */ -handlebars.registerHelper("anchor", function (this: TemplateContext, name: string, checksum: string): string { +prompt.registerHelper("anchor", function (this: prompt.TemplateContext, name: string, checksum: string): string { const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full"; return formatAnchor(name, checksum, style); }); @@ -291,7 +87,7 @@ handlebars.registerHelper("anchor", function (this: TemplateContext, name: strin * In `kind` style each segment is trimmed to its kind prefix (`class.fn`). * In `bare` style the path is omitted (the model uses only `crc` to identify chunks). */ -handlebars.registerHelper("sel", function (this: TemplateContext, chunkPath: string): string { +prompt.registerHelper("sel", function (this: prompt.TemplateContext, chunkPath: string): string { const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full"; if (style === "full") return chunkPath; if (style === "bare") return ""; @@ -327,12 +123,6 @@ export function appendInlineArgsFallback( return `${rendered}\n\n${argsText}`; } -export function renderPromptTemplate(template: string, context: TemplateContext = {}): string { - const compiled = handlebars.compile(template, { noEscape: true, strict: false }); - const rendered = compiled(context ?? {}); - return formatPromptContent(rendered, { renderPhase: "post-render" }); -} - /** * Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates */ @@ -459,7 +249,7 @@ export function expandPromptTemplate(text: string, templates: PromptTemplate[]): const argsText = args.join(" "); const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(template.content); const substituted = substituteArgs(template.content, args); - const rendered = renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText }); + const rendered = prompt.render(substituted, { args, ARGUMENTS: argsText, arguments: argsText }); return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders); } diff --git a/packages/coding-agent/src/discovery/builtin.ts b/packages/coding-agent/src/discovery/builtin.ts index 131b1018b..64d2fe5b1 100644 --- a/packages/coding-agent/src/discovery/builtin.ts +++ b/packages/coding-agent/src/discovery/builtin.ts @@ -4,7 +4,7 @@ * Primary provider for OMP native configs. Supports all capabilities. */ import * as path from "node:path"; -import { logger, tryParseJson } from "@oh-my-pi/pi-utils"; +import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension"; @@ -22,7 +22,6 @@ import { type SystemPrompt, systemPromptCapability } from "../capability/system- import { type CustomTool, toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; import { expandTilde } from "../tools/path-utils"; -import { parseFrontmatter } from "../utils/frontmatter"; import { buildRuleFromMarkdown, createSourceMeta, diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index 58417224e..ec66866bb 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -7,7 +7,7 @@ * User directory: ~/.codex */ import * as path from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, parseFrontmatter } from "@oh-my-pi/pi-utils"; import { registerProvider } from "../capability"; import type { ContextFile } from "../capability/context-file"; import { contextFileCapability } from "../capability/context-file"; @@ -28,7 +28,7 @@ import { slashCommandCapability } from "../capability/slash-command"; import type { CustomTool } from "../capability/tool"; import { toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; -import { parseFrontmatter } from "../utils/frontmatter"; + import { createSourceMeta, discoverExtensionModulePaths, diff --git a/packages/coding-agent/src/discovery/github.ts b/packages/coding-agent/src/discovery/github.ts index 5ddc95109..0a92df7ff 100644 --- a/packages/coding-agent/src/discovery/github.ts +++ b/packages/coding-agent/src/discovery/github.ts @@ -12,12 +12,13 @@ * - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter */ import * as path from "node:path"; +import { parseFrontmatter } from "@oh-my-pi/pi-utils"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { readFile } from "../capability/fs"; import { type Instruction, instructionCapability } from "../capability/instruction"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; -import { parseFrontmatter } from "../utils/frontmatter"; + import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers"; const PROVIDER_ID = "github"; diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 0cd8c6d0f..124a6febb 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -3,13 +3,13 @@ import * as os from "node:os"; import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { FileType, glob } from "@oh-my-pi/pi-natives"; -import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, tryParseJson } from "@oh-my-pi/pi-utils"; +import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils"; import { readDirEntries, readFile } from "../capability/fs"; import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule"; import type { Skill, SkillFrontmatter } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { parseThinkingLevel } from "../thinking"; -import { parseFrontmatter } from "../utils/frontmatter"; + import { buildPluginDirRoot } from "./plugin-dir-roots"; /** diff --git a/packages/coding-agent/src/discovery/opencode.ts b/packages/coding-agent/src/discovery/opencode.ts index dc490712a..347dd45b4 100644 --- a/packages/coding-agent/src/discovery/opencode.ts +++ b/packages/coding-agent/src/discovery/opencode.ts @@ -16,7 +16,7 @@ * Priority: 55 (tool-specific provider) */ import * as path from "node:path"; -import { logger, tryParseJson } from "@oh-my-pi/pi-utils"; +import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; @@ -26,7 +26,7 @@ import { type Settings, settingsCapability } from "../capability/settings"; import { type Skill, skillCapability } from "../capability/skill"; import { type SlashCommand, slashCommandCapability } from "../capability/slash-command"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; -import { parseFrontmatter } from "../utils/frontmatter"; + import { createSourceMeta, discoverExtensionModulePaths, diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index ac503711d..daf8a6401 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -1,5 +1,5 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { renderPromptTemplate } from "../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import { createLspWritethrough, type FileDiagnosticsResult, @@ -194,7 +194,7 @@ export class EditTool implements AgentTool { return { chunk: { description: (session: ToolSession) => - renderPromptTemplate(chunkEditDescription, { + prompt.render(chunkEditDescription, { anchorStyle: resolveAnchorStyle(session.settings), }), parameters: chunkEditParamsSchema, @@ -212,7 +212,7 @@ export class EditTool implements AgentTool { }, }, patch: { - description: () => renderPromptTemplate(patchDescription), + description: () => prompt.render(patchDescription), parameters: patchEditSchema, invalidParamsMessage: "Invalid edit parameters for patch mode.", validate: isPatchParams, @@ -230,7 +230,7 @@ export class EditTool implements AgentTool { }, }, hashline: { - description: () => renderPromptTemplate(hashlineDescription), + description: () => prompt.render(hashlineDescription), parameters: hashlineEditParamsSchema, invalidParamsMessage: "Invalid edit parameters for hashline mode.", validate: isHashlineParams, @@ -246,7 +246,7 @@ export class EditTool implements AgentTool { }, }, replace: { - description: () => renderPromptTemplate(replaceDescription), + description: () => prompt.render(replaceDescription), parameters: replaceEditSchema, invalidParamsMessage: "Invalid edit parameters for replace mode.", validate: isReplaceParams, diff --git a/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts b/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts index 80319f80b..a4032b792 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/bundled/ci-green/index.ts @@ -1,4 +1,4 @@ -import { renderPromptTemplate } from "../../../../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../../extensibility/hooks/types"; import ciGreenRequestTemplate from "../../../../prompts/ci-green-request.md" with { type: "text" }; @@ -20,6 +20,6 @@ export class GreenCommand implements CustomCommand { async execute(_args: string[], _ctx: HookCommandContext): Promise { const headTag = await getHeadTag(this.api); - return renderPromptTemplate(ciGreenRequestTemplate, { headTag }); + return prompt.render(ciGreenRequestTemplate, { headTag }); } } diff --git a/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts b/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts index 2a0619e50..cf10e872c 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/bundled/review/index.ts @@ -11,7 +11,7 @@ * rich context for the orchestrating agent to distribute work across * multiple reviewer agents based on diff weight and locality. */ -import { renderPromptTemplate } from "../../../../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../../extensibility/hooks/types"; import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" }; @@ -209,7 +209,7 @@ function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string): str hunksPreview: skipDiff ? getDiffPreview(f.hunks, linesPerFile) : "", })); - return renderPromptTemplate(reviewRequestTemplate, { + return prompt.render(reviewRequestTemplate, { mode, files: filesWithExt, excluded: stats.excluded, diff --git a/packages/coding-agent/src/extensibility/slash-commands.ts b/packages/coding-agent/src/extensibility/slash-commands.ts index 4b8bc1f62..d766830e4 100644 --- a/packages/coding-agent/src/extensibility/slash-commands.ts +++ b/packages/coding-agent/src/extensibility/slash-commands.ts @@ -1,10 +1,7 @@ import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { parseFrontmatter, prompt } from "@oh-my-pi/pi-utils"; import { slashCommandCapability } from "../capability/slash-command"; -import { - appendInlineArgsFallback, - renderPromptTemplate, - templateUsesInlineArgPlaceholders, -} from "../config/prompt-templates"; +import { appendInlineArgsFallback, templateUsesInlineArgPlaceholders } from "../config/prompt-templates"; import type { SlashCommand } from "../discovery"; import { loadCapability } from "../discovery"; import { @@ -14,7 +11,6 @@ import { } from "../slash-commands/builtin-registry"; import { EMBEDDED_COMMAND_TEMPLATES } from "../task/commands"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; -import { parseFrontmatter } from "../utils/frontmatter"; export type SlashCommandSource = "extension" | "prompt" | "skill"; @@ -223,7 +219,7 @@ export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[ const argsText = args.join(" "); const usesInlineArgPlaceholders = templateUsesInlineArgPlaceholders(fileCommand.content); const substituted = substituteArgs(fileCommand.content, args); - const rendered = renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText }); + const rendered = prompt.render(substituted, { args, ARGUMENTS: argsText, arguments: argsText }); return appendInlineArgsFallback(rendered, argsText, usesInlineArgPlaceholders); } diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 250e44b93..6440d2f9d 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -1,9 +1,8 @@ import * as fs from "node:fs"; import path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { logger, once, untilAborted } from "@oh-my-pi/pi-utils"; +import { logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import type { BunFile } from "bun"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { type Theme, theme } from "../modes/theme/theme"; import lspDescription from "../prompts/tools/lsp.md" with { type: "text" }; import type { ToolSession } from "../tools"; @@ -1088,7 +1087,7 @@ export class LspTool implements AgentTool { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b9005083e..4fcc0b64a 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -19,6 +19,7 @@ import { getSearchDbDir, logger, postmortem, + prompt, } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { AsyncJobManager } from "./async"; @@ -27,11 +28,7 @@ import { loadCapability } from "./capability"; import { type Rule, ruleCapability } from "./capability/rule"; import { ModelRegistry } from "./config/model-registry"; import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver"; -import { - loadPromptTemplates as loadPromptTemplatesInternal, - type PromptTemplate, - renderPromptTemplate, -} from "./config/prompt-templates"; +import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates"; import { Settings, type SkillsSettings } from "./config/settings"; import { CursorExecHandlers } from "./cursor"; import "./discovery"; @@ -872,7 +869,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} onJobComplete: async (jobId, result, job) => { if (!session) return; const formattedResult = await formatAsyncResultForFollowUp(result); - const message = renderPromptTemplate(asyncResultTemplate, { jobId, result: formattedResult }); + const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult }); const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined; await session.sendCustomMessage( { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ba6475637..96231fca5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -50,7 +50,7 @@ import { parseRateLimitReason, } from "@oh-my-pi/pi-ai"; import { killTree, MacOSPowerAssertion, type SearchDb } from "@oh-my-pi/pi-natives"; -import { abortableSleep, getAgentDbPath, isEnoent, logger, setNativeKillTree } from "@oh-my-pi/pi-utils"; +import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, setNativeKillTree } from "@oh-my-pi/pi-utils"; import type { AsyncJob, AsyncJobManager } from "../async"; import type { Rule } from "../capability/rule"; import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry"; @@ -61,7 +61,7 @@ import { type ResolvedModelRoleValue, resolveModelRoleValue, } from "../config/model-resolver"; -import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates"; +import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor"; @@ -317,7 +317,7 @@ interface HandoffOptions { /** Standard thinking levels */ -const AUTO_HANDOFF_THRESHOLD_FOCUS = renderPromptTemplate(autoHandoffThresholdFocusPrompt); +const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt); type RetryFallbackChains = Record; @@ -1139,7 +1139,7 @@ export class AgentSession { if (this.#pendingTtsrInjections.length === 0) return undefined; const rules = this.#pendingTtsrInjections; const content = rules - .map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content })) + .map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content })) .join("\n\n"); this.#pendingTtsrInjections = []; return { content, rules }; @@ -2210,7 +2210,7 @@ export class AgentSession { throw error; } - const content = renderPromptTemplate(planModeReferencePrompt, { + const content = prompt.render(planModeReferencePrompt, { planFilePath, planContent, }); @@ -2247,7 +2247,7 @@ export class AgentSession { : sessionPlanUrl; const planExists = fs.existsSync(resolvedPlanPath); - const content = renderPromptTemplate(planModeActivePrompt, { + const content = prompt.render(planModeActivePrompt, { planFilePath: displayPlanPath, planExists, askToolName: "ask", @@ -3773,7 +3773,7 @@ export class AgentSession { } // Build the handoff prompt - const handoffPrompt = renderPromptTemplate(handoffDocumentPrompt, { + const handoffPrompt = prompt.render(handoffDocumentPrompt, { additionalFocus: customInstructions, }); @@ -4041,7 +4041,7 @@ export class AgentSession { return; } - const reminder = renderPromptTemplate(planModeToolDecisionReminderPrompt, { + const reminder = prompt.render(planModeToolDecisionReminderPrompt, { askToolName: "ask", exitToolName: "exit_plan_mode", }); @@ -4088,7 +4088,7 @@ export class AgentSession { return undefined; } - const eagerTodoReminder = renderPromptTemplate(eagerTodoPrompt); + const eagerTodoReminder = prompt.render(eagerTodoPrompt); return { message: { diff --git a/packages/coding-agent/src/session/compaction/branch-summarization.ts b/packages/coding-agent/src/session/compaction/branch-summarization.ts index 6bcf86485..b590d3ac6 100644 --- a/packages/coding-agent/src/session/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/session/compaction/branch-summarization.ts @@ -7,7 +7,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; -import { renderPromptTemplate } from "../../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import branchSummaryPrompt from "../../prompts/compaction/branch-summary.md" with { type: "text" }; import branchSummaryPreamble from "../../prompts/compaction/branch-summary-preamble.md" with { type: "text" }; import { @@ -244,9 +244,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // Summary Generation // ============================================================================ -const BRANCH_SUMMARY_PREAMBLE = renderPromptTemplate(branchSummaryPreamble); +const BRANCH_SUMMARY_PREAMBLE = prompt.render(branchSummaryPreamble); -const BRANCH_SUMMARY_PROMPT = renderPromptTemplate(branchSummaryPrompt); +const BRANCH_SUMMARY_PROMPT = prompt.render(branchSummaryPrompt); /** * Generate a summary of abandoned branch entries. diff --git a/packages/coding-agent/src/session/compaction/compaction.ts b/packages/coding-agent/src/session/compaction/compaction.ts index 12426efe6..6fff33aca 100644 --- a/packages/coding-agent/src/session/compaction/compaction.ts +++ b/packages/coding-agent/src/session/compaction/compaction.ts @@ -26,8 +26,7 @@ import { getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, } from "@oh-my-pi/pi-ai/utils"; -import { logger } from "@oh-my-pi/pi-utils"; -import { renderPromptTemplate } from "../../config/prompt-templates"; +import { logger, prompt } from "@oh-my-pi/pi-utils"; import compactionShortSummaryPrompt from "../../prompts/compaction/compaction-short-summary.md" with { type: "text" }; import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" }; import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" }; @@ -474,11 +473,11 @@ export function findCutPoint( // Summarization // ============================================================================ -const SUMMARIZATION_PROMPT = renderPromptTemplate(compactionSummaryPrompt); +const SUMMARIZATION_PROMPT = prompt.render(compactionSummaryPrompt); -const UPDATE_SUMMARIZATION_PROMPT = renderPromptTemplate(compactionUpdateSummaryPrompt); +const UPDATE_SUMMARIZATION_PROMPT = prompt.render(compactionUpdateSummaryPrompt); -const SHORT_SUMMARY_PROMPT = renderPromptTemplate(compactionShortSummaryPrompt); +const SHORT_SUMMARY_PROMPT = prompt.render(compactionShortSummaryPrompt); function formatAdditionalContext(context: string[] | undefined): string { if (!context || context.length === 0) return ""; @@ -1201,7 +1200,7 @@ export function prepareCompaction( // Main compaction function // ============================================================================ -const TURN_PREFIX_SUMMARIZATION_PROMPT = renderPromptTemplate(compactionTurnPrefixPrompt); +const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt); /** * Generate summaries for compaction using prepared data. diff --git a/packages/coding-agent/src/session/compaction/utils.ts b/packages/coding-agent/src/session/compaction/utils.ts index c1b80970f..7da8de00b 100644 --- a/packages/coding-agent/src/session/compaction/utils.ts +++ b/packages/coding-agent/src/session/compaction/utils.ts @@ -3,7 +3,7 @@ */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { Message } from "@oh-my-pi/pi-ai"; -import { renderPromptTemplate } from "../../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import fileOperationsTemplate from "../../prompts/system/file-operations.md" with { type: "text" }; import summarizationSystemPrompt from "../../prompts/system/summarization-system.md" with { type: "text" }; @@ -86,7 +86,7 @@ function stripFileOperationTags(summary: string): string { } export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { if (readFiles.length === 0 && modifiedFiles.length === 0) return ""; - return renderPromptTemplate(fileOperationsTemplate, { + return prompt.render(fileOperationsTemplate, { readFiles: truncateFileList(readFiles), modifiedFiles: truncateFileList(modifiedFiles), }); @@ -181,4 +181,4 @@ export function serializeConversation(messages: Message[]): string { // Summarization System Prompt // ============================================================================ -export const SUMMARIZATION_SYSTEM_PROMPT = renderPromptTemplate(summarizationSystemPrompt); +export const SUMMARIZATION_SYSTEM_PROMPT = prompt.render(summarizationSystemPrompt); diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index e4bd48f43..9f48e4d2a 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -14,7 +14,7 @@ import type { TextContent, ToolResultMessage, } from "@oh-my-pi/pi-ai"; -import { renderPromptTemplate } from "../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import branchSummaryContextPrompt from "../prompts/compaction/branch-summary-context.md" with { type: "text" }; import compactionSummaryContextPrompt from "../prompts/compaction/compaction-summary-context.md" with { type: "text" }; import type { OutputMeta } from "../tools/output-meta"; @@ -310,7 +310,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { content: [ { type: "text" as const, - text: renderPromptTemplate(BRANCH_SUMMARY_TEMPLATE, { summary: m.summary }), + text: prompt.render(BRANCH_SUMMARY_TEMPLATE, { summary: m.summary }), }, ], attribution: "agent", @@ -322,7 +322,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { content: [ { type: "text" as const, - text: renderPromptTemplate(COMPACTION_SUMMARY_TEMPLATE, { summary: m.summary }), + text: prompt.render(COMPACTION_SUMMARY_TEMPLATE, { summary: m.summary }), }, ], attribution: "agent", diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 0c1d5e07c..01d8961f4 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -6,17 +6,15 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { contextFileCapability } from "./capability/context-file"; import { systemPromptCapability } from "./capability/system-prompt"; -import { renderPromptTemplate } from "./config/prompt-templates"; import type { SkillsSettings } from "./config/settings"; import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery"; import { loadSkills, type Skill } from "./extensibility/skills"; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" }; -import { formatPromptContent } from "./utils/prompt-format"; interface AlwaysApplyRule { name: string; @@ -25,7 +23,7 @@ interface AlwaysApplyRule { } function normalizePromptBlock(content: string): string { - return formatPromptContent(content, { renderPhase: "post-render" }).trim(); + return prompt.format(content, { renderPhase: "post-render" }).trim(); } function splitComparablePromptBlocks(content: string | null | undefined): string[] { @@ -608,5 +606,5 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): eagerTasks, secretsEnabled, }; - return renderPromptTemplate(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); + return prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); } diff --git a/packages/coding-agent/src/task/agents.ts b/packages/coding-agent/src/task/agents.ts index fe4302e7d..8925e10b3 100644 --- a/packages/coding-agent/src/task/agents.ts +++ b/packages/coding-agent/src/task/agents.ts @@ -4,7 +4,7 @@ * Agents are embedded at build time via Bun's import with { type: "text" }. */ import { Effort } from "@oh-my-pi/pi-ai"; -import { renderPromptTemplate } from "../config/prompt-templates"; +import { parseFrontmatter, prompt } from "@oh-my-pi/pi-utils"; import { parseAgentFields } from "../discovery/helpers"; import designerMd from "../prompts/agents/designer.md" with { type: "text" }; import exploreMd from "../prompts/agents/explore.md" with { type: "text" }; @@ -15,7 +15,7 @@ import oracleMd from "../prompts/agents/oracle.md" with { type: "text" }; import planMd from "../prompts/agents/plan.md" with { type: "text" }; import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" }; import taskMd from "../prompts/agents/task.md" with { type: "text" }; -import { parseFrontmatter } from "../utils/frontmatter"; + import type { AgentDefinition, AgentSource } from "./types"; interface AgentFrontmatter { @@ -35,9 +35,9 @@ interface EmbeddedAgentDef { } function buildAgentContent(def: EmbeddedAgentDef): string { - const body = renderPromptTemplate(def.template); + const body = prompt.render(def.template); if (!def.frontmatter) return body; - return renderPromptTemplate(agentFrontmatterTemplate, { ...def.frontmatter, body }); + return prompt.render(agentFrontmatterTemplate, { ...def.frontmatter, body }); } const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [ diff --git a/packages/coding-agent/src/task/commands.ts b/packages/coding-agent/src/task/commands.ts index 0a23bfff7..3d61ece2c 100644 --- a/packages/coding-agent/src/task/commands.ts +++ b/packages/coding-agent/src/task/commands.ts @@ -4,16 +4,13 @@ * Commands are embedded at build time via Bun's import with { type: "text" }. */ import * as path from "node:path"; +import { parseFrontmatter, prompt } from "@oh-my-pi/pi-utils"; import { type SlashCommand, slashCommandCapability } from "../capability/slash-command"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { loadCapability } from "../discovery"; // Embed command markdown files at build time import initMd from "../prompts/agents/init.md" with { type: "text" }; -import { parseFrontmatter } from "../utils/frontmatter"; -const EMBEDDED_COMMANDS: { name: string; content: string }[] = [ - { name: "init.md", content: renderPromptTemplate(initMd) }, -]; +const EMBEDDED_COMMANDS: { name: string; content: string }[] = [{ name: "init.md", content: prompt.render(initMd) }]; export const EMBEDDED_COMMAND_TEMPLATES: ReadonlyArray<{ name: string; content: string }> = EMBEDDED_COMMANDS; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 2fb6f562d..befdd581f 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -6,12 +6,12 @@ import path from "node:path"; import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { SearchDb } from "@oh-my-pi/pi-natives"; -import { logger, untilAborted } from "@oh-my-pi/pi-utils"; +import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import Ajv, { type ValidateFunction } from "ajv"; import { ModelRegistry } from "../config/model-registry"; import { resolveModelOverride } from "../config/model-resolver"; -import { type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates"; +import type { PromptTemplate } from "../config/prompt-templates"; import { Settings } from "../config/settings"; import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema"; import type { CustomTool } from "../extensibility/custom-tools/types"; @@ -965,7 +965,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise - renderPromptTemplate(subagentSystemPromptTemplate, { + prompt.render(subagentSystemPromptTemplate, { base: defaultPrompt, agent: agent.systemPrompt, worktree: worktree ?? "", @@ -1106,7 +1106,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents; - return renderPromptTemplate(taskDescriptionTemplate, { + return prompt.render(taskDescriptionTemplate, { agents: filteredAgents, MAX_CONCURRENCY: maxConcurrency, isolationEnabled, @@ -1157,7 +1156,7 @@ export class TaskTool implements AgentTool { const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`); const backendSummaryPrefix = isolationBackendWarning ? `\n\n${isolationBackendWarning}` : ""; - const summary = renderPromptTemplate(taskSummaryTemplate, { + const summary = prompt.render(taskSummaryTemplate, { successCount, totalCount: results.length, cancelledCount, diff --git a/packages/coding-agent/src/task/template.ts b/packages/coding-agent/src/task/template.ts index 7e9ec2629..edfaddc03 100644 --- a/packages/coding-agent/src/task/template.ts +++ b/packages/coding-agent/src/task/template.ts @@ -1,4 +1,4 @@ -import { renderPromptTemplate } from "../config/prompt-templates"; +import { prompt } from "@oh-my-pi/pi-utils"; import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" }; import type { TaskItem } from "./types"; @@ -25,7 +25,7 @@ export function renderTemplate(context: string | undefined, task: TaskItem): Ren return { task: assignment || context!, assignment: assignment || context!, id, description }; } return { - task: renderPromptTemplate(subagentUserPromptTemplate, { context, assignment }), + task: prompt.render(subagentUserPromptTemplate, { context, assignment }), assignment, id, description, diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index c353a0e65..33821bde3 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -17,9 +17,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; -import { untilAborted } from "@oh-my-pi/pi-utils"; +import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; import askDescription from "../prompts/tools/ask.md" with { type: "text" }; @@ -385,7 +384,7 @@ export class AskTool implements AgentTool { readonly strict = true; constructor(private readonly session: ToolSession) { - this.description = renderPromptTemplate(askDescription); + this.description = prompt.render(askDescription); } static createIf(session: ToolSession): AskTool | null { diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 8feb3bc71..207f56cca 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -3,9 +3,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { untilAborted } from "@oh-my-pi/pi-utils"; +import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; @@ -71,7 +70,7 @@ export class AstEditTool implements AgentTool { constructor(private readonly session: ToolSession) { this.#asyncEnabled = this.session.settings.get("async.enabled"); this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase; - this.description = renderPromptTemplate(bashDescription, { + this.description = prompt.render(bashDescription, { asyncEnabled: this.#asyncEnabled, hasAstGrep: this.session.settings.get("astGrep.enabled"), hasAstEdit: this.session.settings.get("astEdit.enabled"), diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 47f896904..6469d965e 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { Readability } from "@mozilla/readability"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; -import { $which, getPuppeteerDir, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; +import { $which, getPuppeteerDir, logger, prompt, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { type HTMLElement, parseHTML } from "linkedom"; import type { @@ -16,7 +16,6 @@ import type { default as Puppeteer, SerializedAXNode, } from "puppeteer"; -import { renderPromptTemplate } from "../config/prompt-templates"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { resizeImage } from "../utils/image-resize"; @@ -539,7 +538,7 @@ export class BrowserTool implements AgentTool(); constructor(private readonly session: ToolSession) { - this.description = renderPromptTemplate(browserDescription, {}); + this.description = prompt.render(browserDescription, {}); } async #closeBrowser(): Promise { diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 6c6335769..b74e2723a 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -1,9 +1,8 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { untilAborted } from "@oh-my-pi/pi-utils"; +import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" }; @@ -402,7 +401,7 @@ export class CalculatorTool implements AgentTool { options?: FindToolOptions, ) { this.#customOps = options?.operations; - this.description = renderPromptTemplate(findDescription); + this.description = prompt.render(findDescription); } async execute( diff --git a/packages/coding-agent/src/tools/gemini-image.ts b/packages/coding-agent/src/tools/gemini-image.ts index 2be33254e..46780d3cf 100644 --- a/packages/coding-agent/src/tools/gemini-image.ts +++ b/packages/coding-agent/src/tools/gemini-image.ts @@ -1,10 +1,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { getAntigravityHeaders, getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai"; -import { $env, isEnoent, ptree, readSseJson, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; +import { $env, isEnoent, prompt, ptree, readSseJson, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { ModelRegistry } from "../config/model-registry"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { CustomTool } from "../extensibility/custom-tools/types"; import geminiImageDescription from "../prompts/tools/gemini-image.md" with { type: "text" }; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime"; @@ -599,7 +598,7 @@ async function parseAntigravitySseForImage(response: Response, signal?: AbortSig export const geminiImageTool: CustomTool = { name: "generate_image", label: "GenerateImage", - description: renderPromptTemplate(geminiImageDescription), + description: prompt.render(geminiImageDescription), parameters: geminiImageSchema, async execute(_toolCallId, params, _onUpdate, ctx, signal) { return untilAborted(signal, async () => { diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 16bd45ca2..1d6cba6f5 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -1,9 +1,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { abortableSleep, isEnoent, untilAborted } from "@oh-my-pi/pi-utils"; +import { abortableSleep, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import ghIssueViewDescription from "../prompts/tools/gh-issue-view.md" with { type: "text" }; import ghPrCheckoutDescription from "../prompts/tools/gh-pr-checkout.md" with { type: "text" }; import ghPrDiffDescription from "../prompts/tools/gh-pr-diff.md" with { type: "text" }; @@ -1902,7 +1901,7 @@ function buildTextResult( export class GhRepoViewTool implements AgentTool { readonly name = "gh_repo_view"; readonly label = "GitHub Repo"; - readonly description = renderPromptTemplate(ghRepoViewDescription); + readonly description = prompt.render(ghRepoViewDescription); readonly parameters = ghRepoViewSchema; readonly strict = true; @@ -1943,7 +1942,7 @@ export class GhRepoViewTool implements AgentTool { readonly name = "gh_issue_view"; readonly label = "GitHub Issue"; - readonly description = renderPromptTemplate(ghIssueViewDescription); + readonly description = prompt.render(ghIssueViewDescription); readonly parameters = ghIssueViewSchema; readonly strict = true; @@ -1980,7 +1979,7 @@ export class GhIssueViewTool implements AgentTool { readonly name = "gh_pr_view"; readonly label = "GitHub PR"; - readonly description = renderPromptTemplate(ghPrViewDescription); + readonly description = prompt.render(ghPrViewDescription); readonly parameters = ghPrViewSchema; readonly strict = true; @@ -2024,7 +2023,7 @@ export class GhPrViewTool implements AgentTool { readonly name = "gh_pr_diff"; readonly label = "GitHub PR Diff"; - readonly description = renderPromptTemplate(ghPrDiffDescription); + readonly description = prompt.render(ghPrDiffDescription); readonly parameters = ghPrDiffSchema; readonly strict = true; @@ -2073,7 +2072,7 @@ export class GhPrDiffTool implements AgentTool { readonly name = "gh_pr_checkout"; readonly label = "GitHub PR Checkout"; - readonly description = renderPromptTemplate(ghPrCheckoutDescription); + readonly description = prompt.render(ghPrCheckoutDescription); readonly parameters = ghPrCheckoutSchema; readonly strict = true; @@ -2204,7 +2203,7 @@ export class GhPrCheckoutTool implements AgentTool { readonly name = "gh_pr_push"; readonly label = "GitHub PR Push"; - readonly description = renderPromptTemplate(ghPrPushDescription); + readonly description = prompt.render(ghPrPushDescription); readonly parameters = ghPrPushSchema; readonly strict = true; @@ -2265,7 +2264,7 @@ export class GhPrPushTool implements AgentTool { readonly name = "gh_search_issues"; readonly label = "GitHub Issue Search"; - readonly description = renderPromptTemplate(ghSearchIssuesDescription); + readonly description = prompt.render(ghSearchIssuesDescription); readonly parameters = ghSearchIssuesSchema; readonly strict = true; @@ -2300,7 +2299,7 @@ export class GhSearchIssuesTool implements AgentTool { readonly name = "gh_search_prs"; readonly label = "GitHub PR Search"; - readonly description = renderPromptTemplate(ghSearchPrsDescription); + readonly description = prompt.render(ghSearchPrsDescription); readonly parameters = ghSearchPrsSchema; readonly strict = true; @@ -2335,7 +2334,7 @@ export class GhSearchPrsTool implements AgentTool { readonly name = "gh_run_watch"; readonly label = "GitHub Run Watch"; - readonly description = renderPromptTemplate(ghRunWatchDescription); + readonly description = prompt.render(ghRunWatchDescription); readonly parameters = ghRunWatchSchema; readonly strict = true; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 950cffe7a..2fd3af7e4 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -4,9 +4,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { untilAborted } from "@oh-my-pi/pi-utils"; +import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { formatChunkedGrepLine } from "../edit/modes/chunk"; import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -74,7 +73,7 @@ export class GrepTool implements AgentTool { constructor(private readonly session: ToolSession) { const displayMode = resolveFileDisplayMode(session); - this.description = renderPromptTemplate(grepDescription, { + this.description = prompt.render(grepDescription, { IS_HASHLINE_MODE: displayMode.hashLines, IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, IS_CHUNK_MODE: displayMode.chunked, diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index c85604cc8..7c0325783 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,8 +1,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; -import { renderPromptTemplate } from "../config/prompt-templates"; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" }; import { @@ -49,7 +49,7 @@ export class InspectImageTool implements AgentTool { this.#inspectImageEnabled = session.settings.get("inspect_image.enabled"); this.description = resolveEditMode(session) === "chunk" - ? renderPromptTemplate(readChunkDescription, { + ? prompt.render(readChunkDescription, { anchorStyle: resolveAnchorStyle(session.settings), }) - : renderPromptTemplate(readDescription, { + : prompt.render(readDescription, { DEFAULT_LIMIT: String(this.#defaultLimit), DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES), IS_HASHLINE_MODE: displayMode.hashLines, diff --git a/packages/coding-agent/src/tools/render-mermaid.ts b/packages/coding-agent/src/tools/render-mermaid.ts index d89c07b9d..36a8195ba 100644 --- a/packages/coding-agent/src/tools/render-mermaid.ts +++ b/packages/coding-agent/src/tools/render-mermaid.ts @@ -1,7 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type MermaidAsciiRenderOptions, renderMermaidAscii } from "@oh-my-pi/pi-utils"; +import { type MermaidAsciiRenderOptions, prompt, renderMermaidAscii } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" }; import type { ToolSession } from "./index"; @@ -41,7 +40,7 @@ export class RenderMermaidTool implements AgentTool 0, diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index bd78b583a..cf54b26c7 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -1,10 +1,10 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { SSHHost } from "../capability/ssh"; import { sshCapability } from "../capability/ssh"; -import { renderPromptTemplate } from "../config/prompt-templates"; import { loadCapability } from "../discovery"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; @@ -59,7 +59,7 @@ async function formatHostEntry(host: SSHHost): Promise { } async function formatDescription(hosts: SSHHost[]): Promise { - const baseDescription = renderPromptTemplate(sshDescriptionBase); + const baseDescription = prompt.render(sshDescriptionBase); if (hosts.length === 0) { return baseDescription; } diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index 9b2bb6a0b..e57bdd8e5 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -2,9 +2,9 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { StringEnum } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import chalk from "chalk"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" }; @@ -347,7 +347,7 @@ export class TodoWriteTool implements AgentTool { diff --git a/packages/coding-agent/src/utils/commit-message-generator.ts b/packages/coding-agent/src/utils/commit-message-generator.ts index 13bb99df2..92019c1f4 100644 --- a/packages/coding-agent/src/utils/commit-message-generator.ts +++ b/packages/coding-agent/src/utils/commit-message-generator.ts @@ -5,16 +5,15 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { resolveModelRoleValue } from "../config/model-resolver"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { Settings } from "../config/settings"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" }; import { toReasoningEffort } from "../thinking"; -const COMMIT_SYSTEM_PROMPT = renderPromptTemplate(commitSystemPrompt); +const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt); const MAX_DIFF_CHARS = 4000; /** File patterns that should be excluded from commit message generation diffs. */ diff --git a/packages/coding-agent/src/utils/prompt-format.ts b/packages/coding-agent/src/utils/prompt-format.ts deleted file mode 100644 index a4476d1a1..000000000 --- a/packages/coding-agent/src/utils/prompt-format.ts +++ /dev/null @@ -1,170 +0,0 @@ -export type PromptRenderPhase = "pre-render" | "post-render"; - -export interface PromptFormatOptions { - renderPhase?: PromptRenderPhase; - replaceAsciiSymbols?: boolean; - boldRfc2119Keywords?: boolean; -} - -// Opening XML tag (not self-closing, not closing) -const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/; -// Closing XML tag -const CLOSING_XML = /^<\/([a-z_-]+)>$/; -// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc. -const OPENING_HBS = /^\{\{#/; -// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc. -const CLOSING_HBS = /^\{\{\//; -// List item (- or * or 1.) -const LIST_ITEM = /^(?:[-*]\s|\d+\.\s)/; -// Code fence -const CODE_FENCE = /^```/; -// Table row -const TABLE_ROW = /^\|.*\|$/; -// Table separator (|---|---|) -const TABLE_SEP = /^\|[-:\s|]+\|$/; - -/** RFC 2119 keywords used in prompts. */ -const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g; - -function boldRfc2119Keywords(line: string): string { - return line.replace(RFC2119_KEYWORDS, (match, offset, source) => { - const isAlreadyBold = - source[offset - 2] === "*" && - source[offset - 1] === "*" && - source[offset + match.length] === "*" && - source[offset + match.length + 1] === "*"; - if (isAlreadyBold) { - return match; - } - return `**${match}**`; - }); -} - -/** Compact a table row by trimming cell padding */ -function compactTableRow(line: string): string { - const cells = line.split("|"); - return cells.map(c => c.trim()).join("|"); -} - -/** Compact a table separator row */ -function compactTableSep(line: string): string { - const cells = line.split("|").filter(c => c.trim()); - const normalized = cells.map(c => { - const trimmed = c.trim(); - const left = trimmed.startsWith(":"); - const right = trimmed.endsWith(":"); - if (left && right) return ":---:"; - if (left) return ":---"; - if (right) return "---:"; - return "---"; - }); - return `|${normalized.join("|")}|`; -} - -function replaceCommonAsciiSymbols(line: string): string { - return line - .replace(/\.{3}/g, "…") - .replace(/<->/g, "↔") - .replace(/->/g, "→") - .replace(/<-/g, "←") - .replace(/!=/g, "≠") - .replace(/<=/g, "≤") - .replace(/>=/g, "≥"); -} - -export function formatPromptContent(content: string, options: PromptFormatOptions = {}): string { - const { - renderPhase = "post-render", - replaceAsciiSymbols = false, - boldRfc2119Keywords: shouldBoldRfc2119 = false, - } = options; - const isPreRender = renderPhase === "pre-render"; - const lines = content.split("\n"); - const result: string[] = []; - let inCodeBlock = false; - const topLevelTags: string[] = []; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i].trimEnd(); - let trimmedStart = line.trimStart(); - if (CODE_FENCE.test(trimmedStart)) { - inCodeBlock = !inCodeBlock; - result.push(line); - continue; - } - - if (inCodeBlock) { - result.push(line); - continue; - } - - if (replaceAsciiSymbols) { - line = replaceCommonAsciiSymbols(line); - } - trimmedStart = line.trimStart(); - const trimmed = line.trim(); - - const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>"); - if (isOpeningXml && line.length === trimmedStart.length) { - const match = OPENING_XML.exec(trimmedStart); - if (match) topLevelTags.push(match[1]); - } - - const closingMatch = CLOSING_XML.exec(trimmedStart); - if (closingMatch) { - const tagName = closingMatch[1]; - if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) { - topLevelTags.pop(); - } - } else if (isPreRender && trimmedStart.startsWith("{{")) { - /* keep indentation as-is in pre-render for Handlebars markers */ - } else if (TABLE_SEP.test(trimmedStart)) { - const leadingWhitespace = line.slice(0, line.length - trimmedStart.length); - line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`; - } else if (TABLE_ROW.test(trimmedStart)) { - const leadingWhitespace = line.slice(0, line.length - trimmedStart.length); - line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`; - } - - if (shouldBoldRfc2119) { - line = boldRfc2119Keywords(line); - } - - const isBlank = trimmed === ""; - if (isBlank) { - const prevLine = result[result.length - 1]?.trim() ?? ""; - const nextLine = lines[i + 1]?.trim() ?? ""; - - if (LIST_ITEM.test(nextLine)) { - continue; - } - - if (OPENING_XML.test(prevLine) || (isPreRender && OPENING_HBS.test(prevLine))) { - continue; - } - - if (CLOSING_XML.test(nextLine) || (isPreRender && CLOSING_HBS.test(nextLine))) { - continue; - } - - const prevIsBlank = prevLine === ""; - if (prevIsBlank) { - continue; - } - } - - if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) { - while (result.length > 0 && result[result.length - 1].trim() === "") { - result.pop(); - } - } - - result.push(line); - } - - while (result.length > 0 && result[result.length - 1].trim() === "") { - result.pop(); - } - - return result.join("\n"); -} diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 43743ee64..f5c895fca 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -5,15 +5,14 @@ import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { resolveRoleSelection } from "../config/model-resolver"; -import { renderPromptTemplate } from "../config/prompt-templates"; import type { Settings } from "../config/settings"; import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" }; import { toReasoningEffort } from "../thinking"; -const TITLE_SYSTEM_PROMPT = renderPromptTemplate(titleSystemPrompt); +const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt); const DEFAULT_TERMINAL_TITLE = "π"; const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g; diff --git a/packages/coding-agent/src/web/scrapers/choosealicense.ts b/packages/coding-agent/src/web/scrapers/choosealicense.ts index ce3e64ba9..a89ec543c 100644 --- a/packages/coding-agent/src/web/scrapers/choosealicense.ts +++ b/packages/coding-agent/src/web/scrapers/choosealicense.ts @@ -1,4 +1,4 @@ -import { parseFrontmatter } from "../../utils/frontmatter"; +import { parseFrontmatter } from "@oh-my-pi/pi-utils"; import type { RenderResult, SpecialHandler } from "./types"; import { buildResult, loadPage } from "./types"; import { asString } from "./utils"; diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index ab6de7493..b31d34452 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -7,8 +7,8 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../../config/prompt-templates"; import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" }; @@ -213,7 +213,7 @@ export class SearchTool implements AgentTool = { name: "web_search", label: "Web Search", - description: renderPromptTemplate(webSearchDescription), + description: prompt.render(webSearchDescription), parameters: webSearchSchema, async execute( diff --git a/packages/coding-agent/test/discovery/helpers.test.ts b/packages/coding-agent/test/discovery/helpers.test.ts index 0659f5617..422bc57b3 100644 --- a/packages/coding-agent/test/discovery/helpers.test.ts +++ b/packages/coding-agent/test/discovery/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseFrontmatter } from "@oh-my-pi/pi-coding-agent/utils/frontmatter"; +import { parseFrontmatter } from "@oh-my-pi/pi-utils"; describe("parseFrontmatter", () => { const parse = (content: string) => parseFrontmatter(content, { source: "tests:frontmatter", level: "off" }); diff --git a/packages/coding-agent/test/plan-mode/plan-mode-approved-prompt.test.ts b/packages/coding-agent/test/plan-mode/plan-mode-approved-prompt.test.ts index d4a50c24f..e4a5d8778 100644 --- a/packages/coding-agent/test/plan-mode/plan-mode-approved-prompt.test.ts +++ b/packages/coding-agent/test/plan-mode/plan-mode-approved-prompt.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates"; import planModeApprovedPrompt from "@oh-my-pi/pi-coding-agent/prompts/system/plan-mode-approved.md" with { type: "text", }; +import { prompt } from "@oh-my-pi/pi-utils"; describe("plan-mode-approved prompt", () => { it("includes final plan artifact path in injected execution prompt", () => { - const rendered = renderPromptTemplate(planModeApprovedPrompt, { + const rendered = prompt.render(planModeApprovedPrompt, { planContent: "1. Do work", finalPlanFilePath: "local://WP_MIGRATION_PLAN.md", }); diff --git a/packages/coding-agent/test/prompt-format.test.ts b/packages/coding-agent/test/prompt-format.test.ts index 13dba1969..65bc1548f 100644 --- a/packages/coding-agent/test/prompt-format.test.ts +++ b/packages/coding-agent/test/prompt-format.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { formatPromptContent } from "@oh-my-pi/pi-coding-agent/utils/prompt-format"; +import { prompt } from "@oh-my-pi/pi-utils"; -describe("formatPromptContent renderPhase", () => { +describe("prompt.format renderPhase", () => { test("pre-render preserves indentation on Handlebars block lines", () => { const input = "\n {{#if ok}}\n value\n {{/if}}\n"; - const output = formatPromptContent(input, { renderPhase: "pre-render" }); + const output = prompt.format(input, { renderPhase: "pre-render" }); expect(output).toBe("\n {{#if ok}}\n value\n {{/if}}\n"); }); @@ -13,7 +13,7 @@ describe("formatPromptContent renderPhase", () => { test("pre-render preserves leading tabs", () => { const input = "\t\n\t {{#if ok}}\n\t value\n\t {{/if}}\n"; - const output = formatPromptContent(input, { renderPhase: "pre-render" }); + const output = prompt.format(input, { renderPhase: "pre-render" }); expect(output).toBe(input); }); @@ -21,7 +21,7 @@ describe("formatPromptContent renderPhase", () => { test("pre-render trims trailing whitespace", () => { const input = "\t \n\t {{#if ok}}\t\n\t value \n\t {{/if}} \n"; - const output = formatPromptContent(input, { renderPhase: "pre-render" }); + const output = prompt.format(input, { renderPhase: "pre-render" }); expect(output).toBe("\t\n\t {{#if ok}}\n\t value\n\t {{/if}}\n"); }); @@ -29,7 +29,7 @@ describe("formatPromptContent renderPhase", () => { test("post-render mode preserves indentation on Handlebars-like lines", () => { const input = "\n {{#if ok}}\n value\n {{/if}}\n"; - const output = formatPromptContent(input, { renderPhase: "post-render" }); + const output = prompt.format(input, { renderPhase: "post-render" }); expect(output).toBe("\n {{#if ok}}\n value\n {{/if}}\n"); }); @@ -37,8 +37,8 @@ describe("formatPromptContent renderPhase", () => { test("pre-render removes blank line before closing Handlebars block while post-render keeps it", () => { const input = "\n{{#if ok}}\nvalue\n\n{{/if}}\n"; - const preRender = formatPromptContent(input, { renderPhase: "pre-render" }); - const postRender = formatPromptContent(input, { renderPhase: "post-render" }); + const preRender = prompt.format(input, { renderPhase: "pre-render" }); + const postRender = prompt.format(input, { renderPhase: "post-render" }); expect(preRender).toBe("\n{{#if ok}}\nvalue\n{{/if}}\n"); expect(postRender).toBe("\n{{#if ok}}\nvalue\n\n{{/if}}\n"); @@ -46,7 +46,7 @@ describe("formatPromptContent renderPhase", () => { test("pre-render compacts table rows and does not duplicate content when replacing ascii", () => { const input = '|`cat <<\'EOF\' > file`|`write(path="file", content="...")`|\n|`sed -i \'s/old/new/\' file`|`edit(path="file", edits=[...])`|'; - const output = formatPromptContent(input, { + const output = prompt.format(input, { renderPhase: "pre-render", replaceAsciiSymbols: true, }); diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index f7f079b7f..6fdc1760c 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { renderPromptTemplate, type TemplateContext } from "@oh-my-pi/pi-coding-agent/config/prompt-templates"; import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt"; +import { prompt } from "@oh-my-pi/pi-utils"; import Handlebars from "handlebars"; const baseGitContext = { @@ -16,7 +16,7 @@ const baseGitContext = { const systemPromptsDir = path.resolve(import.meta.dir, "../src/prompts/system"); -const baseRenderContext: TemplateContext = { +const baseRenderContext: prompt.TemplateContext = { TASK_TOOL_NAME: "task", ARGUMENTS: "alpha beta", agent: "You are a delegated worker", @@ -101,7 +101,7 @@ describe("system Handlebars prompt templates", () => { const templatePath = path.join(systemPromptsDir, "custom-system-prompt.md"); const template = await Bun.file(templatePath).text(); - const both = renderPromptTemplate(template, { + const both = prompt.render(template, { ...baseRenderContext, contextFiles: [{ path: "a.txt", content: "A" }], git: { ...baseGitContext, isRepo: true }, @@ -110,7 +110,7 @@ describe("system Handlebars prompt templates", () => { expect(both).toContain("## Context"); expect(both).toContain("## Version Control"); - const contextOnly = renderPromptTemplate(template, { + const contextOnly = prompt.render(template, { ...baseRenderContext, contextFiles: [{ path: "a.txt", content: "A" }], git: { isRepo: false }, @@ -119,7 +119,7 @@ describe("system Handlebars prompt templates", () => { expect(contextOnly).toContain("## Context"); expect(contextOnly).not.toContain("## Version Control"); - const gitOnly = renderPromptTemplate(template, { + const gitOnly = prompt.render(template, { ...baseRenderContext, contextFiles: [], git: { @@ -134,7 +134,7 @@ describe("system Handlebars prompt templates", () => { expect(gitOnly).not.toContain("## Context"); expect(gitOnly).toContain("## Version Control"); - const neither = renderPromptTemplate(template, { + const neither = prompt.render(template, { ...baseRenderContext, contextFiles: [], git: { isRepo: false }, @@ -149,7 +149,7 @@ describe("system Handlebars prompt templates", () => { const template = await Bun.file(templatePath).text(); const baseTools = baseRenderContext.tools as string[]; - const withInspectImage = renderPromptTemplate(template, { + const withInspectImage = prompt.render(template, { ...baseRenderContext, tools: [...baseTools, "inspect_image"], }); @@ -157,7 +157,7 @@ describe("system Handlebars prompt templates", () => { expect(withInspectImage).toContain("**MUST** use `inspect_image` over `read`"); expect(withInspectImage).toContain("Write a specific `question` for `inspect_image`"); - const withoutInspectImage = renderPromptTemplate(template, { + const withoutInspectImage = prompt.render(template, { ...baseRenderContext, tools: baseTools.filter((tool: string) => tool !== "inspect_image"), }); @@ -168,7 +168,7 @@ describe("system Handlebars prompt templates", () => { const templatePath = path.join(systemPromptsDir, "system-prompt.md"); const template = await Bun.file(templatePath).text(); - const rendered = renderPromptTemplate(template, { + const rendered = prompt.render(template, { ...baseRenderContext, mcpDiscoveryMode: true, hasMCPDiscoveryServers: true, diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index 058a0b44a..45c42c14d 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -10,8 +10,8 @@ import * as path from "node:path"; import type { AgentMessage, ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; -import { computeLineHash, formatSessionDumpText, RpcClient, renderPromptTemplate } from "@oh-my-pi/pi-coding-agent"; -import { Snowflake } from "@oh-my-pi/pi-utils"; +import { computeLineHash, formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent"; +import { prompt, Snowflake } from "@oh-my-pi/pi-utils"; import { diffLines } from "diff"; import { formatDirectory } from "./formatter"; import { discoverSharedInfra, InProcessClient, type SharedInfra } from "./in-process-client"; @@ -612,21 +612,21 @@ type BenchmarkPromptDelivery = { }; function buildBenchmarkSystemPrompt(params: { multiFile: boolean; config: BenchmarkConfig }): string { - return renderPromptTemplate(benchmarkSystemPrompt, { + return prompt.render(benchmarkSystemPrompt, { multiFile: params.multiFile, instructions: buildInstructions(params.config), }); } function buildInitialBenchmarkPrompt(params: { taskPrompt: string; guidedContext?: string | null }): string { - return renderPromptTemplate(benchmarkTaskPrompt, { + return prompt.render(benchmarkTaskPrompt, { task_prompt: params.taskPrompt, guided_context: params.guidedContext ?? undefined, }); } function buildRetryBenchmarkPrompt(params: { retryContext: string; guidedContext?: string | null }): string { - return renderPromptTemplate(benchmarkRetryPrompt, { + return prompt.render(benchmarkRetryPrompt, { retry_context: params.retryContext, guided_context: params.guidedContext ?? undefined, }); diff --git a/packages/utils/package.json b/packages/utils/package.json index e113c13cb..17b849037 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "beautiful-mermaid": "^1.1", + "handlebars": "^4.7.9", "winston": "^3.19", "winston-daily-rotate-file": "^5.0" }, diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/utils/src/frontmatter.ts similarity index 98% rename from packages/coding-agent/src/utils/frontmatter.ts rename to packages/utils/src/frontmatter.ts index 1765d53b1..75cfba652 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/utils/src/frontmatter.ts @@ -1,5 +1,6 @@ -import { logger, truncate } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; +import { truncate } from "./format"; +import * as logger from "./logger"; function stripHtmlComments(content: string): string { return content.replace(//g, ""); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f48a5638a..a6e5033c9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -4,6 +4,7 @@ export * from "./color"; export * from "./dirs"; export * from "./env"; export * from "./format"; +export * from "./frontmatter"; export * from "./fs-error"; export * from "./glob"; export * from "./hook-fetch"; @@ -13,6 +14,7 @@ export * from "./mermaid-ascii"; export * as postmortem from "./postmortem"; export * as procmgr from "./procmgr"; export { setNativeKillTree } from "./procmgr"; +export * as prompt from "./prompt"; export * as ptree from "./ptree"; export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree"; export * from "./snowflake"; diff --git a/packages/utils/src/prompt.ts b/packages/utils/src/prompt.ts new file mode 100644 index 000000000..d55986f53 --- /dev/null +++ b/packages/utils/src/prompt.ts @@ -0,0 +1,401 @@ +import type { HelperDelegate, HelperOptions, Template, TemplateDelegate } from "handlebars"; +import Handlebars from "handlebars"; + +export type { HelperDelegate, HelperOptions, Template, TemplateDelegate }; + +export type PromptRenderPhase = "pre-render" | "post-render"; + +export interface PromptFormatOptions { + renderPhase?: PromptRenderPhase; + replaceAsciiSymbols?: boolean; + boldRfc2119Keywords?: boolean; +} + +// Opening XML tag (not self-closing, not closing) +const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/; +// Closing XML tag +const CLOSING_XML = /^<\/([a-z_-]+)>$/; +// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc. +const OPENING_HBS = /^\{\{#/; +// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc. +const CLOSING_HBS = /^\{\{\//; +// List item (- or * or 1.) +const LIST_ITEM = /^(?:[-*]\s|\d+\.\s)/; +// Code fence +const CODE_FENCE = /^```/; +// Table row +const TABLE_ROW = /^\|.*\|$/; +// Table separator (|---|---|) +const TABLE_SEP = /^\|[-:\s|]+\|$/; + +/** RFC 2119 keywords used in prompts. */ +const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g; + +function boldRfc2119Keywords(line: string): string { + return line.replace(RFC2119_KEYWORDS, (match, offset, source) => { + const isAlreadyBold = + source[offset - 2] === "*" && + source[offset - 1] === "*" && + source[offset + match.length] === "*" && + source[offset + match.length + 1] === "*"; + if (isAlreadyBold) { + return match; + } + return `**${match}**`; + }); +} + +/** Compact a table row by trimming cell padding */ +function compactTableRow(line: string): string { + const cells = line.split("|"); + return cells.map(c => c.trim()).join("|"); +} + +/** Compact a table separator row */ +function compactTableSep(line: string): string { + const cells = line.split("|").filter(c => c.trim()); + const normalized = cells.map(c => { + const trimmed = c.trim(); + const left = trimmed.startsWith(":"); + const right = trimmed.endsWith(":"); + if (left && right) return ":---:"; + if (left) return ":---"; + if (right) return "---:"; + return "---"; + }); + return `|${normalized.join("|")}|`; +} + +function replaceCommonAsciiSymbols(line: string): string { + return line + .replace(/\.{3}/g, "…") + .replace(/<->/g, "↔") + .replace(/->/g, "→") + .replace(/<-/g, "←") + .replace(/!=/g, "≠") + .replace(/<=/g, "≤") + .replace(/>=/g, "≥"); +} + +export function format(content: string, options: PromptFormatOptions = {}): string { + const { + renderPhase = "post-render", + replaceAsciiSymbols = false, + boldRfc2119Keywords: shouldBoldRfc2119 = false, + } = options; + const isPreRender = renderPhase === "pre-render"; + const lines = content.split("\n"); + const result: string[] = []; + let inCodeBlock = false; + const topLevelTags: string[] = []; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trimEnd(); + let trimmedStart = line.trimStart(); + if (CODE_FENCE.test(trimmedStart)) { + inCodeBlock = !inCodeBlock; + result.push(line); + continue; + } + + if (inCodeBlock) { + result.push(line); + continue; + } + + if (replaceAsciiSymbols) { + line = replaceCommonAsciiSymbols(line); + } + trimmedStart = line.trimStart(); + const trimmed = line.trim(); + + const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>"); + if (isOpeningXml && line.length === trimmedStart.length) { + const match = OPENING_XML.exec(trimmedStart); + if (match) topLevelTags.push(match[1]); + } + + const closingMatch = CLOSING_XML.exec(trimmedStart); + if (closingMatch) { + const tagName = closingMatch[1]; + if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) { + topLevelTags.pop(); + } + } else if (isPreRender && trimmedStart.startsWith("{{")) { + /* keep indentation as-is in pre-render for Handlebars markers */ + } else if (TABLE_SEP.test(trimmedStart)) { + const leadingWhitespace = line.slice(0, line.length - trimmedStart.length); + line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`; + } else if (TABLE_ROW.test(trimmedStart)) { + const leadingWhitespace = line.slice(0, line.length - trimmedStart.length); + line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`; + } + + if (shouldBoldRfc2119) { + line = boldRfc2119Keywords(line); + } + + const isBlank = trimmed === ""; + if (isBlank) { + const prevLine = result[result.length - 1]?.trim() ?? ""; + const nextLine = lines[i + 1]?.trim() ?? ""; + + if (LIST_ITEM.test(nextLine)) { + continue; + } + + if (OPENING_XML.test(prevLine) || (isPreRender && OPENING_HBS.test(prevLine))) { + continue; + } + + if (CLOSING_XML.test(nextLine) || (isPreRender && CLOSING_HBS.test(nextLine))) { + continue; + } + + const prevIsBlank = prevLine === ""; + if (prevIsBlank) { + continue; + } + } + + if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) { + while (result.length > 0 && result[result.length - 1].trim() === "") { + result.pop(); + } + } + + result.push(line); + } + + while (result.length > 0 && result[result.length - 1].trim() === "") { + result.pop(); + } + + return result.join("\n"); +} + +export interface TemplateContext extends Record { + args?: string[]; + ARGUMENTS?: string; + arguments?: string; +} + +const handlebars = Handlebars.create(); + +handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string { + const args = this.args ?? []; + const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10); + if (!Number.isFinite(parsedIndex)) return ""; + const zeroBased = parsedIndex - 1; + if (zeroBased < 0) return ""; + return args[zeroBased] ?? ""; +}); + +/** + * {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}} + * Renders an array with customizable prefix, suffix, and join separator. + * Note: Use \n in join for newlines (will be unescaped automatically). + */ +handlebars.registerHelper( + "list", + function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string { + if (!Array.isArray(context) || context.length === 0) return ""; + const prefix = (options.hash.prefix as string) ?? ""; + const suffix = (options.hash.suffix as string) ?? ""; + const rawSeparator = (options.hash.join as string) ?? "\n"; + const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t"); + return context.map(item => `${prefix}${options.fn(item)}${suffix}`).join(separator); + }, +); + +/** + * {{join array ", "}} + * Joins an array with a separator (default: ", "). + */ +handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => { + if (!Array.isArray(context)) return ""; + const sep = typeof separator === "string" ? separator : ", "; + return context.join(sep); +}); + +/** + * {{default value "fallback"}} + * Returns the value if truthy, otherwise returns the fallback. + */ +handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue); + +/** + * {{pluralize count "item" "items"}} + * Returns "1 item" or "5 items" based on count. + */ +handlebars.registerHelper( + "pluralize", + (count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`, +); + +/** + * {{#when value "==" compare}}...{{else}}...{{/when}} + * Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <= + */ +handlebars.registerHelper( + "when", + function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string { + const ops: Record boolean> = { + "==": (a, b) => a === b, + "===": (a, b) => a === b, + "!=": (a, b) => a !== b, + "!==": (a, b) => a !== b, + ">": (a, b) => (a as number) > (b as number), + "<": (a, b) => (a as number) < (b as number), + ">=": (a, b) => (a as number) >= (b as number), + "<=": (a, b) => (a as number) <= (b as number), + }; + const fn = ops[operator]; + if (!fn) return options.inverse(this); + return fn(lhs, rhs) ? options.fn(this) : options.inverse(this); + }, +); + +/** + * {{#ifAny a b c}}...{{else}}...{{/ifAny}} + * True if any argument is truthy. + */ +handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string { + const options = args.pop() as Handlebars.HelperOptions; + return args.some(Boolean) ? options.fn(this) : options.inverse(this); +}); + +/** + * {{#ifAll a b c}}...{{else}}...{{/ifAll}} + * True if all arguments are truthy. + */ +handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string { + const options = args.pop() as Handlebars.HelperOptions; + return args.every(Boolean) ? options.fn(this) : options.inverse(this); +}); + +/** + * {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}} + * Generates a markdown table from an array of objects. + */ +handlebars.registerHelper( + "table", + function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string { + if (!Array.isArray(context) || context.length === 0) return ""; + const headersStr = options.hash.headers as string | undefined; + const headers = headersStr?.split("|") ?? []; + const separator = headers.map(() => "---").join(" | "); + const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : ""; + const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n"); + return headerRow + rows; + }, +); + +/** + * {{#codeblock lang="diff"}}...{{/codeblock}} + * Wraps content in a fenced code block. + */ +handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string { + const lang = (options.hash.lang as string) ?? ""; + const content = options.fn(this).trim(); + return `\`\`\`${lang}\n${content}\n\`\`\``; +}); + +/** + * {{#xml "tag"}}content{{/xml}} + * Wraps content in XML-style tags. Returns empty string if content is empty. + */ +handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string { + const content = options.fn(this).trim(); + if (!content) return ""; + return `<${tag}>\n${content}\n`; +}); + +/** + * {{escapeXml value}} + * Escapes XML special characters: & < > " + */ +handlebars.registerHelper("escapeXml", (value: unknown): string => { + if (value == null) return ""; + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +}); + +/** + * {{len array}} + * Returns the length of an array or string. + */ +handlebars.registerHelper("len", (value: unknown): number => { + if (Array.isArray(value)) return value.length; + if (typeof value === "string") return value.length; + return 0; +}); + +/** + * {{add a b}} + * Adds two numbers. + */ +handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0)); + +/** + * {{sub a b}} + * Subtracts b from a. + */ +handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0)); + +/** + * {{#has collection item}}...{{else}}...{{/has}} + * Checks if an array includes an item or if a Set/Map has a key. + */ +handlebars.registerHelper( + "has", + function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string { + let found = false; + if (Array.isArray(collection)) { + found = collection.includes(item); + } else if (collection instanceof Set) { + found = collection.has(item); + } else if (collection instanceof Map) { + found = collection.has(item); + } else if (collection && typeof collection === "object") { + if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") { + found = item in collection; + } + } + return found ? options.fn(this) : options.inverse(this); + }, +); + +/** + * {{includes array item}} + * Returns true if array includes item. For use in other helpers. + */ +handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => { + if (Array.isArray(collection)) return collection.includes(item); + if (collection instanceof Set) return collection.has(item); + if (collection instanceof Map) return collection.has(item); + return false; +}); + +/** + * {{not value}} + * Returns logical NOT of value. For use in subexpressions. + */ +handlebars.registerHelper("not", (value: unknown): boolean => !value); + +export function registerHelper(name: string, fn: HelperDelegate): void { + handlebars.registerHelper(name, fn); +} + +export function registerPartial(name: string, fn: Template): void { + handlebars.registerPartial(name, fn); +} + +export function compile(template: string): (context: TemplateContext) => string { + return handlebars.compile(template, { noEscape: true, strict: false }); +} + +export function render(template: string, context: TemplateContext = {}): string { + const compiled = compile(template); + const rendered = compiled(context ?? {}); + return format(rendered, { renderPhase: "post-render" }); +}