orca/src/shared/commit-message-prompt.ts

334 lines
11 KiB
TypeScript

// Why: keeping the base prompt and assembly here (in shared) lets both the
// renderer (preview/tests) and main (actual generation) reach the exact same
// string without duplicating the wording.
const COMMIT_MESSAGE_BASE_PROMPT = `You are generating a single git commit message.
Read the staged diff below and produce the message.
Rules:
- First line: imperative mood, <= 72 chars, no trailing period.
- Optional body: blank line, then wrapped at 72 chars explaining WHY.
- Output ONLY the commit message - no preamble, no code fences, no quotes.
- Do not include "Co-authored-by" trailers - Orca appends them after generation when configured.
Staged diff:
\`\`\`diff
{{DIFF}}
\`\`\`
`
/** Builds the final prompt sent to the agent. The custom suffix is appended verbatim
* when non-empty so the user can override style (Conventional Commits, gitmoji, …). */
export function buildCommitPrompt(diff: string, customSuffix: string): string {
const base = COMMIT_MESSAGE_BASE_PROMPT.replace('{{DIFF}}', diff)
const trimmedSuffix = customSuffix.trim()
if (!trimmedSuffix) {
return base
}
return `${base}\n\nAdditional user prompt:\n${trimmedSuffix}`
}
export const STAGED_DIFF_BYTE_BUDGET = 200_000
/** Splits a unified diff into one section per file, keyed on the `diff --git`
* header. Each section keeps the leading newline that preceded its header so
* concatenating the sections reproduces the original byte-for-byte. */
function splitDiffIntoFileSections(diff: string): string[] {
const boundary = '\ndiff --git '
const sections: string[] = []
let start = 0
let next = diff.indexOf(boundary)
while (next !== -1) {
// Include the boundary newline in the current section; the next section
// starts at the `diff --git` header itself.
sections.push(diff.slice(start, next + 1))
start = next + 1
next = diff.indexOf(boundary, start)
}
sections.push(diff.slice(start))
return sections
}
/** Clips one section to `limit` bytes on a line boundary so the agent never sees
* a half-written diff line, and records how many bytes were dropped. */
function clipSectionOnLineBoundary(section: string, limit: number): string {
if (section.length <= limit) {
return section
}
if (limit <= 0) {
return ''
}
const markerFor = (omitted: number): string => `\n...(diff truncated, ${omitted} bytes omitted)\n`
let marker = markerFor(section.length)
if (marker.length >= limit) {
return marker.slice(0, limit)
}
// Reserve headroom for the marker, then back up to the previous newline unless
// that would discard most of the budget (one very long line).
const target = limit - marker.length
const lineBreak = section.lastIndexOf('\n', target)
const cut = lineBreak > target / 2 ? lineBreak : target
const omitted = section.length - cut
marker = markerFor(omitted)
return `${section.slice(0, Math.min(cut, Math.max(0, limit - marker.length)))}${marker}`
}
/** Distributes `budget` across `sizes` by water-filling: everyone starts with an
* equal share, and the slack from files that fit is handed back to the files
* that don't. Keeps one huge generated file from starving the human-authored
* changes elsewhere in the diff. */
function allocateBudgetFairly(sizes: number[], budget: number): number[] {
const alloc: number[] = Array.from({ length: sizes.length }, () => 0)
let active = sizes.map((_, i) => i)
let remaining = budget
while (active.length > 0 && remaining > 0) {
const share = Math.floor(remaining / active.length)
if (share === 0) {
break
}
const stillActive: number[] = []
for (const i of active) {
const need = sizes[i] - alloc[i]
const grant = Math.min(need, share)
alloc[i] += grant
remaining -= grant
if (grant < need) {
stillActive.push(i)
}
}
active = stillActive
}
return alloc
}
/** Truncates a diff that exceeds the byte budget. Splits the budget fairly across
* files and clips on line boundaries, so a single oversized file can't crowd out
* the rest and the agent never receives a malformed diff. */
export function truncateDiffForPrompt(
diff: string,
budget: number = STAGED_DIFF_BYTE_BUDGET
): string {
if (diff.length <= budget) {
return diff
}
const sections = splitDiffIntoFileSections(diff)
if (sections.length <= 1) {
return clipSectionOnLineBoundary(diff, budget)
}
const allocations = allocateBudgetFairly(
sections.map((section) => section.length),
budget
)
return sections.map((section, i) => clipSectionOnLineBoundary(section, allocations[i])).join('')
}
/** Strips noise around the agent's output: surrounding whitespace, a single
* enclosing fenced code block, and lone "Generating…" preamble lines some
* CLIs print before the real answer. */
export function cleanGeneratedCommitMessage(raw: string): string {
let text = raw.replace(/\r\n/g, '\n').trim()
// Why: real commit messages never start with an ellipsis or the word
// "Generating"/"Thinking" — those leak from CLIs that print a status line
// before the actual response.
const firstNewline = text.indexOf('\n')
if (firstNewline !== -1) {
const firstLine = text.slice(0, firstNewline)
if (/^(generating|thinking)\b/i.test(firstLine) || /^[.…]+$/.test(firstLine.trim())) {
text = text.slice(firstNewline + 1).trim()
}
}
const fence = /^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```$/
const fenced = text.match(fence)
if (fenced) {
text = fenced[1].trim()
}
// Why: some CLIs format a one-shot answer as a list item even when the
// prompt asks for raw text; a Git subject should not carry that marker.
text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim()
return text
}
function stripAnsiControlSequences(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '')
}
export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}'
export type TokenizeCustomCommandResult =
| { ok: true; tokens: string[] }
| { ok: false; error: string }
// Why: deliberately POSIX-shell-style only for *grouping* (single + double
// quotes, backslash escapes inside double quotes). We do NOT expand `$VAR`,
// command substitution, backticks, globs, or `~`. The user's intent is
// "spawn this exact CLI" — adding shell semantics on top would create
// surprising behavior across platforms (especially Windows) and a security
// surface we don't need.
export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomCommandResult {
const tokens: string[] = []
let current = ''
let inToken = false
let quote: '"' | "'" | null = null
let i = 0
while (i < template.length) {
const ch = template[i]
if (quote) {
if (ch === '\\' && quote === '"' && i + 1 < template.length) {
current += template[i + 1]
i += 2
continue
}
if (ch === quote) {
quote = null
i++
// Why: leaving a quoted region still keeps the token open — `a"b"c`
// tokenizes as a single arg `abc`.
inToken = true
continue
}
current += ch
i++
continue
}
if (ch === '"' || ch === "'") {
quote = ch
inToken = true
i++
continue
}
if (ch === '\\' && i + 1 < template.length) {
current += template[i + 1]
inToken = true
i += 2
continue
}
if (/\s/.test(ch)) {
if (inToken) {
tokens.push(current)
current = ''
inToken = false
}
i++
continue
}
current += ch
inToken = true
i++
}
if (quote) {
return { ok: false, error: 'Unclosed quote in command template.' }
}
if (inToken) {
tokens.push(current)
}
return { ok: true, tokens }
}
export type CustomCommandPlan =
| { ok: true; binary: string; args: string[]; stdinPayload: string | null }
| { ok: false; error: string }
/**
* Parses a user-supplied command template into a spawn-ready binary + argv,
* substituting `{prompt}` with the agent prompt. When the template contains
* no `{prompt}`, the prompt is delivered via stdin (mirrors `claude -p`).
*
* Quoting is a tokenizer-level concern only — we use argv (no shell), so the
* substituted prompt is always passed as a single argument regardless of
* whether the template wrote `{prompt}` or `"{prompt}"`.
*/
export function planCustomCommand(template: string, prompt: string): CustomCommandPlan {
const tokenized = tokenizeCustomCommandTemplate(template)
if (!tokenized.ok) {
return { ok: false, error: tokenized.error }
}
if (tokenized.tokens.length === 0) {
return { ok: false, error: 'Custom command is empty.' }
}
const [binary, ...rest] = tokenized.tokens
if (!binary) {
return { ok: false, error: 'Custom command must start with a binary name.' }
}
const substitute = (token: string): string =>
token.includes(CUSTOM_PROMPT_PLACEHOLDER)
? token.split(CUSTOM_PROMPT_PLACEHOLDER).join(prompt)
: token
const usesPlaceholder = tokenized.tokens.some((t) => t.includes(CUSTOM_PROMPT_PLACEHOLDER))
if (usesPlaceholder) {
return {
ok: true,
binary: substitute(binary),
args: rest.map(substitute),
stdinPayload: null
}
}
return { ok: true, binary, args: rest, stdinPayload: prompt }
}
// Why: agent CLIs (Codex, Claude) prefix their stdout/stderr with config
// preamble, the echoed prompt, and hook lifecycle messages. When something
// fails, the actionable error is buried far below all of that. This pulls
// out the real message so the user sees something legible instead of a
// dump of the agent's runtime state.
export function extractAgentErrorMessage(stdout: string, stderr: string): string | null {
const combined = stripAnsiControlSequences(`${stdout}\n${stderr}`)
const lines = combined.split(/\r?\n/)
// Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload.
// Walk from the end so the most recent (and usually most meaningful)
// error wins when an agent prints multiple.
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line)
if (!match) {
continue
}
const payload = match[1].trim()
if (payload.startsWith('{')) {
try {
const parsed = JSON.parse(payload) as {
message?: string
error?: { message?: string }
}
const inner = parsed.error?.message ?? parsed.message
if (typeof inner === 'string' && inner.trim().length > 0) {
return inner.trim()
}
} catch {
// Fall through to using the raw payload below.
}
}
if (payload.length > 0) {
return payload
}
}
const compact = combined.replace(/([A-Za-z])\r?\n\s*([A-Za-z_])/g, '$1$2').replace(/\s+/g, ' ')
const errorCodeMatch = /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact)
if (errorCodeMatch) {
const payload = errorCodeMatch[1].trim()
const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload)
if (messageMatch?.[1]?.trim()) {
return messageMatch[1].trim()
}
if (payload.length > 0) {
return payload
}
}
return null
}