mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* Command Code TUI output scrape — that CLI lacks hooks, so working/done
|
|
* agent-status rows are seeded from its rendered status words and idle
|
|
* composer. Shared because main runs this per-PTY under side-effect authority
|
|
* (emitting command-code facts) while the renderer keeps the byte path for
|
|
* remote-runtime PTYs and the kill switch.
|
|
*/
|
|
import {
|
|
cleanCommandCodePromptCandidate,
|
|
isCommandCodeIdlePromptCandidate
|
|
} from './command-code-prompt-text'
|
|
import { stripTerminalControl } from './terminal-control-stripping'
|
|
import { escapeRegex } from './string-utils'
|
|
import { ownRetainedString } from './own-retained-string'
|
|
|
|
type CommandCodeOutputStatusDetector = {
|
|
observe: (data: string) => boolean
|
|
}
|
|
|
|
const RECENT_TEXT_LIMIT = 300
|
|
const STATUS_SCAN_TEXT_LIMIT = 4096
|
|
const COMMAND_CODE_STATUS_GLYPH_RE_SOURCE = '[·○◇☆✧⌘✻⎿]'
|
|
// Why: Command Code 0.27.3 randomizes its in-flight LLM status from this
|
|
// package-local list, so checking only a few examples misses real active turns.
|
|
const COMMAND_CODE_LLM_STATUS_WORDS = [
|
|
'Thinking',
|
|
'Pondering',
|
|
'Contemplating',
|
|
'Reasoning',
|
|
'Reflecting',
|
|
'Considering',
|
|
'Deliberating',
|
|
'Analyzing',
|
|
'Evaluating',
|
|
'Examining',
|
|
'Inspecting',
|
|
'Investigating',
|
|
'Reviewing',
|
|
'Researching',
|
|
'Studying',
|
|
'Exploring',
|
|
'Mapping',
|
|
'Tracing',
|
|
'Parsing',
|
|
'Processing',
|
|
'Calculating',
|
|
'Computing',
|
|
'Synthesizing',
|
|
'Planning',
|
|
'Outlining',
|
|
'Sketching',
|
|
'Drafting',
|
|
'Composing',
|
|
'Crafting',
|
|
'Building',
|
|
'Assembling',
|
|
'Constructing',
|
|
'Designing',
|
|
'Formulating',
|
|
'Structuring',
|
|
'Organizing',
|
|
'Preparing',
|
|
'Refining',
|
|
'Polishing',
|
|
'Honing',
|
|
'Tuning',
|
|
'Aligning',
|
|
'Connecting',
|
|
'Resolving',
|
|
'Weaving',
|
|
'Threading',
|
|
'Sculpting',
|
|
'Crystallizing',
|
|
'Channeling',
|
|
'Conjuring',
|
|
'Brewing',
|
|
'Working',
|
|
'Cogitating',
|
|
'Ruminating',
|
|
'Hypothesizing',
|
|
'Conceptualizing',
|
|
'Philosophizing',
|
|
'Deciphering',
|
|
'Demystifying',
|
|
'Articulating',
|
|
'Illuminating',
|
|
'Elaborating',
|
|
'Orchestrating',
|
|
'Choreographing',
|
|
'Architecting',
|
|
'Calibrating',
|
|
'Materializing',
|
|
'Visualizing',
|
|
'Harmonizing',
|
|
'Contemplificating',
|
|
'Supercalifragilisting',
|
|
'Bibbidibobbidibooing',
|
|
'Abracadabraing',
|
|
'Hocuspocusing',
|
|
'Razzmatazzing'
|
|
] as const
|
|
|
|
const LLM_STATUS_WORDS_RE_SOURCE = COMMAND_CODE_LLM_STATUS_WORDS.map(escapeRegex).join('|')
|
|
const ACTIVE_LLM_STATUS_RE = new RegExp(
|
|
`(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:${LLM_STATUS_WORDS_RE_SOURCE})\\b(?:…|\\.\\.\\.)`
|
|
)
|
|
const ACTIVE_EXECUTION_STATUS_RE = new RegExp(
|
|
`(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()`
|
|
)
|
|
const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[\u276F>]\s+Ask your question\.\.\./
|
|
const SEMVER_NUMBER_RE_SOURCE = '(?:0|[1-9]\\d*)'
|
|
const SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)'
|
|
const SEMVER_BUILD_IDENTIFIER_RE_SOURCE = '[0-9A-Za-z-]+'
|
|
const COMMAND_CODE_BANNER_RE = new RegExp(
|
|
`(?:^|[\\r\\n])[ \\t]*#[ \\t]+Command Code[ \\t]+v` +
|
|
`${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}` +
|
|
`(?:-${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE})*)?` +
|
|
`(?:\\+${SEMVER_BUILD_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_BUILD_IDENTIFIER_RE_SOURCE})*)?` +
|
|
`(?=[ \\t]*[\\r\\n])`
|
|
)
|
|
|
|
function cleanPromptCandidate(value: string): string {
|
|
return cleanCommandCodePromptCandidate(stripTerminalControl(value))
|
|
}
|
|
|
|
function isIdlePromptCandidate(value: string): boolean {
|
|
return isCommandCodeIdlePromptCandidate(value)
|
|
}
|
|
|
|
function isCommandCodeLaunchCommand(command: string | null | undefined): boolean {
|
|
if (!command) {
|
|
return false
|
|
}
|
|
return /(?:^|[\s;&|])(?:command-code|commandcode|cmdc)(?:\s|$)/.test(command)
|
|
}
|
|
|
|
function rawTextMayContainCommandCodeBanner(rawText: string): boolean {
|
|
// Why: every terminal pane observes this detector, but only Command Code
|
|
// panes need the ANSI/control stripping path. Use a broad no-false-negative
|
|
// letter prefilter so ANSI styling inside the banner words still works.
|
|
return rawText.includes('C') && rawText.includes('o') && rawText.includes('d')
|
|
}
|
|
|
|
// COMMAND_CODE_BANNER_RE requires a literal '#', and stripTerminalControl only ever removes
|
|
// characters, so raw bytes without one cannot produce a banner match.
|
|
const BANNER_REQUIRED_RAW_CHAR = '#'
|
|
|
|
/**
|
|
* Prefilter run against the raw chunk before the scan windows are built. Testing the whole carry
|
|
* plus chunk over-admits relative to the window test (the windows drop middle content), so it can
|
|
* never turn a match into a miss.
|
|
*/
|
|
function rawChunkMayContainCommandCodeBanner(previousRawText: string, data: string): boolean {
|
|
return (
|
|
data.includes(BANNER_REQUIRED_RAW_CHAR) || previousRawText.includes(BANNER_REQUIRED_RAW_CHAR)
|
|
)
|
|
}
|
|
|
|
function appendRecentRawText(previousRawText: string, data: string): string {
|
|
if (data.length >= RECENT_TEXT_LIMIT) {
|
|
return ownRetainedString(data.slice(-RECENT_TEXT_LIMIT))
|
|
}
|
|
return (previousRawText + data).slice(-RECENT_TEXT_LIMIT)
|
|
}
|
|
|
|
function buildStatusScanRawText(prefix: string, data: string): string {
|
|
const boundedPrefix =
|
|
prefix.length > RECENT_TEXT_LIMIT + 1 ? prefix.slice(-(RECENT_TEXT_LIMIT + 1)) : prefix
|
|
const dataBudget = STATUS_SCAN_TEXT_LIMIT - boundedPrefix.length
|
|
|
|
if (dataBudget <= 0) {
|
|
return boundedPrefix.slice(-STATUS_SCAN_TEXT_LIMIT)
|
|
}
|
|
if (data.length <= dataBudget) {
|
|
return boundedPrefix + data
|
|
}
|
|
|
|
const headBudget = Math.max(0, Math.floor((dataBudget - 1) / 2))
|
|
const tailBudget = Math.max(0, dataBudget - headBudget - 1)
|
|
const head = headBudget > 0 ? data.slice(0, headBudget) : ''
|
|
const tail = tailBudget > 0 ? data.slice(-tailBudget) : ''
|
|
// Why: pasted terminal echoes can produce megabyte-sized chunks. Status
|
|
// detection only needs chunk-boundary context plus recent output, so scan the
|
|
// start and end windows instead of regex-stripping the full PTY payload.
|
|
return `${boundedPrefix}${head}\n${tail}`
|
|
}
|
|
|
|
function patternOverlapsSanitizedText(
|
|
pattern: RegExp,
|
|
previousTextLength: number,
|
|
combinedText: string
|
|
): boolean {
|
|
const re = new RegExp(pattern.source, 'g')
|
|
for (const match of combinedText.matchAll(re)) {
|
|
const start = match.index ?? 0
|
|
if (start + match[0].length > previousTextLength) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type StatusScanContext = {
|
|
combinedText: string
|
|
previousTextLength: number
|
|
combinedTextWithChunkBoundary: string
|
|
previousTextWithChunkBoundaryLength: number
|
|
}
|
|
|
|
function patternOverlapsStatusContext(pattern: RegExp, context: StatusScanContext): boolean {
|
|
return (
|
|
patternOverlapsSanitizedText(pattern, context.previousTextLength, context.combinedText) ||
|
|
patternOverlapsSanitizedText(
|
|
pattern,
|
|
context.previousTextWithChunkBoundaryLength,
|
|
context.combinedTextWithChunkBoundary
|
|
)
|
|
)
|
|
}
|
|
|
|
function isActiveStatusText(context: StatusScanContext): boolean {
|
|
return (
|
|
patternOverlapsStatusContext(ACTIVE_LLM_STATUS_RE, context) ||
|
|
patternOverlapsStatusContext(ACTIVE_EXECUTION_STATUS_RE, context)
|
|
)
|
|
}
|
|
|
|
function isIdlePromptText(context: StatusScanContext): boolean {
|
|
return patternOverlapsStatusContext(IDLE_PROMPT_RE, context)
|
|
}
|
|
|
|
export function createCommandCodeOutputStatusDetector(args: {
|
|
startupCommand?: string | null
|
|
/** Continuity seed for a detector created long after launch (parked watchers):
|
|
* the banner is off-screen by then, so a turn known to be in flight both arms
|
|
* the scrape and carries its prompt into the idle-composer done check. */
|
|
inFlightTurn?: { prompt: string } | null
|
|
onWorking: (prompt: string) => void
|
|
onDone?: (prompt: string) => void
|
|
}): CommandCodeOutputStatusDetector {
|
|
let hasSeenCommandCodeUi =
|
|
isCommandCodeLaunchCommand(args.startupCommand) || Boolean(args.inFlightTurn)
|
|
let lastSubmittedPrompt = args.inFlightTurn?.prompt ?? ''
|
|
let recentRawText = ''
|
|
|
|
return {
|
|
observe(data: string): boolean {
|
|
const previousRawText = recentRawText
|
|
recentRawText = appendRecentRawText(previousRawText, data)
|
|
// Why before the windows: a non-Command-Code pane pays two ~4KB string builds per chunk
|
|
// otherwise, only to fail the same prefilter a few lines later.
|
|
if (!hasSeenCommandCodeUi && !rawChunkMayContainCommandCodeBanner(previousRawText, data)) {
|
|
return false
|
|
}
|
|
const scanRawText = buildStatusScanRawText(previousRawText, data)
|
|
const scanRawTextWithChunkBoundary = previousRawText
|
|
? buildStatusScanRawText(`${previousRawText}\n`, data)
|
|
: scanRawText
|
|
|
|
if (!hasSeenCommandCodeUi) {
|
|
if (!rawTextMayContainCommandCodeBanner(scanRawText)) {
|
|
return false
|
|
}
|
|
const scanText = stripTerminalControl(scanRawText)
|
|
const scanTextWithChunkBoundary = stripTerminalControl(scanRawTextWithChunkBoundary)
|
|
const previousTextWithChunkBoundaryLength = previousRawText
|
|
? stripTerminalControl(`${previousRawText}\n`).length
|
|
: 0
|
|
if (
|
|
!COMMAND_CODE_BANNER_RE.test(scanText) &&
|
|
!COMMAND_CODE_BANNER_RE.test(
|
|
scanTextWithChunkBoundary.slice(previousTextWithChunkBoundaryLength)
|
|
)
|
|
) {
|
|
return false
|
|
}
|
|
hasSeenCommandCodeUi = true
|
|
}
|
|
|
|
const scanText = stripTerminalControl(scanRawText)
|
|
const scanTextWithChunkBoundary = stripTerminalControl(scanRawTextWithChunkBoundary)
|
|
const previousTextLength = previousRawText ? stripTerminalControl(previousRawText).length : 0
|
|
const previousTextWithChunkBoundaryLength = previousRawText
|
|
? stripTerminalControl(`${previousRawText}\n`).length
|
|
: 0
|
|
const statusContext: StatusScanContext = {
|
|
combinedText: scanText,
|
|
previousTextLength,
|
|
combinedTextWithChunkBoundary: scanTextWithChunkBoundary,
|
|
previousTextWithChunkBoundaryLength
|
|
}
|
|
for (const promptMatch of scanText.matchAll(
|
|
/(?:^|[\r\n])\s*[\u276F>]\s+([^\r\n]+)(?=[\r\n])/g
|
|
)) {
|
|
const prompt = cleanPromptCandidate(promptMatch[1] ?? '')
|
|
if (prompt && !isIdlePromptCandidate(prompt)) {
|
|
lastSubmittedPrompt = prompt
|
|
}
|
|
}
|
|
// Why: Command Code lacks a prompt-start hook. Its TUI prints these
|
|
// status words while a submitted prompt is actively running, including
|
|
// no-tool turns that would otherwise jump straight from idle to done.
|
|
if (isActiveStatusText(statusContext)) {
|
|
args.onWorking(lastSubmittedPrompt)
|
|
return true
|
|
}
|
|
// Why: Command Code does not reliably emit a Stop hook for no-tool turns.
|
|
// When a submitted prompt has returned to the idle composer, let the pane
|
|
// connection settle-check the current row and mark that turn done.
|
|
if (lastSubmittedPrompt && isIdlePromptText(statusContext)) {
|
|
args.onDone?.(lastSubmittedPrompt)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|