mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
225 lines
7.7 KiB
TypeScript
225 lines
7.7 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
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|