diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f4043f864..1de6d1ed0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Extracted prompt formatting logic into reusable `formatPromptContent()` utility with configurable render phases and formatting options - Added `type_definition` action to navigate to symbol type definitions with source context - Added `implementation` action to find concrete implementations of symbols with source context - Added `code_actions` action to list and apply language server code fixes, refactors, and import suggestions @@ -19,6 +19,8 @@ ### Changed +- Refactored prompt template rendering to use unified `formatPromptContent()` function with phase-aware formatting (pre-render vs post-render) +- Updated `format-prompts.ts` script to use centralized prompt formatting utility instead of inline implementation - Replaced `column` parameter with `symbol` parameter for more intuitive position specification - Removed `files` parameter; use glob patterns in `file` parameter instead - Removed `end_line` and `end_character` parameters; range operations now use single position diff --git a/packages/coding-agent/scripts/format-prompts.ts b/packages/coding-agent/scripts/format-prompts.ts index 7e23322bb..bf013b93e 100644 --- a/packages/coding-agent/scripts/format-prompts.ts +++ b/packages/coding-agent/scripts/format-prompts.ts @@ -14,6 +14,7 @@ * 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; @@ -21,177 +22,11 @@ const AGENTIC_PROMPTS_DIR = new URL("../src/commit/agentic/prompts/", import.met const PROMPT_DIRS = [PROMPTS_DIR, COMMIT_PROMPTS_DIR, AGENTIC_PROMPTS_DIR]; -// 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 = /^[-*]|\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 { - // Split by |, trim each cell, rejoin - const cells = line.split("|"); - return cells.map((c) => c.trim()).join("|"); -} - -/** Compact a table separator row */ -function compactTableSep(line: string): string { - // Normalize to minimal |---|---| - const cells = line.split("|").filter((c) => c.trim()); - const normalized = cells.map((c) => { - const trimmed = c.trim(); - // Preserve alignment markers - const left = trimmed.startsWith(":"); - const right = trimmed.endsWith(":"); - if (left && right) return ":---:"; - if (left) return ":---"; - if (right) return "---:"; - return "---"; - }); - return "|" + normalized.join("|") + "|"; -} - -function formatPrompt(content: string): string { - const lines = content.split("\n"); - const result: string[] = []; - let inCodeBlock = false; - // Stack of tag names whose opening tag was at column 0 (top-level) - const topLevelTags: string[] = []; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i].trimEnd(); - - const trimmed = line.trimStart(); - - // Track code blocks - don't modify inside them - if (CODE_FENCE.test(trimmed)) { - inCodeBlock = !inCodeBlock; - result.push(line); - continue; - } - - if (inCodeBlock) { - result.push(line); - continue; - } - - // Replace common ascii ellipsis and arrow patterns with their unicode equivalents - line = line - .replace(/\.{3}/g, "…") - .replace(/->/g, "→") - .replace(/<-/g, "←") - .replace(/<->/g, "↔") - .replace(/!=/g, "≠") - .replace(/<=/g, "≤") - .replace(/>=/g, "≥"); - - // Track top-level XML opening tags for depth-aware indent stripping - const isOpeningXml = OPENING_XML.test(trimmed) && !trimmed.endsWith("/>"); - if (isOpeningXml && line.length === trimmed.length) { - // Opening tag at column 0 — track as top-level - const match = OPENING_XML.exec(trimmed); - if (match) topLevelTags.push(match[1]); - } - - // Strip leading whitespace from top-level closing XML tags and Handlebars - const closingMatch = CLOSING_XML.exec(trimmed); - if (closingMatch) { - const tagName = closingMatch[1]; - if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) { - // Closing tag matches a top-level opener — strip indent - line = trimmed; - topLevelTags.pop(); - } else { - line = line.trimEnd(); - } - } else if (trimmed.startsWith("{{")) { - line = trimmed; - } else if (TABLE_SEP.test(trimmed)) { - // Compact table separator - line = compactTableSep(trimmed); - } else if (TABLE_ROW.test(trimmed)) { - // Compact table row - line = compactTableRow(trimmed); - } else { - // Trim trailing whitespace (preserve leading for non-closing-tags) - line = line.trimEnd(); - } - line = boldRfc2119Keywords(line); - - const isBlank = trimmed === ""; - - // Skip blank lines that violate our rules - if (isBlank) { - const prevLine = result[result.length - 1]?.trim() ?? ""; - const nextLine = lines[i + 1]?.trim() ?? ""; - - // Rule 1: No blank line before list items - if (LIST_ITEM.test(nextLine)) { - continue; - } - - // Rule 2: No blank after opening XML tag or Handlebars block - if (OPENING_XML.test(prevLine) || OPENING_HBS.test(prevLine)) { - continue; - } - - // Rule 3: No blank before closing XML tag or Handlebars block - if (CLOSING_XML.test(nextLine) || CLOSING_HBS.test(nextLine)) { - continue; - } - - // Rule 4: Collapse multiple blank lines - const prevIsBlank = prevLine === ""; - if (prevIsBlank) { - continue; - } - } - - // Rule 3 (cleanup): Remove trailing blanks before closing tag - if (CLOSING_XML.test(trimmed) || CLOSING_HBS.test(trimmed)) { - while (result.length > 0 && result[result.length - 1].trim() === "") { - result.pop(); - } - } - - result.push(line); - } - - // Rule 8: No trailing newline at EOF - while (result.length > 0 && result[result.length - 1].trim() === "") { - result.pop(); - } - - return result.join("\n"); -} +const PROMPT_FORMAT_OPTIONS = { + renderPhase: "pre-render", + replaceAsciiSymbols: true, + boldRfc2119Keywords: true, +} as const; async function main() { const glob = new Glob("**/*.md"); @@ -207,7 +42,7 @@ async function main() { for (const fullPath of files) { const original = await Bun.file(fullPath).text(); - const formatted = formatPrompt(original); + const formatted = formatPromptContent(original, PROMPT_FORMAT_OPTIONS); if (original !== formatted) { if (check) { diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 1ae485c8b..48aafe485 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -6,6 +6,7 @@ import { computeLineHash } from "../patch/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 @@ -276,60 +277,7 @@ handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): str export function renderPromptTemplate(template: string, context: TemplateContext = {}): string { const compiled = handlebars.compile(template, { noEscape: true, strict: false }); const rendered = compiled(context ?? {}); - return optimizePromptLayout(rendered); -} - -function optimizePromptLayout(input: string): string { - // 1) strip CR / normalize line endings - let s = input.replace(/\r\n?/g, "\n"); - - // normalize NBSP -> space - s = s.replace(/\u00A0/g, " "); - - const lines = s.split("\n").map(line => { - // 2) remove trailing whitespace (spaces/tabs) per line - let l = line.replace(/[ \t]+$/g, ""); - - // 3) lines with only whitespace -> empty line - if (/^[ \t]*$/.test(l)) return ""; - - // 4) normalize leading indentation: every 2 spaces -> \t (preserve leftover 1 space) - // NOTE: This is intentionally *only* leading indentation to avoid mangling prose. - const m = l.match(/^[ \t]+/); - if (m) { - const indent = m[0]; - const rest = l.slice(indent.length); - - let out = ""; - let spaces = 0; - - for (const ch of indent) { - if (ch === "\t") { - // flush pending spaces before existing tab - out += "\t".repeat(Math.floor(spaces / 2)); - if (spaces % 2) out += " "; - spaces = 0; - out += "\t"; - } else { - spaces++; - } - } - - out += "\t".repeat(Math.floor(spaces / 2)); - if (spaces % 2) out += " "; - - l = out + rest; - } - - return l; - }); - - s = lines.join("\n"); - - // 5) collapse excessive blank lines - s = s.replace(/\n{3,}/g, "\n\n"); - - return s.trim(); + return formatPromptContent(rendered, { renderPhase: "post-render" }); } /** diff --git a/packages/coding-agent/src/utils/prompt-format.ts b/packages/coding-agent/src/utils/prompt-format.ts new file mode 100644 index 000000000..ba477cd65 --- /dev/null +++ b/packages/coding-agent/src/utils/prompt-format.ts @@ -0,0 +1,172 @@ +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(); + const trimmed = line.trimStart(); + + if (CODE_FENCE.test(trimmed)) { + inCodeBlock = !inCodeBlock; + result.push(line); + continue; + } + + if (inCodeBlock) { + result.push(line); + continue; + } + + if (replaceAsciiSymbols) { + line = replaceCommonAsciiSymbols(line); + } + + const isOpeningXml = OPENING_XML.test(trimmed) && !trimmed.endsWith("/>"); + if (isOpeningXml && line.length === trimmed.length) { + const match = OPENING_XML.exec(trimmed); + if (match) topLevelTags.push(match[1]); + } + + const closingMatch = CLOSING_XML.exec(trimmed); + if (closingMatch) { + const tagName = closingMatch[1]; + if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) { + line = trimmed; + topLevelTags.pop(); + } else { + line = line.trimEnd(); + } + } else if (isPreRender && trimmed.startsWith("{{")) { + line = trimmed; + } else if (TABLE_SEP.test(trimmed)) { + line = compactTableSep(trimmed); + } else if (TABLE_ROW.test(trimmed)) { + line = compactTableRow(trimmed); + } else { + line = line.trimEnd(); + } + + 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/test/prompt-format.test.ts b/packages/coding-agent/test/prompt-format.test.ts new file mode 100644 index 000000000..6afbdeaaf --- /dev/null +++ b/packages/coding-agent/test/prompt-format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { formatPromptContent } from "@oh-my-pi/pi-coding-agent/utils/prompt-format"; + +describe("formatPromptContent renderPhase", () => { + test("pre-render mode strips indentation from Handlebars block lines", () => { + const input = "\n {{#if ok}}\n value\n {{/if}}\n"; + + const output = formatPromptContent(input, { renderPhase: "pre-render" }); + + expect(output).toBe("\n{{#if ok}}\n value\n{{/if}}\n"); + }); + + 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" }); + + expect(output).toBe("\n {{#if ok}}\n value\n {{/if}}\n"); + }); + + 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" }); + + expect(preRender).toBe("\n{{#if ok}}\nvalue\n{{/if}}\n"); + expect(postRender).toBe("\n{{#if ok}}\nvalue\n\n{{/if}}\n"); + }); +}); diff --git a/packages/coding-agent/test/tools/task-template.test.ts b/packages/coding-agent/test/tools/task-template.test.ts index c55b6577f..9d5065512 100644 --- a/packages/coding-agent/test/tools/task-template.test.ts +++ b/packages/coding-agent/test/tools/task-template.test.ts @@ -31,7 +31,7 @@ describe("renderTemplate", () => { description: "label", assignment: "the real work", }); - expect(result.task).toStartWith(`${sectionSeparator("Background").trimStart()}\ncontext`); + expect(result.task).toStartWith(`${sectionSeparator("Background").trimStart()}\n\ncontext`); expect(result.task).toContain("the real work"); });