/** * `vclaw video match-highlights` — the PURE verification passes. * * The one-pass segment listing is good at timestamps and bad at completeness and * at outcome labels. Measured on the live 2026-09-16 run (part 1, 91 minutes): * the listing produced 120 deliveries, of which 21 were duplicates and 17 real * legal balls were missing across 13 gaps; of 13 claimed fours only 9 were real, * and 5 real fours sat on balls the listing had skipped entirely. * * So three passes sit on top of the listing, ported from the proven * `verify_part.py` harness: * * 1. **Gap pass** (completeness). Long silences between catalogued deliveries * are cut into stretch reels and one agentic call per batch lists whatever * deliveries are inside them. Recovered rows are merged back and deduped. * 2. **Judge 1** (precision). Every candidate boundary is cut into one reel and * one agentic call rules on each window: did the ball actually reach the rope? * 3. **Judge 2** (recall inside precision). A candidate the judge rejected, but * which the listing had called a boundary, gets its whole stretch re-examined * — the boundary is often on a ball the listing skipped, not on the one it * catalogued. * * Then every event is RELABELLED from the judged verdicts, because the listing's * own `t` values are the part that cannot be trusted. * * Everything here is a deterministic function of its inputs. The reels, uploads * and calls live in `src/cli/handlers/match-highlights-passes.ts`. */ import type { MatchEvent } from './match-highlights.js'; import type { MatchWindow } from './match-highlights-cut.js'; /* ------------------------------------------------------------------ * * Tunables — the values the live run settled on * ------------------------------------------------------------------ */ /** A gap counts as "something is missing" past `max(floor, multiplier × median gap)`. */ export const GAP_MEDIAN_MULTIPLIER = 2.2; export const GAP_FLOOR_SECONDS = 50; /** Fallback median when the listing is too short to have gaps. */ export const GAP_MEDIAN_FALLBACK = 20; /** * A stretch longer than this is not scanned. A segment that failed leaves a hole * the size of a whole segment; re-uploading that as a "gap" would cost as much as * the original call and is not what the pass is for. */ export const MAX_STRETCH_SECONDS = 300; /** Stretches per gap-pass reel. One call per batch keeps each prompt legible. */ export const GAP_BATCH_SIZE = 8; /** * Total source seconds per gap reel. Eight 300-second stretches would be a * 40-minute re-encode read into ONE Buffer for the upload; the live run saw a * 782 MB reel that way. Whichever cap is reached first closes the batch. */ export const GAP_BATCH_MAX_SECONDS = 900; /** Two events whose starts are within this are one event. */ export const DEDUPE_WITHIN_SECONDS = 4; /** A window at least this long is judged even when the listing gave it a dull label. */ export const JUDGE_LONG_WINDOW_SECONDS = 14; /** Padding for the gap-pass stretch reels. */ export const GAP_REEL_PRE = 1.5; export const GAP_REEL_POST = 2.0; /** Padding for the judge reels. */ export const JUDGE_REEL_PRE = 1.5; export const JUDGE_REEL_POST = 3.0; /** How long past the last event a trailing disputed stretch may run. */ export const DISPUTED_TAIL_SECONDS = 30; /* ------------------------------------------------------------------ * * Window tables — how a verdict finds its way back to an event * ------------------------------------------------------------------ */ export interface WindowTableRow { /** Index announced to the model. */ i: number; /** Position of this window inside the concatenated reel. */ reelStart: number; reelEnd: number; /** Position in the source recording. */ start: number; end: number; note: string; /** Indices into the array the windows were planned from. */ sources: number[]; } /** * Lay the cut windows out along the reel they will be concatenated into. * * `sources` is carried through from {@link planWindows}. Recovering the event by * un-padding (`start + pre`) is NOT safe: a window clamped at 0 or at the * duration no longer carries its padding, and a merged window would hand one * verdict to two events. */ export function buildWindowTable(windows: readonly MatchWindow[]): WindowTableRow[] { const table: WindowTableRow[] = []; let cursor = 0; for (const [i, window] of windows.entries()) { const length = window.end - window.start; table.push({ i, reelStart: round1(cursor), reelEnd: round1(cursor + length), start: round1(window.start), end: round1(window.end), note: window.note, sources: [...window.sources], }); cursor += length; } return table; } /** The window list as the prompt states it. */ export function windowsText(table: readonly WindowTableRow[], label = 'note'): string { return table .map((w) => `window ${w.i}: reel ${w.reelStart}s-${w.reelEnd}s (source ${w.start.toFixed(1)}s) ${label}: ${w.note}`) .join('\n'); } /** Clip seconds → source seconds for one window. */ export function clipToSource(window: WindowTableRow, clipSeconds: number): number { return round1(clipSeconds + (window.start - window.reelStart)); } function round1(value: number): number { return Math.round(value * 10) / 10; } /** Median of a non-empty list; the even case averages the two middle values. */ function median(values: readonly number[]): number { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } /* ------------------------------------------------------------------ * * 1. Gap pass * ------------------------------------------------------------------ */ export interface GapStretch { /** Source seconds the stretch spans (between two catalogued events). */ s: number; e: number; n: string; } export interface GapDetection { stretches: GapStretch[]; medianGap: number; threshold: number; /** Stretches longer than {@link MAX_STRETCH_SECONDS}, left alone; usually a failed segment. */ oversized: number; } /** * Find the silences that are long enough to hide a delivery. * * The gap is measured END of one event to START of the next. The threshold is * deliberately loose: a stretch that turns out to hold nothing costs an empty * array in the answer, while a missed stretch costs a ball. On the live run this * flagged 21 stretches, of which 13 held the 17 recovered balls. */ export function detectGapStretches( events: readonly MatchEvent[], options: { medianMultiplier?: number; floorSeconds?: number; maxStretchSeconds?: number } = {}, ): GapDetection { const sorted = [...events].sort((a, b) => a.s - b.s); const positiveGaps: number[] = []; for (let i = 0; i < sorted.length - 1; i += 1) { const gap = sorted[i + 1].s - sorted[i].e; if (gap > 0) positiveGaps.push(gap); } const medianGap = positiveGaps.length > 0 ? median(positiveGaps) : GAP_MEDIAN_FALLBACK; const threshold = Math.max( options.floorSeconds ?? GAP_FLOOR_SECONDS, (options.medianMultiplier ?? GAP_MEDIAN_MULTIPLIER) * medianGap, ); const maxStretch = options.maxStretchSeconds ?? MAX_STRETCH_SECONDS; const stretches: GapStretch[] = []; let oversized = 0; for (let i = 0; i < sorted.length - 1; i += 1) { const span = sorted[i + 1].s - sorted[i].e; if (span <= threshold) continue; if (span > maxStretch) { oversized += 1; continue; } stretches.push({ s: sorted[i].e, e: sorted[i + 1].s, n: `gap after delivery at ${sorted[i].s.toFixed(0)}s`, }); } return { stretches, medianGap: round1(medianGap), threshold: round1(threshold), oversized }; } /** * Split the stretches into one reel's worth each: at most `size` stretches AND * at most `maxSeconds` of source per reel. The seconds cap matters because the * reel is re-encoded and then read into ONE Buffer to upload. */ export function batchStretches( stretches: readonly GapStretch[], size = GAP_BATCH_SIZE, maxSeconds = GAP_BATCH_MAX_SECONDS, ): GapStretch[][] { const batches: GapStretch[][] = []; let current: GapStretch[] = []; let seconds = 0; for (const stretch of stretches) { const span = stretch.e - stretch.s; // Never emit an empty batch: a single oversized stretch gets its own reel. if (current.length > 0 && (current.length >= size || seconds + span > maxSeconds)) { batches.push(current); current = []; seconds = 0; } current.push(stretch); seconds += span; } if (current.length > 0) batches.push(current); return batches; } export interface MappedGapRows { recovered: MatchEvent[]; /** Rows that named an unknown stretch, carried a bad number, or landed outside their stretch. */ dropped: number; } /** * Map one gap-pass answer (`{w, s, e, t, n}` in CLIP seconds) back onto the * source timeline. A row whose start falls outside its own stretch (±2 s of the * padded window) is dropped and counted: the model has drifted onto a * neighbouring window and its timestamp would be wrong. */ export function mapGapRows(payload: unknown, table: readonly WindowTableRow[]): MappedGapRows { const recovered: MatchEvent[] = []; let dropped = 0; if (!Array.isArray(payload)) return { recovered, dropped }; for (const raw of payload) { if (!raw || typeof raw !== 'object') { dropped += 1; continue; } const row = raw as { w?: unknown; s?: unknown; e?: unknown; t?: unknown; n?: unknown }; const windowIndex = Number(row.w); const window = Number.isInteger(windowIndex) ? table[windowIndex] : undefined; const s = finiteNumber(row.s); const e = finiteNumber(row.e); if (!window || s === undefined || e === undefined) { dropped += 1; continue; } const sourceStart = clipToSource(window, s); const sourceEnd = clipToSource(window, e); if (sourceStart < window.start - 2 || sourceStart > window.end + 2) { dropped += 1; continue; } recovered.push({ s: sourceStart, e: sourceEnd, ...(typeof row.t === 'string' && row.t !== '' ? { t: row.t } : {}), ...(typeof row.n === 'string' && row.n !== '' ? { n: row.n } : {}), segment: 'gap-pass', }); } return { recovered, dropped }; } /* ------------------------------------------------------------------ * * 2. Dedupe * ------------------------------------------------------------------ */ export interface DedupeResult { events: MatchEvent[]; removed: number; } /** * Two starts within `withinSeconds` are one delivery: the listing repeats itself * across a segment seam, and the gap pass re-finds a ball that was catalogued * after all. The survivor keeps the earlier start and the later end, joins the * notes, and is PROMOTED to an interesting label if the absorbed row had one — * a duplicate that says "four" is worth more than one that says "dot". */ export function dedupeEvents( events: readonly MatchEvent[], options: { withinSeconds?: number; interestingTypes?: readonly string[] } = {}, ): DedupeResult { const within = options.withinSeconds ?? DEDUPE_WITHIN_SECONDS; const interesting = new Set(options.interestingTypes ?? ['four', 'six', 'wicket']); const sorted = [...events].sort((a, b) => (a.s === b.s ? a.e - b.e : a.s - b.s)); const kept: MatchEvent[] = []; for (const event of sorted) { const last = kept[kept.length - 1]; if (last && event.s - last.s < within) { last.e = Math.max(last.e, event.e); if (event.n !== undefined && event.n !== '') { last.n = last.n === undefined || last.n === '' ? event.n : `${last.n} | ${event.n}`; } if (event.t !== undefined && interesting.has(event.t)) last.t = event.t; continue; } kept.push({ ...event }); } return { events: kept, removed: sorted.length - kept.length }; } /* ------------------------------------------------------------------ * * 3. Judge * ------------------------------------------------------------------ */ export interface JudgeVerdict { /** Window index inside the judged reel. */ i: number; /** Source seconds of the event this verdict is about. */ start: number; end: number; boundary: boolean; kind?: string; evidence?: string; via: 'judge-1' | 'judge-2'; } /** * Which events get judged: the ones the listing labelled interesting, PLUS any * window at least {@link JUDGE_LONG_WINDOW_SECONDS} long. The long-window clause * is the recall lever — a ball that reached the rope takes a while to come back, * so a long window is suspicious regardless of what the listing called it. * Returns indices into `events`. */ export function selectJudgeCandidates( events: readonly MatchEvent[], types: readonly string[], longWindowSeconds = JUDGE_LONG_WINDOW_SECONDS, ): number[] { const keep = new Set(types); const picked: number[] = []; for (const [index, event] of events.entries()) { const labelled = event.t !== undefined && keep.has(event.t); if (labelled || event.e - event.s >= longWindowSeconds) picked.push(index); } return picked; } /** Map a judge-1 answer (`{i, boundary, kind, evidence}`) onto the events it judged. */ export function mapJudgeVerdicts( payload: unknown, table: readonly WindowTableRow[], events: readonly MatchEvent[], ): JudgeVerdict[] { const verdicts: JudgeVerdict[] = []; if (!Array.isArray(payload)) return verdicts; for (const raw of payload) { if (!raw || typeof raw !== 'object') continue; const row = raw as { i?: unknown; boundary?: unknown; kind?: unknown; evidence?: unknown }; const index = Number(row.i); const window = Number.isInteger(index) ? table[index] : undefined; if (!window) continue; // The window's `sources` says exactly which events it covers; a judge reel is // planned with mergeOverlaps:false so that is normally one event. for (const sourceIndex of window.sources) { const event = events[sourceIndex]; if (!event) continue; verdicts.push({ i: window.i, start: event.s, end: event.e, boundary: row.boundary === true, ...(typeof row.kind === 'string' && row.kind !== '' ? { kind: row.kind } : {}), ...(typeof row.evidence === 'string' && row.evidence !== '' ? { evidence: row.evidence } : {}), via: 'judge-1' as const, }); } } return verdicts; } /** * The stretches judge 2 re-examines: a candidate the judge REJECTED that the * listing had nonetheless called a boundary. On the live run this is where the * real four usually was — on the very next ball, which the listing had skipped. * Each stretch runs from that event's start to the following event's start. */ export function selectDisputedStretches( events: readonly MatchEvent[], verdicts: readonly JudgeVerdict[], types: readonly string[], durationSeconds: number, ): GapStretch[] { const keep = new Set(types); const stretches: GapStretch[] = []; for (const verdict of verdicts) { if (verdictConfirms(verdict, types)) continue; const index = events.findIndex((event) => Math.abs(event.s - verdict.start) < 3); if (index < 0) continue; const event = events[index]; if (event.t === undefined || !keep.has(event.t)) continue; const next = index + 1 < events.length ? events[index + 1].s : Math.min(durationSeconds, event.e + DISPUTED_TAIL_SECONDS); if (next <= event.s) continue; stretches.push({ s: event.s, e: next, n: `listing said ${event.t}: ${event.n ?? ''}`.trim() }); } return stretches; } /** * Map a judge-2 answer (`{i, boundary, kind, runUpStart, deadBall, evidence}`) * back to source seconds. Unlike judge 1 these verdicts carry their OWN * timestamps, because the whole point is that the boundary was on a different * ball from the one the stretch starts with. */ export function mapDisputedVerdicts(payload: unknown, table: readonly WindowTableRow[]): JudgeVerdict[] { const verdicts: JudgeVerdict[] = []; if (!Array.isArray(payload)) return verdicts; for (const raw of payload) { if (!raw || typeof raw !== 'object') continue; const row = raw as { i?: unknown; boundary?: unknown; kind?: unknown; evidence?: unknown; runUpStart?: unknown; deadBall?: unknown }; const index = Number(row.i); const window = Number.isInteger(index) ? table[index] : undefined; if (!window || row.boundary !== true) continue; const runUp = finiteNumber(row.runUpStart); const dead = finiteNumber(row.deadBall); if (runUp === undefined || dead === undefined) continue; const start = clipToSource(window, runUp); const end = clipToSource(window, dead); if (end <= start) continue; verdicts.push({ i: window.i, start, end, boundary: true, kind: typeof row.kind === 'string' && row.kind !== '' ? row.kind : 'four', ...(typeof row.evidence === 'string' && row.evidence !== '' ? { evidence: row.evidence } : {}), via: 'judge-2' as const, }); } return verdicts; } /** * Does this verdict confirm its event? * * `boundary` asks one question — did the ball reach the rope — so a judged * WICKET comes back `{boundary: false, kind: "wicket"}`. Reading `boundary` * alone therefore threw away real wickets from a reel whose default `--types` * includes them. A verdict also confirms when its `kind` is one of the types * being kept, which generalises past cricket. */ export function verdictConfirms(verdict: JudgeVerdict, types: readonly string[]): boolean { if (verdict.boundary) return true; const kind = verdict.kind; return kind !== undefined && kind !== '' && kind !== 'none' && types.includes(kind); } /* ------------------------------------------------------------------ * * 4. Relabel * ------------------------------------------------------------------ */ export interface RelabelResult { events: MatchEvent[]; /** Events promoted to a judged label. */ promoted: number; /** Events the listing called a boundary that the judge did not confirm. */ demoted: number; } /** * Replace the listing's outcome labels with the judged ones. * * This is the step that makes `--types` mean something. The listing's `t` is the * part the live run found unreliable: 13 claimed fours, 9 real. Every event * within 4 s of a confirmed boundary takes that boundary's kind; every other * event still carrying an unconfirmed interesting label is demoted to * `demotedType`. Judge-2 boundaries that match no existing event are ADDED, * because they sit on balls the listing never catalogued. */ export function relabelEvents( events: readonly MatchEvent[], boundaries: readonly JudgeVerdict[], options: { types?: readonly string[]; demotedType?: string; withinSeconds?: number } = {}, ): RelabelResult { const keep = new Set(options.types ?? ['four', 'six', 'wicket']); const demotedType = options.demotedType ?? 'runs'; const within = options.withinSeconds ?? DEDUPE_WITHIN_SECONDS; const confirmed = boundaries.filter((verdict) => verdictConfirms(verdict, [...keep])); const merged: MatchEvent[] = events.map((event) => ({ ...event })); for (const verdict of confirmed) { if (verdict.via !== 'judge-2') continue; if (merged.some((event) => Math.abs(event.s - verdict.start) < within)) continue; merged.push({ s: verdict.start, e: verdict.end, ...(verdict.kind ? { t: verdict.kind } : {}), ...(verdict.evidence ? { n: verdict.evidence } : {}), segment: 'judge-2', }); } merged.sort((a, b) => (a.s === b.s ? a.e - b.e : a.s - b.s)); let promoted = 0; let demoted = 0; for (const event of merged) { const match = confirmed.find((verdict) => Math.abs(verdict.start - event.s) < within); if (match) { if (match.kind !== undefined && event.t !== match.kind) promoted += 1; if (match.kind !== undefined) event.t = match.kind; continue; } if (event.t !== undefined && keep.has(event.t)) { event.t = demotedType; demoted += 1; } } return { events: merged, promoted, demoted }; } /* ------------------------------------------------------------------ * * Sport prompt table * ------------------------------------------------------------------ */ export interface SportPrompts { id: string; /** Default event types for the highlights reel. */ highlightTypes: readonly string[]; /** The per-segment listing prompt. */ listing: string; /** "What did the listing miss in these stretches?" */ gap(table: readonly WindowTableRow[]): string; /** "Did each of these actually happen?" */ judge(table: readonly WindowTableRow[]): string; /** "The listing says there was one here and you said no — look at the whole stretch." */ disputed(table: readonly WindowTableRow[]): string; /** * "Each of these windows ends on a scorer's keystroke — find the ball it was for." * Used by `--events-file … --place`, where the events are already known and only * their timing is being bought. `postSeconds` is the window's own tail, so the prompt * cannot drift from the geometry it describes. */ place(table: readonly WindowTableRow[], options?: { postSeconds?: number }): string; } const CRICKET_LISTING = `This is a recording of an amateur club cricket match (one fixed camera behind the bowler's arm or side-on). List EVERY delivery that is bowled in this recording, in order. For each delivery give: - "s": the second (number) at which the bowler starts the run-up (walks/jogs in from the mark). Include ~1 s before the run-up begins. - "e": the second (number) at which the ball is dead: the shot has been played and the ball has been fielded, reached the boundary, or the wicket celebration has settled. About 2-3 s after the shot. - "t": one of dot | runs | four | six | wicket | wide | noball | other - "n": a note of at most 8 words (e.g. "cover drive for four", "bowled middle stump", "leg-side wide"). Skip everything that is not a delivery: walking between overs, drinks, field changes, replays, idle camera, chatter. Timestamps are seconds from the start of THIS file as a plain number (e.g. 754.5), never mm:ss. Return ONLY a JSON array of objects {"s","e","t","n"}, no prose, no markdown fences. `; /** * One entry per sport. The prompts are the exact wording the live run used; a new * sport is a data addition here, not a code change. Scoreboard OCR is deliberately * NOT part of this: it was broadcast-specific and is a separate concern. */ export const SPORT_PROMPTS: Record = { cricket: { id: 'cricket', highlightTypes: ['four', 'six', 'wicket'], listing: CRICKET_LISTING, gap: (table) => `This clip is ${table.length} stretches of an amateur cricket match cut back to back. Each stretch is the time BETWEEN two deliveries that were already catalogued, and may contain zero, one or more further deliveries that were missed.\n` + `The stretches, by time in THIS clip, are:\n${windowsText(table, 'context')}\n\n` + 'List EVERY delivery bowled inside these stretches (bowler runs in and bowls). For each: "w": stretch index, "s": clip seconds where the run-up starts (~1 s before), "e": clip seconds when the ball is dead (~2-3 s after the shot), "t": dot|runs|four|six|wicket|wide|noball|other, "n": note ≤8 words. ' + 'Ignore walking, drinks, field changes. Return ONLY a JSON array of {"w","s","e","t","n"}; an empty array if there are none.', judge: (table) => `This clip is a sequence of ${table.length} cricket deliveries cut back to back from one match recording; a live scoreboard overlay (team total RUNS/WICKETS and overs) is at the bottom.\n` + `The windows, by time in THIS clip, are:\n${windowsText(table)}\n\n` + "For EACH window answer TWO questions. (1) Did the ball actually reach or cross the boundary rope (a four or a six) on that delivery? Use the ball's path, fielders giving up the chase, the umpire's signal, the batters not running, and the scoreboard rising by 4 or 6. A ball chased down and fielded inside the rope is NOT a boundary even if the batters run. (2) Did a WICKET fall on that delivery (bowled, caught, lbw, run out, stumped)? A wicket is reported through \"kind\", and a wicket that is not also a boundary still has \"boundary\": false — so set \"kind\": \"wicket\" whenever the batter is out, regardless of the first answer.\n" + 'Return ONLY a JSON array: {"i": , "boundary": true|false, "kind": "four"|"six"|"wicket"|"none", "evidence": "<≤12 words>"}.', disputed: (table) => `This clip is ${table.length} stretches of a cricket match cut back to back; a live scoreboard overlay is at the bottom. Each stretch runs from just before one delivery's run-up until the start of the following catalogued delivery, and may contain a boundary on a delivery that is NOT at the start of the stretch.\n` + `The stretches, by time in THIS clip, are:\n${windowsText(table, 'context')}\n\n` + "For EACH stretch: find the delivery, if any, on which the ball actually reached or crossed the boundary rope (ball to the rope, fielders giving up, umpire's signal, batters not running, scoreboard +4/+6). Report the clip time the bowler starts the run-up and the clip time the ball is dead. If no boundary is visible, say so.\n" + 'Return ONLY a JSON array: {"i": , "boundary": true|false, "kind": "four"|"six"|"wicket"|"none", "runUpStart": , "deadBall": , "evidence": "<≤12 words>"}.', place: (table, options) => `This clip is ${table.length} windows of an amateur cricket match cut back to back; a live scoreboard overlay (team total RUNS/WICKETS and overs) is at the bottom.\n` + `Each window ends about ${options?.postSeconds ?? 3} seconds AFTER the moment the scorer recorded the outcome named in its note, so the scoreboard change itself is visible in the last few seconds of the window. ` + 'The scorer is behind the play: measured over this match, typically about 10 seconds, nine times out of ten within 22, and slower for a wicket than for a boundary (nine times out of ten within 26). So the delivery the note is about is in the LATTER part of its window, and the earlier part is the previous ball, walking about, or field changes. The window note gives the scoreboard before and after that ball, so the scoreboard rising by that amount is what tells you which delivery it was.\n' + `The windows, by time in THIS clip, are:\n${windowsText(table, 'scored')}\n\n` + 'For EACH window find that ONE delivery and report, in CLIP seconds: "runUpStart" — the second the bowler starts running in (not the previous ball, not the walk back); "shot" — the second bat meets ball, or the second the dismissal happens; "deadBall" — the second the ball is dead, the shot played and the ball fielded or over the rope or the celebration settled. If the ball is still in play when the window ends, report "deadBall" as the window\'s last second rather than guessing past it.\n' + 'Set "kind" to four, six or wicket for what you see. Use "kind": "none" ONLY when the delivery the note describes is not visible anywhere in that window — then leave the three times null. Do not guess a time to fill a window in.\n' + 'Return ONLY a JSON array: {"i": , "runUpStart": , "shot": , "deadBall": , "kind": "four"|"six"|"wicket"|"none", "evidence": "<≤12 words>"}.', }, }; export const DEFAULT_SPORT = 'cricket'; export function resolveSport(id: string | undefined): SportPrompts { const sport = SPORT_PROMPTS[(id ?? DEFAULT_SPORT).trim().toLowerCase()]; if (!sport) { throw new Error(`unknown sport "${id}"; available: ${Object.keys(SPORT_PROMPTS).sort().join(', ')}`); } return sport; } /** * The one retry this command allows. `too many tool calls` means the agentic * loop ran out of tool budget scanning a long reel — a different failure from * `incomplete` (the output cap), which is terminal and never retried. Retrying * with an instruction to inspect fewer, larger windows is what unstuck the live * run's longest gap batches. */ export function isToolCallOverflow(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error ?? ''); return /too many tool calls/i.test(message); } export const TOOL_OVERFLOW_RETRY_PREFIX = "[retry] The previous attempt failed with: 'Model generated too many tool calls'. Inspect fewer, larger windows this time.\n\n"; function finiteNumber(value: unknown): number | undefined { if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; }