refactor(coding-agent): extracted prompt formatting into reusable utility

- Extracted prompt formatting logic into reusable `formatPromptContent()` utility function with configurable render phases.
- Removed 166 lines of duplicate formatting code from scripts and config modules by centralizing regex patterns and helper functions.
- Updated prompt template rendering to use unified `formatPromptContent()` instead of inline `optimizePromptLayout()` implementation.
- Added comprehensive test coverage for prompt formatting with pre-render and post-render mode validation.
This commit is contained in:
can1357
2026-02-27 19:16:18 +01:00
parent 400fdac6b1
commit 7de4c831e6
6 changed files with 215 additions and 228 deletions
+3 -1
View File
@@ -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
+7 -172
View File
@@ -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) {
@@ -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" });
}
/**
@@ -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");
}
@@ -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 = "<root>\n {{#if ok}}\n value\n {{/if}}\n</root>";
const output = formatPromptContent(input, { renderPhase: "pre-render" });
expect(output).toBe("<root>\n{{#if ok}}\n value\n{{/if}}\n</root>");
});
test("post-render mode preserves indentation on Handlebars-like lines", () => {
const input = "<root>\n {{#if ok}}\n value\n {{/if}}\n</root>";
const output = formatPromptContent(input, { renderPhase: "post-render" });
expect(output).toBe("<root>\n {{#if ok}}\n value\n {{/if}}\n</root>");
});
test("pre-render removes blank line before closing Handlebars block while post-render keeps it", () => {
const input = "<root>\n{{#if ok}}\nvalue\n\n{{/if}}\n</root>";
const preRender = formatPromptContent(input, { renderPhase: "pre-render" });
const postRender = formatPromptContent(input, { renderPhase: "post-render" });
expect(preRender).toBe("<root>\n{{#if ok}}\nvalue\n{{/if}}\n</root>");
expect(postRender).toBe("<root>\n{{#if ok}}\nvalue\n\n{{/if}}\n</root>");
});
});
@@ -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<context>\ncontext`);
expect(result.task).toContain("the real work");
});