mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
270 lines
8.3 KiB
TypeScript
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
|
|
}
|