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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user