Files
orca/src/shared/commit-message-agent-output.ts
T
Neilandczzczz 4f2007a809 fix(jcode): narrow dynamic reads with predicates, pin the Windows hook shape
CI's anti-slop audit rejects Reflect.get: parse dynamic input into a named type
instead. Adds Windows script-shape tests too, since Windows is the platform this
change could not be exercised on directly.

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
2026-10-03 01:49:40 -07:00

287 lines
10 KiB
TypeScript

/** 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 <think>; Kimi-VL-Thinking uses ◁think▷.
const REASONING_TAGS = [
{ open: '<think>', close: '</think>' },
{ 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
}