mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* perf(orcad): keep the server bundle ASCII so V8 stores it one byte per character 129 non-ASCII characters in regex literals (and three in a String.raw plugin template) made V8 hold the whole ~11 MB orcad-server.js source as a two-byte string. Write them as \uXXXX escapes and fail the build if any return. Co-Authored-By: Claude <noreply@anthropic.com> * fix(orcad): keep license comments ASCII and name the module the guard trips on esbuild gathers legal comments at the end of the bundle; escape that comment so a dependency's license text cannot fail the ASCII guard, while licenses still ship with the code. The guard now reports the offset and, from an unminified rebuild, the module the character came from. A dark-switch build is covered. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
287 lines
10 KiB
TypeScript
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) || /^[.\u2026]+$/.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*)(?:[-*\u2022\u25CF]\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
|
|
}
|