Files
orca/src/shared/harness-injected-user-turns.ts

93 lines
3.6 KiB
TypeScript

// Why: agent harnesses (Claude Code and its forks) inject machinery into the
// conversation as user-role turns — background task notifications, system
// reminders, inter-agent messages, slash-command envelopes, local-command
// output, interruption and compaction notices. These fire user-prompt hooks
// and land in transcripts, but they are not something the user typed, so
// prompt-derived UI must not surface them.
//
// We match only tags we have observed from harnesses, never a broad kebab
// shape: a real prompt starting with a custom `<my-element>` or a Grok
// `<user_query>` envelope is a genuine user turn, and misclassifying it would
// hide the turn (drop it from transcripts, demote its session title, or leave
// the agent visibly done after an interrupt).
const LEADING_TAG_NAME = /^<([a-z][a-z0-9-]*)(?:[\s>]|$)/
// Consumers must only treat tags we have observed from harnesses as machinery;
// arbitrary kebab tags can be genuine user code.
const KNOWN_HARNESS_TAG_NAMES = new Set([
'agent-message',
'bash-input',
'bash-stderr',
'bash-stdout',
'command-args',
'command-message',
'command-name',
'cross-session-message',
'fork-boilerplate',
'local-command-caveat',
'local-command-stderr',
'local-command-stdout',
'mcp-polling-update',
'mcp-resource-update',
'system-reminder',
'task-notification',
'teammate-message',
'user-memory-input',
'user-prompt-submit-hook'
])
// Injected turns identified by a leading string rather than a known tag name:
// the harness only emits <channel> in its attributed `<channel source=…>` form
// (a bare <channel> is a real RSS/XML paste), plus prose deliveries and notices.
const COMPACT_CONTINUATION_PREFIX = 'this session is being continued from a previous conversation'
const HARNESS_INJECTED_TURN_PREFIXES = [
'<channel source=',
'[request interrupted',
'a message arrived from ',
'another claude session sent a message',
'no response requested.',
'caveat: the messages below were generated by the user while running local commands',
COMPACT_CONTINUATION_PREFIX
]
// Why: classification only inspects leading tags/prefixes. Cap the toLowerCase
// copy so vault-scan / prompt-seed paths stay O(1) on multi-KB pastes.
const HARNESS_CLASSIFY_HEAD_LIMIT = 256
const HARNESS_CLASSIFY_LEADING_WS_LIMIT = 64
/** True only for observed harness shapes. Match on trimmed, lowercased text.
* Unknown kebab tags stay user turns — only tags we have observed count. */
export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
const normalized = normalizedHarnessTurnHead(text)
if (!normalized) {
return false
}
const tagName = LEADING_TAG_NAME.exec(normalized)?.[1]
if (tagName && KNOWN_HARNESS_TAG_NAMES.has(tagName)) {
return true
}
return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
/** True only for the observed post-compaction continuation prompt. */
export function isCompactContinuationUserTurnText(text: string): boolean {
return normalizedHarnessTurnHead(text).startsWith(COMPACT_CONTINUATION_PREFIX)
}
function normalizedHarnessTurnHead(text: string): string {
let start = 0
const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT)
while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) {
start += 1
}
if (start >= text.length) {
return ''
}
const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
return text.slice(start, headEnd).toLowerCase()
}
function isAsciiWhitespace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13 || code === 12
}