/** jcode's `run --json` envelope, narrowed to the field the answer lives in. */ type JcodeJsonResult = { text?: unknown } function isJcodeJsonResult(value: unknown): value is JcodeJsonResult { return typeof value === 'object' && value !== null && 'text' in value } /** Strips noise around the agent's output: surrounding whitespace, a single * enclosing fenced code block, lone "Generating…" preamble lines some CLIs * print before the real answer, and a reasoning block the output opens with. */ 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: jcode --json emits a `{"text": …}` envelope (its plain-text mode // mixes thinking and a "[Tokens]…" usage line into stdout); extract the // answer field when the output is such an envelope. if (text.startsWith('{')) { try { const parsed: unknown = JSON.parse(text) if (isJcodeJsonResult(parsed) && typeof parsed.text === 'string') { text = parsed.text.trim() } } catch { // not a JSON envelope; fall through to the plain-text cleanup } } // Why: jcode prints a trailing "[Tokens] upload: …" usage line after the // answer; strip it so the result stays clean for branch/commit naming. const tokensUsageIndex = text.search(/\n\[Tokens\]\s/) if (tokensUsageIndex !== -1) { text = text.slice(0, tokensUsageIndex).trimEnd() } // 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() } } text = stripLeadingReasoningBlock(text) 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 } // Why: reasoning models print their chain of thought before the answer. // DeepSeek-R1, Qwen3 and Kimi K2 use ; Kimi-VL-Thinking uses ◁think▷. const REASONING_TAGS = [ { open: '', close: '' }, { open: '◁think▷', close: '◁/think▷' } ] as const function stripLeadingReasoningBlock(text: string): string { for (const { open, close } of REASONING_TAGS) { if (!text.startsWith(open)) { continue } const closeIndex = text.indexOf(close, open.length) return closeIndex === -1 ? text : text.slice(closeIndex + close.length).trim() } return text } /** Drops reasoning that ends in a closing tag with no opening tag before it. * Chat templates that prefill the opening tag in the prompt (Qwen3-Thinking, * DeepSeek-R1-0528, Kimi K2.5) keep it out of stdout. Only custom commands * should use this: any other message may legitimately mention the closing tag. */ export function stripPrefilledReasoningPreamble(text: string): string { for (const { open, close } of REASONING_TAGS) { const closeIndex = text.indexOf(close) if (closeIndex !== -1 && text.lastIndexOf(open, closeIndex) === -1) { return text.slice(closeIndex + close.length).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 ) } export function stripAnsiControlSequences(value: string): string { const esc = String.fromCharCode(27) const bel = String.fromCharCode(7) // CSI (colors/cursor) and OSC (titles/hyperlinks) both appear in raw CLI // failure output once it is shown verbatim instead of parsed. return value.replace( new RegExp( `${esc}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bel}${esc}\\r\\n]*(?:${bel}|${esc}\\\\))`, 'g' ), '' ) } function stripAnsiIfPresent(value: string): string { return value.includes(String.fromCharCode(27)) ? stripAnsiControlSequences(value) : value } // Only the two ends of the output are read, like glancing at the first and // last lines of a long log. const FAILURE_EXCERPT_SCAN_WINDOW = 8192 const FAILURE_EXCERPT_HEAD_LINE_COUNT = 2 // Why: when both ends are shown, the tail gets the larger budget because most // CLIs print the operative error last; the head budget covers CLIs that // front-load it. A lone excerpt keeps the whole toast/persistence budget. const FAILURE_EXCERPT_HEAD_BUDGET = 100 const FAILURE_EXCERPT_TAIL_BUDGET = 130 const FAILURE_EXCERPT_SINGLE_BUDGET = 240 // Why: agent CLIs share no error format, and per-CLI parsing rots every time a // vendor rewords a message. Orca deliberately does NOT interpret failure // output — it excerpts it positionally (first lines plus last line) so every // CLI's real failure text reaches the user. Callers must still sanitize the // excerpt before display or persistence. export function excerptAgentFailureOutput(stdout: string, stderr: string): string | null { // stderr is where CLIs put diagnostics; stdout is the fallback for the ones // that report failures inline (and often echoes the prompt, so it never // overrides a non-blank stderr). const source = /\S/.test(stderr) ? stderr : stdout if (!/\S/.test(source)) { return null } if (source.length <= FAILURE_EXCERPT_SCAN_WINDOW) { const lines = collectExcerptLines(source, Number.POSITIVE_INFINITY) if (lines.length === 0) { return null } if (lines.length <= FAILURE_EXCERPT_HEAD_LINE_COUNT + 1) { return truncateExcerptPart(lines.join(' '), FAILURE_EXCERPT_SINGLE_BUDGET) } return composeTwoEndExcerpt( lines.slice(0, FAILURE_EXCERPT_HEAD_LINE_COUNT), lines.at(-1) ?? null ) } const headLines = collectExcerptLines( source.slice(0, FAILURE_EXCERPT_SCAN_WINDOW), FAILURE_EXCERPT_HEAD_LINE_COUNT ) const tailLine = collectExcerptLinesFromEnd(source.slice(source.length - FAILURE_EXCERPT_SCAN_WINDOW), 1)[0] ?? null if (headLines.length === 0) { return tailLine ? truncateExcerptPart(tailLine, FAILURE_EXCERPT_SINGLE_BUDGET) : null } return composeTwoEndExcerpt(headLines, tailLine) } export function sanitizeAgentFailureDetail(detail: string | null): string | null { // Cf covers bidi overrides that could visually reorder persisted, client-synced text. const trimmed = detail ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ') .replace(/\s+/g, ' ') .trim() if (!trimmed) { return null } const redacted = trimmed .replace( /\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g, '[path]' ) // JSON may double Windows separators; URL separators must remain single. .replace( /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g, '[path]' ) // Keep single-segment remedies such as /login while redacting filesystem paths. .replace( /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g, '$1[path]' ) return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted } function composeTwoEndExcerpt(headLines: string[], tailLine: string | null): string { const headPart = truncateExcerptPart(headLines.join(' '), FAILURE_EXCERPT_HEAD_BUDGET) // Repeated lines (spinner/retry frames) would otherwise show twice. if (tailLine === null || headLines.includes(tailLine)) { return headPart } return `${headPart} … ${truncateExcerptPart(tailLine, FAILURE_EXCERPT_TAIL_BUDGET)}` } function truncateExcerptPart(value: string, budget: number): string { return value.length > budget ? `${value.slice(0, budget).trimEnd()}…` : value } function collectExcerptLines(text: string, max: number): string[] { // Bare `\r` is a boundary too: progress bars redraw with carriage returns. const lines = text.split(/\r\n|\r|\n/) const collected: string[] = [] for (let index = 0; index < lines.length && collected.length < max; index += 1) { const line = stripAnsiIfPresent(lines[index]).trim() if (line.length > 0) { collected.push(line) } } return collected } function collectExcerptLinesFromEnd(text: string, max: number): string[] { const lines = text.split(/\r\n|\r|\n/) const collected: string[] = [] for (let index = lines.length - 1; index >= 0 && collected.length < max; index -= 1) { const line = stripAnsiIfPresent(lines[index]).trim() if (line.length > 0) { collected.push(line) } } return collected }