mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* Surface the Pi CLI's real error when branch auto-naming and commit generation fail Pi failures (missing provider credentials, HTTP 4xx/5xx, connection errors) previously collapsed to the generic 'Pi CLI command failed with code 1.' because extractAgentErrorMessage only recognized Error:-prefixed lines. Add two stderr-only extraction passes for pi's failure formats and narrow the unix-path redactor so pi's /login remedy token survives redaction. Fixes the OP variant of STA-1492 (#7808). * Replace per-CLI failure parsing with sanitized raw output excerpts Every agent CLI formats errors differently, and the parsing passes only ever covered the vendors someone had already debugged (the pi passes fixed pi and nothing else). Show the output itself instead: a positional excerpt (first two non-empty lines plus the last one) of stderr, falling back to stdout when stderr is silent, path-redacted and capped as before. Exit-0 runs with empty stdout now stay 'returned an empty <result>' instead of misreporting a command failure. The sanitizer also strips Cf characters (bidi overrides) now that provider-controlled bodies flow through verbatim, and OSC sequences are stripped alongside CSI. * Keep the full CLI output of failed branch-name generation for on-demand viewing The persisted rename-failed badge carries only a sanitized excerpt (it syncs to paired clients), so the complete diagnosis was previously buried in the main-process log. Failed generations now capture their full stdout/stderr (bounded to 64 KiB per stream, head and tail) into a main-memory-only store keyed by worktree — never persisted or synced. The rename-failed dialog fetches it on demand and shows it in place of the excerpt, ANSI/control/bidi-stripped; after a restart or on paired web clients it falls back to the excerpt. Renderer-bound generation results explicitly drop the capture so IPC payload shapes are unchanged. * Cover the rename-failed dialog's full-output fetch and excerpt fallback * Split the folder-workspace title rename flow into its own module first-work-branch-rename.ts sat at the max-lines ceiling; the folder title flow is a self-contained concept and main's concurrent additions to this file pushed the CI merge ref over the limit. * Harden the failure-output capture and dialog against review findings - Flatten bounded captures so V8 sliced strings no longer retain the multi-megabyte parent stream in the capture map (128 MiB -> ~4 MiB in a 32-entry probe). - Require an OSC terminator and stop its char class at newlines so an unterminated/boundary-truncated sequence can no longer swallow the omission marker and diagnostic tail. - Exclude stdout from the persisted branch-name failure detail (it can echo the prompt into synced metadata); the full local-only capture still keeps it for the dialog. - Refetch and reset the dialog's full output when the persisted error changes so an open dialog never shows or copies a stale run. - Report signal-terminated generators (null exit code) as 'was terminated before exiting' instead of 'code null'. * Redact JSON-escaped Windows drive paths without breaking scheme URLs Provider JSON bodies double backslashes (C:\\Users\\name), which slipped past the drive-letter redaction into the persisted, client-synced failure excerpt. Allow repeated backslashes only — a URL's :// must stay single so remedy links survive. * Fix branch rename failure IPC re-registration --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
// Why: keeping the base prompt and assembly here (in shared) lets both the
|
|
// renderer (preview/tests) and main (actual generation) reach the exact same
|
|
// string without duplicating the wording.
|
|
|
|
const COMMIT_MESSAGE_BASE_PROMPT = `You are generating a single git commit message.
|
|
Read the staged diff below and produce the message.
|
|
|
|
Rules:
|
|
- First line: imperative mood, <= 72 chars, no trailing period.
|
|
- Optional body: blank line, then wrapped at 72 chars explaining WHY.
|
|
- Output ONLY the commit message - no preamble, no code fences, no quotes.
|
|
- Do not include "Co-authored-by" trailers - Orca appends them after generation when configured.
|
|
|
|
Staged diff:
|
|
\`\`\`diff
|
|
{{DIFF}}
|
|
\`\`\`
|
|
`
|
|
|
|
export {
|
|
cleanGeneratedCommitMessage,
|
|
excerptAgentFailureOutput
|
|
} from './commit-message-agent-output'
|
|
|
|
/** Builds the final prompt sent to the agent. The custom suffix is appended verbatim
|
|
* when non-empty so the user can override style (Conventional Commits, gitmoji, …). */
|
|
export function buildCommitPrompt(diff: string, customSuffix: string): string {
|
|
const base = COMMIT_MESSAGE_BASE_PROMPT.replace('{{DIFF}}', diff)
|
|
const trimmedSuffix = customSuffix.trim()
|
|
if (!trimmedSuffix) {
|
|
return base
|
|
}
|
|
return `${base}\n\nAdditional user prompt:\n${trimmedSuffix}`
|
|
}
|
|
|
|
export const STAGED_DIFF_BYTE_BUDGET = 200_000
|
|
|
|
/** Splits a unified diff into one section per file, keyed on the `diff --git`
|
|
* header. Each section keeps the leading newline that preceded its header so
|
|
* concatenating the sections reproduces the original byte-for-byte. */
|
|
function splitDiffIntoFileSections(diff: string): string[] {
|
|
const boundary = '\ndiff --git '
|
|
const sections: string[] = []
|
|
let start = 0
|
|
let next = diff.indexOf(boundary)
|
|
while (next !== -1) {
|
|
// Include the boundary newline in the current section; the next section
|
|
// starts at the `diff --git` header itself.
|
|
sections.push(diff.slice(start, next + 1))
|
|
start = next + 1
|
|
next = diff.indexOf(boundary, start)
|
|
}
|
|
sections.push(diff.slice(start))
|
|
return sections
|
|
}
|
|
|
|
/** Clips one section to `limit` bytes on a line boundary so the agent never sees
|
|
* a half-written diff line, and records how many bytes were dropped. */
|
|
function clipSectionOnLineBoundary(section: string, limit: number): string {
|
|
if (section.length <= limit) {
|
|
return section
|
|
}
|
|
if (limit <= 0) {
|
|
return ''
|
|
}
|
|
|
|
const markerFor = (omitted: number): string => `\n...(diff truncated, ${omitted} bytes omitted)\n`
|
|
let marker = markerFor(section.length)
|
|
if (marker.length >= limit) {
|
|
return marker.slice(0, limit)
|
|
}
|
|
|
|
// Reserve headroom for the marker, then back up to the previous newline unless
|
|
// that would discard most of the budget (one very long line).
|
|
const target = limit - marker.length
|
|
const lineBreak = section.lastIndexOf('\n', target)
|
|
const cut = lineBreak > target / 2 ? lineBreak : target
|
|
const omitted = section.length - cut
|
|
marker = markerFor(omitted)
|
|
return `${section.slice(0, Math.min(cut, Math.max(0, limit - marker.length)))}${marker}`
|
|
}
|
|
|
|
/** Distributes `budget` across `sizes` by water-filling: everyone starts with an
|
|
* equal share, and the slack from files that fit is handed back to the files
|
|
* that don't. Keeps one huge generated file from starving the human-authored
|
|
* changes elsewhere in the diff. */
|
|
function allocateBudgetFairly(sizes: number[], budget: number): number[] {
|
|
const alloc: number[] = Array.from({ length: sizes.length }, () => 0)
|
|
let active = sizes.map((_, i) => i)
|
|
let remaining = budget
|
|
while (active.length > 0 && remaining > 0) {
|
|
const share = Math.floor(remaining / active.length)
|
|
if (share === 0) {
|
|
break
|
|
}
|
|
const stillActive: number[] = []
|
|
for (const i of active) {
|
|
const need = sizes[i] - alloc[i]
|
|
const grant = Math.min(need, share)
|
|
alloc[i] += grant
|
|
remaining -= grant
|
|
if (grant < need) {
|
|
stillActive.push(i)
|
|
}
|
|
}
|
|
active = stillActive
|
|
}
|
|
return alloc
|
|
}
|
|
|
|
/** Truncates a diff that exceeds the byte budget. Splits the budget fairly across
|
|
* files and clips on line boundaries, so a single oversized file can't crowd out
|
|
* the rest and the agent never receives a malformed diff. */
|
|
export function truncateDiffForPrompt(
|
|
diff: string,
|
|
budget: number = STAGED_DIFF_BYTE_BUDGET
|
|
): string {
|
|
if (diff.length <= budget) {
|
|
return diff
|
|
}
|
|
const sections = splitDiffIntoFileSections(diff)
|
|
if (sections.length <= 1) {
|
|
return clipSectionOnLineBoundary(diff, budget)
|
|
}
|
|
const allocations = allocateBudgetFairly(
|
|
sections.map((section) => section.length),
|
|
budget
|
|
)
|
|
return sections.map((section, i) => clipSectionOnLineBoundary(section, allocations[i])).join('')
|
|
}
|
|
|
|
export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}'
|
|
|
|
export type TokenizeCustomCommandResult =
|
|
| { ok: true; tokens: string[] }
|
|
| { ok: false; error: string }
|
|
|
|
// Why: deliberately POSIX-shell-style only for *grouping* (single + double
|
|
// quotes, backslash escapes inside double quotes). We do NOT expand `$VAR`,
|
|
// command substitution, backticks, globs, or `~`. The user's intent is
|
|
// "spawn this exact CLI" — adding shell semantics on top would create
|
|
// surprising behavior across platforms (especially Windows) and a security
|
|
// surface we don't need.
|
|
export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomCommandResult {
|
|
const tokens: string[] = []
|
|
let current = ''
|
|
let inToken = false
|
|
let quote: '"' | "'" | null = null
|
|
let i = 0
|
|
|
|
while (i < template.length) {
|
|
const ch = template[i]
|
|
if (quote) {
|
|
if (ch === '\\' && quote === '"' && i + 1 < template.length) {
|
|
current += template[i + 1]
|
|
i += 2
|
|
continue
|
|
}
|
|
if (ch === quote) {
|
|
quote = null
|
|
i++
|
|
// Why: leaving a quoted region still keeps the token open — `a"b"c`
|
|
// tokenizes as a single arg `abc`.
|
|
inToken = true
|
|
continue
|
|
}
|
|
current += ch
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch
|
|
inToken = true
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (ch === '\\' && i + 1 < template.length) {
|
|
current += template[i + 1]
|
|
inToken = true
|
|
i += 2
|
|
continue
|
|
}
|
|
|
|
if (/\s/.test(ch)) {
|
|
if (inToken) {
|
|
tokens.push(current)
|
|
current = ''
|
|
inToken = false
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
|
|
current += ch
|
|
inToken = true
|
|
i++
|
|
}
|
|
|
|
if (quote) {
|
|
return { ok: false, error: 'Unclosed quote in command template.' }
|
|
}
|
|
if (inToken) {
|
|
tokens.push(current)
|
|
}
|
|
return { ok: true, tokens }
|
|
}
|
|
|
|
export type CustomCommandPlan =
|
|
| { ok: true; binary: string; args: string[]; stdinPayload: string | null }
|
|
| { ok: false; error: string }
|
|
|
|
/**
|
|
* Parses a user-supplied command template into a spawn-ready binary + argv,
|
|
* substituting `{prompt}` with the agent prompt. When the template contains
|
|
* no `{prompt}`, the prompt is delivered via stdin (mirrors `claude -p`).
|
|
*
|
|
* Quoting is a tokenizer-level concern only — we use argv (no shell), so the
|
|
* substituted prompt is always passed as a single argument regardless of
|
|
* whether the template wrote `{prompt}` or `"{prompt}"`.
|
|
*/
|
|
export function planCustomCommand(template: string, prompt: string): CustomCommandPlan {
|
|
const tokenized = tokenizeCustomCommandTemplate(template)
|
|
if (!tokenized.ok) {
|
|
return { ok: false, error: tokenized.error }
|
|
}
|
|
if (tokenized.tokens.length === 0) {
|
|
return { ok: false, error: 'Custom command is empty.' }
|
|
}
|
|
const [binary, ...rest] = tokenized.tokens
|
|
if (!binary) {
|
|
return { ok: false, error: 'Custom command must start with a binary name.' }
|
|
}
|
|
|
|
const substitute = (token: string): string =>
|
|
token.includes(CUSTOM_PROMPT_PLACEHOLDER)
|
|
? token.split(CUSTOM_PROMPT_PLACEHOLDER).join(prompt)
|
|
: token
|
|
const usesPlaceholder = tokenized.tokens.some((t) => t.includes(CUSTOM_PROMPT_PLACEHOLDER))
|
|
if (usesPlaceholder) {
|
|
return {
|
|
ok: true,
|
|
binary: substitute(binary),
|
|
args: rest.map(substitute),
|
|
stdinPayload: null
|
|
}
|
|
}
|
|
return { ok: true, binary, args: rest, stdinPayload: prompt }
|
|
}
|