// The /ask search agent: one shared loop, two gather transports. // // A turn is: GATHER (let the model search the transcripts — possibly several // times, refining as it reads results) → ANSWER (stream a cited answer over the // excerpts it gathered). The gather transport is either provider-agnostic // "scripted" (a SEARCH:/DONE text protocol) or provider-native function-calling; // everything else — running searches, accumulating/deduping videos, the answer // phase, the event stream the UI renders — is shared. Auto-detect picks native // where supported and falls back to scripted on a capability error. import { askOnce, askStream, type ChatMessage, type DebugCall, type FinishReason, type Provider, } from "./askProvider"; import { retrieve, type RetrievedVideo } from "./askRetrieval"; import { accumulationSystemPrompt, answerSystemPrompt, applyReportPatch, buildAccumulationContent, buildApiMessages, buildGroundedContent, collectPriorPool, gatherSystemPrompt, type UiMessage, } from "./askConversation"; import { anthropicGather } from "./nativeTools/anthropic"; import { openaiGather } from "./nativeTools/openai"; import { geminiGather } from "./nativeTools/gemini"; import { abortError, ToolsUnavailableError, type NativeGatherContext, } from "./nativeTools/shared"; import { matchAliases, type SearchAlias, } from "yt-dlp-transcript-common/lib/searchAliases"; import type { DisplaySummary } from "yt-dlp-transcript-common/lib/transcripts"; import { fetchTranscript } from "yt-dlp-transcript-common/components/transcriptCache"; import { fetchThread } from "yt-dlp-transcript-common/components/postsCache"; import { cuesToSnippets, mergeSnippets, windowCues, } from "yt-dlp-transcript-common/lib/transcriptWindow"; import { hms } from "yt-dlp-transcript-common/lib/aiHandoff"; export type AgentMode = "auto" | "native" | "scripted"; export type AgentEvent = | { type: "search_start"; query: string } | { type: "search_done"; query: string; count: number } // The model read more of a pinned video's transcript (fetch_context tool). | { type: "fetch_start"; ref: string; label: string } | { type: "fetch_done"; ref: string; count: number } // The model upserted a section of the running report (update_report tool). | { type: "report_update"; section: string } // Fired once gather is done, BEFORE the answer streams — carries the grounding // so the UI can persist it and a failed answer stream can be retried without // re-searching. | { type: "answer_start"; videos: RetrievedVideo[]; groundedContent: string } | { type: "delta"; text: string }; // Default search budget per turn. Bounds round-trips (and spend on the user's // key). Round-trips ≈ searches + 1 (answer), + gather decision turns. export const DEFAULT_BUDGET = 4; // Default max output tokens for the ANSWER phase. 2048 was too small for report- // style answers (they truncated mid-sentence); 8192 fits comfortably within // Gemini 2.5 Flash / GPT-4o / Claude output limits. User-overridable per turn. export const DEFAULT_ANSWER_TOKENS = 8192; // Per-search result cap and overall context cap (bounds tokens sent to the model). const PER_SEARCH_LIMIT = 8; const MAX_CONTEXT_VIDEOS = 15; // fetch_context windowing: ± seconds around the moment, a hard cue cap, and the // max snippets a video may accumulate (so enrichment can't blow the budget). const FETCH_WINDOW = { before: 45, after: 45, maxCues: 60 }; const SNIPPETS_PER_VIDEO_CAP = 30; // ─── transport selection ─── // Provider+model pairs that failed a native tool call this session; Auto avoids // re-trying native for them. const forcedScripted = new Set(); const pairKey = (p: Provider, m: string) => `${p}:${m.toLowerCase()}`; export function supportsNativeTools(provider: Provider, model: string): boolean { const m = model.toLowerCase(); switch (provider) { case "anthropic": return m.startsWith("claude-"); case "openai": return /^(gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-5|o1|o3|o4)/.test(m); case "gemini": return /gemini-(1\.5|2)/.test(m) || m.includes("2.0") || m.includes("2.5"); default: return false; } } export function pickTransport( provider: Provider, model: string, mode: AgentMode, ): "native" | "scripted" { if (mode === "scripted") return "scripted"; if (mode === "native") return "native"; // explicit choice ignores the cache if (forcedScripted.has(pairKey(provider, model))) return "scripted"; return supportsNativeTools(provider, model) ? "native" : "scripted"; } // ─── scripted transport ─── // Parse one scripted decision line. Lenient: accepts a SEARCH: line anywhere // (stripping code fences/quotes); anything else — including "DONE" or prose — is // treated as done gathering. export function parseScriptedDecision( reply: string, ): { kind: "search"; query: string } | { kind: "done" } { const cleaned = reply.replace(/```[a-z]*/gi, "").replace(/```/g, "").trim(); const m = cleaned.match(/SEARCH\s*:\s*(.+)/i); if (m) { const q = m[1] .split(/\r?\n/)[0] .trim() .replace(/^["'`]+|["'`]+$/g, "") .trim(); if (q && !/^done$/i.test(q)) return { kind: "search", query: q }; } return { kind: "done" }; } async function scriptedGather( ctx: NativeGatherContext, provider: Provider, ): Promise { const convo: ChatMessage[] = [ ...ctx.history, { role: "user", content: ctx.question }, ]; for (let i = 0; i < ctx.budget; i++) { if (ctx.signal?.aborted) throw abortError(); const reply = await askOnce({ provider, apiKey: ctx.apiKey, model: ctx.model, system: ctx.system, messages: convo, maxTokens: 120, signal: ctx.signal, onDebug: ctx.onDebug, }); const decision = parseScriptedDecision(reply); if (decision.kind === "done") return; convo.push({ role: "assistant", content: `SEARCH: ${decision.query}` }); const results = await ctx.runSearch(decision.query); convo.push({ role: "user", content: `Results for "${decision.query}":\n${results}\n\n` + `Reply "SEARCH: " to search again, or "DONE" to answer.`, }); } } // ─── result formatting fed back to the model during gather ─── // Feed a search's results back to the model during gather. `numberOf` overrides // the per-result 1-based index (report mode uses it to show the GLOBAL registry // number, so a report-mode turn's report write cites the same stable `[n]` the // registry's source list uses); the "n." lead-in then becomes a bracketed "[n]" // so the model reads it as the citation number. export function formatResultsForModel( videos: RetrievedVideo[], numberOf?: (video: RetrievedVideo, index: number) => number, ): string { if (videos.length === 0) return "No transcript excerpts matched that query."; return videos .map((v, i) => { const site = v.siteTitle ? ` (${v.siteTitle})` : ""; const date = v.uploadDate ? ` [${v.uploadDate}]` : ""; const lead = numberOf ? `[${numberOf(v, i)}]` : `${i + 1}.`; const head = `${lead} "${v.title}" — ${v.channel}${site}${date}`; const lines = v.snippets.map((s) => ` ${s.clock} ${s.text}`).join("\n"); return lines ? `${head}\n${lines}` : head; }) .join("\n\n"); } // Tell the model about the pinned results it's grounded in (expand mode), each // with the `ref` it passes to fetch_context and the moments that matched — so it // can reason about them and read more around any moment. const SEED_DIGEST_CAP = 40; export function buildSeedDigest(videos: RetrievedVideo[]): string { const shown = videos.slice(0, SEED_DIGEST_CAP); const lines = shown.map((v) => { const times = v.snippets .slice(0, 6) .map((s) => s.clock) .join(", "); const site = v.siteTitle ? ` (${v.siteTitle})` : ""; return `- ref "${v.key}": "${v.title}" — ${v.channel}${site}${times ? `; matched at ${times}` : ""}`; }); const overflow = videos.length > shown.length ? `\n…and ${videos.length - shown.length} more pinned results.` : ""; return ( "PINNED RESULTS — the user handed you these search results as your starting " + "grounding. You may search the archive for more, and you may read more of " + "any of these around a moment:\n" + lines.join("\n") + overflow ); } // ─── the turn ─── export type RunAskTurnOptions = { provider: Provider; apiKey: string; model: string; mode: AgentMode; question: string; prior: UiMessage[]; summaries: DisplaySummary[]; // Post slugs available to search, so the agent's retrieval covers the social // corpus alongside video transcripts. Omitted = video-only, as before. postScopeSlugs?: ReadonlySet | null; aliases: SearchAlias[]; budget?: number; signal?: AbortSignal; onEvent: (e: AgentEvent) => void; // Retry path AND pinned-strict grounding: reuse pre-supplied grounding and SKIP // the whole gather phase — only (re)stream the answer. Set when retrying a turn // whose gather succeeded but whose answer stream failed (e.g. a 503 mid-answer), // or when answering strictly from a search's handed-off results. `truncated` // carries through when the supplied set was capped. precomputedGrounding?: { videos: RetrievedVideo[]; groundedContent: string; truncated?: boolean; }; // Pinned-expand grounding: seed the gathered-videos set with these before the // model runs its own searches, so the final grounding is the handed-off results // merged with whatever the model finds. seedVideos?: RetrievedVideo[]; // Human-edited context: replaces the history reconstructed from `prior` (used // when the user has pruned the context in the context panel). historyOverride?: ChatMessage[]; // Report mode (native providers only): the running report document carried in, // and a flag turning the mode on. When on, the prior Q&A text replay is dropped // in favour of a compact "report so far" history (the excerpt pool still flows), // and the update_report tool is offered so the model keeps the report current. report?: string; reportMode?: boolean; // Report mode: the persisted report SOURCE REGISTRY carried in — every video // cited across the report so far, ordered + deduped by key. The turn numbers its // gathered videos by their GLOBAL index in this registry (so a report write cites // the same stable `[n]` the registry's source list uses) and returns the registry // grown with this turn's new videos (see AskTurnResult.reportSources). reportSources?: RetrievedVideo[]; // Max output tokens for the answer phase (default DEFAULT_ANSWER_TOKENS). maxAnswerTokens?: number; // Optional capture of each provider call (finish reason, payloads) for the // debug export. Never receives the API key. onDebug?: (rec: DebugCall) => void; }; export type AskTurnResult = { answer: string; videos: RetrievedVideo[]; groundedContent: string; truncated: boolean; // The report after this turn (possibly updated via update_report). Echoes the // input report unchanged when report mode is off or nothing was written. report: string; // Report mode: the source registry grown with this turn's gathered videos, in // the GLOBAL-index order the turn's report writes cited them by. Undefined when // report mode is off (the caller then leaves its registry untouched). reportSources?: RetrievedVideo[]; // The answer's normalized finish reason and (if blocked) the raw block reason, // so the UI can flag truncation / a safety block instead of a silent stop. finishReason?: FinishReason; blockReason?: string; }; export async function runAskTurn( opts: RunAskTurnOptions, ): Promise { const { provider, apiKey, model, mode, question, prior, summaries, aliases, signal, onEvent, } = opts; const budget = opts.budget ?? DEFAULT_BUDGET; const reportMode = opts.reportMode === true; const answerTokens = opts.maxAnswerTokens ?? DEFAULT_ANSWER_TOKENS; // The running report — mutated in place by the update_report executor below and // returned in the result so the caller can persist it. let report = opts.report ?? ""; // Report mode: number this turn's gathered videos by their GLOBAL index in the // carried-in report source registry, so a report write cites the same stable // `[n]` the registry's source list uses (and `[n @ mm:ss]` seeks the moment). // `reportOrder` accumulates the registry (base + this turn's new videos, in // first-seen discovery order) and is returned so the caller can persist it. const reportBase = opts.reportSources ?? []; const reportOrder: RetrievedVideo[] = [...reportBase]; const reportIndexByKey = new Map(reportBase.map((v, i) => [v.key, i + 1])); const reportNumberOf = (v: RetrievedVideo): number => { const existing = reportIndexByKey.get(v.key); if (existing != null) return existing; reportOrder.push(v); const n = reportOrder.length; reportIndexByKey.set(v.key, n); return n; }; // Capture the answer phase's finish reason (+ any block reason) so the result // can flag truncation/blocking instead of a silent stop; also forward every // provider call to the debug sink for the export. let answerFinish: FinishReason | undefined; let answerBlock: string | undefined; const onDebug = (rec: DebugCall) => { if (rec.phase === "answer") { answerFinish = rec.finishReason; answerBlock = rec.blockReason; } opts.onDebug?.(rec); }; // Report mode COMPACTION: instead of replaying every prior Q&A turn as text, // send a single "report so far" assistant message (empty report → no history at // all). The cumulative excerpt pool still flows into the grounding unchanged — // only the prior Q&A text replay is dropped, keeping long sessions bounded. const compactHistory = (): ChatMessage[] => report.trim() === "" ? [] : [ { role: "assistant", content: "Report so far (keep it current with update_report):\n\n" + report, }, ]; const history = reportMode ? compactHistory() : opts.historyOverride ?? buildApiMessages(prior); // Aliases this turn actually used. Seeded from the question itself (so a // strict / no-search turn still gets focused context) and grown with every // alias a search fires (accumulated in runSearch below). Deduped by id, then // handed to the answer prompt so the model reads THIS turn's specific // mis-transcribed terms right — without the full glossary's noise. const usedAliases = new Map(); const addUsedAliases = (list: SearchAlias[]) => { for (const a of list) if (!usedAliases.has(a.id)) usedAliases.set(a.id, a); }; if (aliases.length) { addUsedAliases(matchAliases(question, "transcripts", aliases)); addUsedAliases(matchAliases(question, "metadata", aliases)); } const usedAliasList = () => [...usedAliases.values()]; // Retry path: grounding already gathered on a prior attempt — skip gather and // go straight to (re)streaming the answer over the same excerpts. if (opts.precomputedGrounding) { const { videos: pv, groundedContent } = opts.precomputedGrounding; onEvent({ type: "answer_start", videos: pv, groundedContent }); const answer = await askStream({ provider, apiKey, model, system: answerSystemPrompt(usedAliasList()), messages: [...history, { role: "user", content: groundedContent }], maxTokens: answerTokens, signal, onDelta: (chunk) => onEvent({ type: "delta", text: chunk }), onDebug, }); return { answer, videos: pv, groundedContent, truncated: (opts.precomputedGrounding.truncated ?? false) || answerFinish === "length", report, // Strict/retry path skips gather → no report write → registry unchanged. reportSources: reportMode ? reportBase : undefined, finishReason: answerFinish, blockReason: answerBlock, }; } const hasPriorGrounding = prior.some( (m) => m.role === "assistant" && (m.sources?.length ?? 0) > 0, ); const videos = new Map(); // Keys gathered THIS turn (pinned seed + fresh search hits), as opposed to the // carried-forward pool. Used to prioritise this turn's relevant videos when the // merged set is capped, so a new search isn't starved by an old pool. const freshKeys = new Set(); // Carry forward the cumulative excerpt pool from earlier turns so a follow-up // (reformat/summarise/expand) still sees those excerpts — sent once, in this // turn's grounding, rather than re-embedded in every replayed history turn. for (const v of collectPriorPool(prior)) videos.set(v.key, v); // Pinned-expand: start from the handed-off results, then let the model add to // them. Deduped by key; still capped below at MAX_CONTEXT_VIDEOS. const seedVideos = opts.seedVideos ?? []; for (const v of seedVideos) { videos.set(v.key, v); freshKeys.add(v.key); } const queries: string[] = []; let truncated = false; const runSearch = async (rawQuery: string): Promise => { const query = rawQuery.trim(); if (!query) return "No query provided."; onEvent({ type: "search_start", query }); const r = await retrieve({ question: query, summaries, postScopeSlugs: opts.postScopeSlugs ?? null, aliases, signal, limit: PER_SEARCH_LIMIT, }); for (const v of r.videos) { videos.set(v.key, v); freshKeys.add(v.key); } addUsedAliases(r.firedAliases); if (r.truncated) truncated = true; queries.push(query); onEvent({ type: "search_done", query, count: r.videos.length }); // Report mode shows the GLOBAL registry number so the report write's `[n]` // resolves against the persisted source list. return formatResultsForModel(r.videos, reportMode ? reportNumberOf : undefined); }; // Expand mode with a pinned set: let the model read more of a grounding video's // transcript around a moment. Windows the cues client-side (the channel page is // already warm from the search), merges them into that video's snippets — so the // deeper context reaches the answer + citations, not just the gather loop. const canFetchThisTurn = seedVideos.length > 0; const runFetchContext = async ( rawRef: string, aroundSeconds?: number, ): Promise => { const ref = rawRef.trim(); const v = videos.get(ref); if (!v) return `No pinned video with ref "${ref}".`; // For a POST, "more context" means the THREAD (parent + replies) — the // natural analogue of a transcript's surrounding cue window, since a post // has no timeline to window over. if (v.isPost) { onEvent({ type: "fetch_start", ref: v.key, label: v.title }); let thread; try { thread = await fetchThread(v.key); } catch { onEvent({ type: "fetch_done", ref: v.key, count: 0 }); return `Couldn't load the thread for this post.`; } const snips = thread.map((p) => ({ clock: "", seconds: 0, text: `${p.authorName || p.author}: ${p.text.trim().replace(/\s+/g, " ")}`, })); const before = v.snippets.length; v.snippets = mergeSnippets(v.snippets, snips, SNIPPETS_PER_VIDEO_CAP); const added = v.snippets.length - before; onEvent({ type: "fetch_done", ref: v.key, count: snips.length }); if (snips.length === 0) return `No thread found for this post.`; const body = snips.map((s) => s.text).join("\n"); return ( `Thread around the post by ${v.channel} ` + `(${added} new post${added === 1 ? "" : "s"} added to your citable excerpts):\n${body}` ); } const center = typeof aroundSeconds === "number" ? aroundSeconds : v.snippets[0]?.seconds ?? 0; onEvent({ type: "fetch_start", ref: v.key, label: v.title }); let cues; try { const detail = await fetchTranscript(v.key); cues = detail.cues ?? []; } catch { onEvent({ type: "fetch_done", ref: v.key, count: 0 }); return `Couldn't load the transcript for "${v.title}".`; } const snips = cuesToSnippets(windowCues(cues, center, FETCH_WINDOW)); const before = v.snippets.length; v.snippets = mergeSnippets(v.snippets, snips, SNIPPETS_PER_VIDEO_CAP); const added = v.snippets.length - before; onEvent({ type: "fetch_done", ref: v.key, count: snips.length }); if (snips.length === 0) { return `No transcript lines found near ${hms(center)} in "${v.title}".`; } const body = snips.map((s) => `[${s.clock}] ${s.text}`).join("\n"); return ( `Transcript excerpt from "${v.title}" around ${hms(center)} ` + `(${added} new line${added === 1 ? "" : "s"} added to your citable excerpts):\n${body}` ); }; // Report mode: apply the model's section upsert to the running report, emit an // event so the UI can show a live "updating report" indicator, and hand back a // short confirmation as the tool result. Report writes don't consume the search // budget (they have their own REPORT_BUDGET inside each provider loop). const runUpdateReport = async ( section: string, content: string, ): Promise => { report = applyReportPatch(report, section, content); onEvent({ type: "report_update", section }); return `Updated the "${section}" section.`; }; const gatherCtx: NativeGatherContext = { apiKey, model, system: "", // set per transport below history, question, budget, runSearch, onDebug, signal, }; const doGather = async (kind: "native" | "scripted"): Promise => { const canFetch = kind === "native" && canFetchThisTurn; const canReport = kind === "native" && reportMode; let system = gatherSystemPrompt(aliases, kind, budget, canFetch, canReport); if (canFetchThisTurn) system += `\n\n${buildSeedDigest(seedVideos)}`; const ctx: NativeGatherContext = { ...gatherCtx, system, runFetchContext: canFetch ? runFetchContext : undefined, runUpdateReport: canReport ? runUpdateReport : undefined, }; if (kind === "scripted") return scriptedGather(ctx, provider); switch (provider) { case "anthropic": return anthropicGather(ctx); case "openai": return openaiGather(ctx); case "gemini": return geminiGather(ctx); } }; const kind = pickTransport(provider, model, mode); try { await doGather(kind); } catch (e) { if ((e as Error).name === "AbortError") throw e; if (kind === "native" && e instanceof ToolsUnavailableError) { // This model/endpoint doesn't support tools — remember and retry scripted. forcedScripted.add(pairKey(provider, model)); await doGather("scripted"); } else { throw e; } } // Safety net: never answer a fresh question with zero grounding just because a // model ignored the protocol / declined to search. Seeded results already count // as grounding, so don't force a search when we started from a handed-off set. if (queries.length === 0 && !hasPriorGrounding && seedVideos.length === 0) { await runSearch(question); } // Cap the merged set, but keep this turn's fresh videos ahead of the older // carried-forward pool so a new search's results survive the cut. const all = [...videos.values()]; const finalVideos = [ ...all.filter((v) => freshKeys.has(v.key)), ...all.filter((v) => !freshKeys.has(v.key)), ].slice(0, MAX_CONTEXT_VIDEOS); const groundedContent = buildGroundedContent(question, finalVideos); // In report mode, rebuild the compact history from the NOW-updated report so the // answer phase sees the freshest report the gather loop wrote. const answerHistory = reportMode ? compactHistory() : history; onEvent({ type: "answer_start", videos: finalVideos, groundedContent }); const answer = await askStream({ provider, apiKey, model, system: answerSystemPrompt(usedAliasList()), messages: [...answerHistory, { role: "user", content: groundedContent }], maxTokens: answerTokens, signal, onDelta: (chunk) => onEvent({ type: "delta", text: chunk }), onDebug, }); return { answer, videos: finalVideos, groundedContent, truncated: truncated || answerFinish === "length", report, // The registry grown with this turn's gathered videos, in the global-index // order the report writes cited them by. reportSources: reportMode ? reportOrder : undefined, finishReason: answerFinish, blockReason: answerBlock, }; } // ─── whole-corpus sweep: fold ONE chunk of results into the running report ─── export type RunReportChunkOptions = { provider: Provider; apiKey: string; model: string; // The report directive (e.g. "major contradictions") — the sweep's focus. directive: string; // This chunk's videos, with their hit excerpts (the batch's evidence). videos: RetrievedVideo[]; // 1-based batch position, for the "Batch i of n" label. index: number; count: number; // The running report carried in; the returned report threads to the next chunk. report: string; aliases: SearchAlias[]; // Number each batch video by its GLOBAL registry index (so the report's `[n]` is // stable across every batch, resolving against the persisted source list). The // caller merges the batch into its registry first, then passes this. Omitted → // 1-based per-batch numbering. numberOf?: (video: RetrievedVideo, index: number) => number; signal?: AbortSignal; onEvent: (e: AgentEvent) => void; onDebug?: (rec: DebugCall) => void; }; export type ReportChunkResult = { // The report after folding this chunk in. report: string; // How many update_report writes the model made this chunk. writes: number; }; // Reduce one chunk of search results into the running report. A lean cousin of // runAskTurn's gather phase, report-mode-only: NO answer stream (the report IS the // output — the *Gather transport returns when the model stops calling tools) and // NO searching (budget 0 — the chunk's excerpts are the evidence; the prompt says // so). The model folds findings via update_report and may drill into a thin line // via fetch_context. Native providers only — the caller gates on report-mode // availability. The chunk's raw excerpts are dropped when this returns; only the // updated report survives. export async function runReportChunk( opts: RunReportChunkOptions, ): Promise { const { provider, apiKey, model, directive, index, count, aliases, signal, onEvent } = opts; let report = opts.report ?? ""; let writes = 0; // This chunk's videos, keyed for fetch_context ref resolution (and in-place // snippet enrichment when the model reads more transcript around a line). const videos = new Map(); for (const v of opts.videos) videos.set(v.key, v); // Focused aliases: those the DIRECTIVE references (no search fires here, so the // batch's mis-transcribed terms are steered purely from the sweep's focus). const usedAliases = new Map(); const addUsedAliases = (list: SearchAlias[]) => { for (const a of list) if (!usedAliases.has(a.id)) usedAliases.set(a.id, a); }; if (aliases.length) { addUsedAliases(matchAliases(directive, "transcripts", aliases)); addUsedAliases(matchAliases(directive, "metadata", aliases)); } // The report is the carried state: a single compacted "report so far" message // (empty report → no history), mirroring runAskTurn's report-mode compaction. const compactHistory = (): ChatMessage[] => report.trim() === "" ? [] : [ { role: "assistant", content: "Report so far (keep it current with update_report):\n\n" + report, }, ]; const runUpdateReport = async ( section: string, content: string, ): Promise => { report = applyReportPatch(report, section, content); writes += 1; onEvent({ type: "report_update", section }); return `Updated the "${section}" section.`; }; // Drill into a thin line: window this chunk-video's transcript around a moment // and merge it into that video's citable excerpts (same windowing as runAskTurn). const runFetchContext = async ( rawRef: string, aroundSeconds?: number, ): Promise => { const ref = rawRef.trim(); const v = videos.get(ref); if (!v) return `No batch video with ref "${ref}".`; const center = typeof aroundSeconds === "number" ? aroundSeconds : v.snippets[0]?.seconds ?? 0; onEvent({ type: "fetch_start", ref: v.key, label: v.title }); let cues; try { const detail = await fetchTranscript(v.key); cues = detail.cues ?? []; } catch { onEvent({ type: "fetch_done", ref: v.key, count: 0 }); return `Couldn't load the transcript for "${v.title}".`; } const snips = cuesToSnippets(windowCues(cues, center, FETCH_WINDOW)); v.snippets = mergeSnippets(v.snippets, snips, SNIPPETS_PER_VIDEO_CAP); onEvent({ type: "fetch_done", ref: v.key, count: snips.length }); if (snips.length === 0) { return `No transcript lines found near ${hms(center)} in "${v.title}".`; } const body = snips.map((s) => `[${s.clock}] ${s.text}`).join("\n"); return `Transcript excerpt from "${v.title}" around ${hms(center)}:\n${body}`; }; const system = `${accumulationSystemPrompt([...usedAliases.values()])}\n\n` + buildSeedDigest(opts.videos); const question = buildAccumulationContent( directive, opts.videos, index, count, opts.numberOf, ); const ctx: NativeGatherContext = { apiKey, model, system, history: compactHistory(), question, // No searching — the chunk is the evidence. A stray search call is refused // by the transport (budget reached) and the prompt reinforces "do not search". budget: 0, runSearch: async () => "Searching is disabled during a corpus sweep — use the batch excerpts provided.", runFetchContext, runUpdateReport, onDebug: opts.onDebug, signal, }; switch (provider) { case "anthropic": await anthropicGather(ctx); break; case "openai": await openaiGather(ctx); break; case "gemini": await geminiGather(ctx); break; default: throw new Error("A corpus sweep needs a native-tool-capable provider."); } return { report, writes }; }