Files
orca/src/shared/commit-message-agent-output.ts
T

270 lines
8.3 KiB
TypeScript

/** 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 {
// Why: agent output can include very large generated bodies; normalize and
// unwrap by scanning boundaries instead of building newline-sized arrays.
let text = normalizeGeneratedCommitMessageLineFeeds(raw).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 fenced = findEnclosingCommitMessageFenceBody(text)
if (fenced !== null) {
text = fenced.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 normalizeGeneratedCommitMessageLineFeeds(value: string): string {
let crlfStart = value.indexOf('\r\n')
if (crlfStart === -1) {
return value
}
let normalized = value.slice(0, crlfStart)
let chunkStart = crlfStart + 2
normalized += '\n'
crlfStart = value.indexOf('\r\n', chunkStart)
while (crlfStart !== -1) {
normalized += value.slice(chunkStart, crlfStart)
normalized += '\n'
chunkStart = crlfStart + 2
crlfStart = value.indexOf('\r\n', chunkStart)
}
return `${normalized}${value.slice(chunkStart)}`
}
function findEnclosingCommitMessageFenceBody(text: string): string | null {
if (!text.startsWith('```')) {
return null
}
let headerEnd = 3
while (headerEnd < text.length && text.charCodeAt(headerEnd) !== 10) {
if (!isCommitFenceInfoCharacter(text.charCodeAt(headerEnd))) {
return null
}
headerEnd++
}
if (headerEnd >= text.length) {
return null
}
const closingFenceStart = text.length - 3
if (closingFenceStart <= headerEnd || !text.endsWith('```')) {
return null
}
if (text.charCodeAt(closingFenceStart - 1) !== 10) {
return null
}
return text.slice(headerEnd + 1, closingFenceStart - 1)
}
function isCommitFenceInfoCharacter(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 45 ||
code === 95
)
}
function stripAnsiControlSequences(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '')
}
const AGENT_ERROR_CODE_SCAN_LIMIT = 8192
const ERROR_CODE_MARKER = 'error code:'
// 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 = `${stdout}\n${stderr}`
// 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.
const errorPayload = findLastAgentErrorPayload(combined)
if (errorPayload !== null) {
const payload = errorPayload.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 = extractCompactedErrorCodeTail(combined)
const errorCodeMatch = compact ? /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact) : null
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
}
function extractCompactedErrorCodeTail(combined: string): string | null {
const markerStart = lastIndexOfAsciiIgnoreCase(combined, ERROR_CODE_MARKER)
if (markerStart === -1) {
return null
}
return compactAgentErrorTail(
combined.slice(markerStart, markerStart + AGENT_ERROR_CODE_SCAN_LIMIT)
)
}
function lastIndexOfAsciiIgnoreCase(value: string, needle: string): number {
if (needle.length === 0 || value.length < needle.length) {
return -1
}
for (let index = value.length - needle.length; index >= 0; index -= 1) {
if (equalsAsciiIgnoreCaseAt(value, needle, index)) {
return index
}
}
return -1
}
function equalsAsciiIgnoreCaseAt(value: string, needle: string, index: number): boolean {
for (let offset = 0; offset < needle.length; offset += 1) {
if (
toAsciiLowerCode(value.charCodeAt(index + offset)) !==
toAsciiLowerCode(needle.charCodeAt(offset))
) {
return false
}
}
return true
}
function compactAgentErrorTail(value: string): string {
let compact = ''
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index += 1
}
const next = findNextNonWhitespaceCode(value, index + 1)
if (shouldDropWrappedLineBreak(compact.charCodeAt(compact.length - 1), next)) {
index = next.index - 1
continue
}
if (compact.length > 0 && compact.charCodeAt(compact.length - 1) !== 32) {
compact += ' '
}
index = next.index - 1
continue
}
if (isAsciiWhitespace(code)) {
if (compact.length > 0 && compact.charCodeAt(compact.length - 1) !== 32) {
compact += ' '
}
continue
}
compact += value[index]
}
return compact.trim()
}
function findNextNonWhitespaceCode(value: string, start: number): { code: number; index: number } {
for (let index = start; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (!isAsciiWhitespace(code)) {
return { code, index }
}
}
return { code: Number.NaN, index: value.length }
}
function shouldDropWrappedLineBreak(previous: number, next: { code: number }): boolean {
return isAsciiLetter(previous) && (isAsciiLetter(next.code) || next.code === 95)
}
function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}
function isAsciiLetter(code: number): boolean {
const lower = toAsciiLowerCode(code)
return lower >= 97 && lower <= 122
}
function toAsciiLowerCode(code: number): number {
return code >= 65 && code <= 90 ? code + 32 : code
}
function findLastAgentErrorPayload(combined: string): string | null {
let lineEnd = combined.length
for (let index = combined.length - 1; index >= -1; index--) {
if (index >= 0) {
const code = combined.charCodeAt(index)
if (code !== 10 && code !== 13) {
continue
}
if (code === 10 && index > 0 && combined.charCodeAt(index - 1) === 13) {
continue
}
}
// Why: agent logs can include paste-sized echoed prompts. Error lines are
// short diagnostics, so bound per-line ANSI stripping before matching.
const rawLine = combined.slice(
index + 1,
Math.min(lineEnd, index + 1 + AGENT_ERROR_CODE_SCAN_LIMIT)
)
const line = rawLine.includes(String.fromCharCode(27))
? stripAnsiControlSequences(rawLine)
: rawLine
const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line)
if (match?.[1]) {
return match[1]
}
lineEnd = index > 0 && combined.charCodeAt(index - 1) === 13 ? index - 1 : index
}
return null
}