import { DEFAULT_DIRECTIVE, DEFAULT_REPORT_PATH, renderWarnings, type PromptRequest, } from "./promptRequest"; // ─── The orchestration text a sweep / ask runs on ─── // // The server stays the single source of truth for the sweep discipline — // citation format, extractor budget, base-link expansion, the coverage rule — // rather than duplicating it into a markdown command file that would drift. // All three entry points (the `sweep_plan` and `ask_plan` tools and the MCP // `sweep` prompt) render through here. // Facts the server resolved before writing the instructions, so the model does // not spend round-trips rediscovering them. Anything unresolvable is simply // absent and the instructions fall back to asking. export type PlanContext = { // The canonical corpus handle every call in this plan must pass. corpus: string; // Channel/group tokens validated against the live corpus. knownChannels?: string[]; unknownChannels?: string[]; knownGroups?: string[]; unknownGroups?: string[]; // The groups on offer, when the user picked no scope and has to choose. availableGroups?: { id: string; name: string; channels: number }[]; // Extra notes to surface (e.g. a decoded link's plan). notes?: string[]; }; function quoted(xs: string[]): string { return xs.map((x) => `"${x}"`).join(", "); } // The scope arguments every search/enumerate call in the plan should carry, // rendered literally so they can be copied. function scopeArgs(req: PromptRequest, ctx: PlanContext): string { const parts = [`source: "${ctx.corpus}"`]; if (req.channels.length > 0) parts.push(`channels: [${quoted(req.channels)}]`); if (req.groups.length > 0) parts.push(`groups: [${quoted(req.groups)}]`); if (req.contentTypes) parts.push(`content_types: [${quoted(req.contentTypes)}]`); if (req.regex) parts.push(`regex: true`); return parts.join(", "); } function scopeProse(req: PromptRequest): string { const clauses: string[] = []; if (req.channels.length > 0) clauses.push(`channels ${quoted(req.channels)}`); if (req.groups.length > 0) clauses.push(`groups ${quoted(req.groups)}`); return clauses.join(" and "); } // The scope-validation preamble: what the server already checked, and what the // model must do about anything that didn't resolve. function scopeSteps( req: PromptRequest, ctx: PlanContext, ): { steps: string[]; halt: boolean } { const steps: string[] = []; const unknown = [ ...(ctx.unknownChannels ?? []).map((c) => `channel "${c}"`), ...(ctx.unknownGroups ?? []).map((g) => `group "${g}"`), ]; if (unknown.length > 0) { steps.push( `**Fix the scope first.** These did not match anything in this corpus: ` + `${unknown.join(", ")}. Do NOT proceed with a silently-wider scope — ` + `show me \`list_channels\` output and ask which I meant.`, ); // Halt here: an instruction list that goes on to enumerate would invite // running the sweep over a silently wider scope than was asked for. return { steps, halt: true }; } const hasScope = req.channels.length > 0 || req.groups.length > 0; if (!hasScope && !req.link) { // When the server could pre-resolve the roster, inline it — that turns a // round-trip into a question the model can ask immediately. When it // couldn't, say how to fetch it. const roster = ctx.availableGroups && ctx.availableGroups.length > 0 ? `\n\n The groups in this corpus are:\n` + ctx.availableGroups .map((g) => ` - ${g.id} · ${g.name} · ${g.channels} channel(s)`) .join("\n") : ` Call \`list_channels\` (with \`source: "${ctx.corpus}"\`) and ` + `present the groups and their channels.`; steps.push( `**Choose the scope first — do NOT default to the whole corpus.** No ` + `channels or groups were given.${roster.startsWith(" Call") ? roster : ""} ` + `Ask which group(s) or channel(s) to sweep, or to confirm **all** for ` + `the whole corpus. Wait for my choice, then use it as the ` + `\`channels\`/\`groups\` scope in every call below.` + (roster.startsWith(" Call") ? "" : roster), ); } return { steps, halt: false }; } // The per-batch map-reduce: a cheap extractor subagent quotes verbatim, the // orchestrator does every bit of the synthesis. The transcript bulk never // enters the orchestrator's context and never costs the big model. function batchStep(req: PromptRequest, ctx: PlanContext, reportPath: string): string { return ( `**Per batch (map-reduce), for each group of up to ${req.batchSize} ids:**\n` + ` - **Spawn a subagent as a DUMB EXTRACTOR on the cheapest model** — ` + `use the Task tool and request model "${req.parseModel}" (its model ` + `parameter, or a "${req.parseModel}"-backed agent type); if no model ` + `override is available, spawn it anyway on the default. Give it exactly ` + `this job: call \`get_transcripts\` with the batch's ids, ` + `\`source: "${ctx.corpus}"\`, the sweep's \`queries\`, and ` + `\`link_style: "base"\`, then return ONLY the lines relevant to the ` + `directive, VERBATIM — do NOT analyze, summarize, or rephrase anything. ` + `Group the kept lines per video as \`### \` + that video's ` + `\`moment_base:\` line copied exactly (or its \`source:\` line when there ` + `is no moment_base) + the kept lines in their \`[mm:ss|seconds]\` form. ` + `Hard budget: at most 40 lines (~600 words) per batch — if more match, ` + `keep the strongest and end with \`(+N more matching lines)\`. Return ` + `nothing else.\n` + ` - **Pass the corpus handle into every subagent.** Each one must call ` + `with \`source: "${ctx.corpus}"\`. A subagent that omits it reads this ` + `server's default corpus instead, and its quotes would be from the wrong ` + `archive with nothing in the output to show it.\n` + ` - **Merge — you (the orchestrator) do ALL the synthesis.** Cross-` + `reference the returned fragment against the report so far and upsert ` + `findings — claims, and contradictions with earlier claims — into ` + `well-titled \`## sections\` of \`${reportPath}\` (Write/Edit). Cite every ` + `VIDEO finding as **\`[title @ mm:ss](<moment url>)\`**, expanding each ` + `kept \`[mm:ss|seconds]\` stamp by appending the integer after the \`|\` ` + `to that video's moment_base (full link = \`<moment_base><seconds>\`; no ` + `moment_base → link the \`source:\` URL instead). A POST finding has no ` + `timestamp — cite it as **\`[post by <author>, <date>](<source url>)\`**, ` + `never with \`@ mm:ss\`. Then discard the fragment. Batches are ` + `independent, so you may dispatch several subagents in parallel.\n` + ` - **Fallback:** with no Task tool, do the batch inline — call ` + `\`get_transcripts\` with \`link_style: "base"\` yourself, fold the ` + `expanded cited findings into the report, then **drop the raw excerpt ` + `text** before moving on.` ); } // Media for a citation goes through the editor's fetch job, never a yt-dlp the // agent runs itself (the operator's rule: every fetch through Archilyzer). One // step, shared by both builders so the two cannot drift. `wait_seconds` and // `full` stay un-backticked: they are arguments, not tools. function clipStep(ctx: PlanContext): string { return ( `**Media for a cited moment — through the editor only.** When I ask for ` + `the clip behind a citation (or a report needs one), call \`fetch_clip\` ` + `with that citation's channel slug, video id, start/end in seconds (or ` + `mm:ss) and a one-line reason (or full: true when the ask genuinely needs ` + `the whole recording — a window is the default), and pass ` + `source: "${ctx.corpus}" so a Rumble id resolves to the right directory. ` + `NEVER run yt-dlp yourself, in any form. If the tool reports no editor is ` + `configured, say so and stop — the README's yt-dlp command is the ` + `operator's fallback, not yours. If it returns queued, call it again with ` + `the job it names. A video the archive holds locally is cut from its ` + `saved copy without a fetch (cached at once).` ); } // The editor-backed writes and reads (release 19 A9): the same rule as // fetch_clip — through the editor, never by hand, and only when asked. function archiveStep(): string { return ( `**Archive work — through the editor, and only when I ask.** A video the ` + `archive has not published yet is read off the editor's disk by ` + `\`get_transcript\` (it says so; such a video has no moment link — cite its ` + `source URL and time). When I ask for a channel to be synced, downloaded, ` + `transcribed, its posts fetched or a video imported, call \`enqueue\` and ` + `follow the job it names with \`get_job\` — a platform queue may hold it for ` + `hours; report the job, do not wait it out. \`channel_coverage\` answers ` + `what a channel holds by date and where its gaps are (a VOD mirror's videos ` + `by the day they were recorded). The operator's notes on articles and ` + `video projects: \`notes\` (list, read; a reply is \`umtool notes reply\`). ` + `Settings, storage and deletes are not reachable from here — name the ` + `\`pnpm ops\` command instead.` ); } // The full sweep instructions. export function buildSweepInstructions( req: PromptRequest, ctx: PlanContext, ): string { const reportPath = req.reportPath ?? DEFAULT_REPORT_PATH; const directive = req.directive ?? DEFAULT_DIRECTIVE; const subject = req.query ? `"${req.query}"` : req.link ? "the share link's search" : "the subject below"; const args = scopeArgs(req, ctx); const scope = scopeSteps(req, ctx); const steps: string[] = [...scope.steps]; if (!scope.halt && req.link) { steps.push( `**Decode the link.** Call \`open_link\` with link="${req.link}" and ` + `\`dry_run: true\`. It returns the plan: the resolved corpus handle, ` + `the query tree, every active filter, the channel scope validated ` + `against that corpus, and anything ignored. **Show me the plan and ` + `confirm it captures what I want.** If I ask for a change ("drop the ` + `availability filter", "only channel X", "search Y instead"), re-call ` + `with the matching \`overrides\` until it is right. Then call once ` + `more without \`dry_run\` to run it. Use the handle it reports as ` + `\`source\` from then on.`, ); } if (!scope.halt) { steps.push( `**Enumerate the complete worklist — one call.** Call ` + `\`enumerate_matches\` with ${args}` + (req.query ? `, query: "${req.query}"` : "") + `. It returns EVERY match (id/title/channel/date) in a single scan plus ` + `the batch count. Do NOT page \`search_transcripts\` for this: that is ` + `one full corpus scan per page, and it is how a previous sweep reported ` + `319 videos after seeing 200. If the first line says **COVERAGE ` + `PARTIAL**, the list is a sample — narrow the scope or raise ` + `\`max_pages\`, and if you proceed anyway, say so prominently in the ` + `report — it now also NAMES the channels it never reached, so quote ` + `those rather than just saying "partial".`, ); steps.push( `**Narrow it if the question is narrow.** \`enumerate_matches\` and ` + `\`search_transcripts\` take the same filters: \`states\` (e.g. ` + `\`["deleted","private","members_only","unlisted","maybe_missing"]\` for ` + `"what did the videos that are now GONE say"), \`date_from\`/` + `\`date_to\`, \`media_type\`, \`age\`, \`exclude\` (video-level NOT, ` + `for "cup" but not "world cup"), \`tags\` — the operator's CURATED ` + `per-video tags, cutting across channels ("every stream where X is on ` + `mic"); call \`list_tags\` for the ids this corpus publishes rather ` + `than guessing one — and \`scopes\` to search descriptions, ` + `keywords or live chat instead of captions. Use them when the question ` + `implies them: a filtered scan reads only the shard pages that can hold ` + `a match, which is the difference between seconds and a minute per ` + `query — and it makes the answer narrower and more honest at the same ` + `time.`, ); steps.push( `**Counts are of RECORDINGS, not uploads.** Some videos are mirrored ` + `across platforms/channels; the tools collapse those to one row by ` + `default and name the collapsed copies inline. Quote the number the ` + `footer gives you. If a total here disagrees with an older report, the ` + `old one was double-counting mirrors — say that rather than splitting ` + `the difference.`, ); steps.push( `**A hit can come from another caption track.** Where a video has more ` + `than one English track whose words differ, search reads them all; a ` + `snippet tagged \`in uploaded captions\` (or another track) matched ` + `words the primary transcript — the original audio's captions — does ` + `not have there. Uploaded captions are not always what was said: read ` + `the primary around that moment (\`get_transcript\`; \`track\` reads ` + `the other one) before quoting, and say which track the words are from.`, ); steps.push( `**State the plan.** Report N (the enumerated total) and ` + `\`ceil(N / ${req.batchSize})\` batches before you start. The report may ` + `only ever claim the coverage this number justifies: N videos ` + `enumerated, and however many you actually read.`, ); steps.push( `**Honour operator corrections.** If the directory holding ` + `\`${reportPath}\` also holds a \`video.manifest.json\` (a ` + `report-to-video cut made from an earlier pass), read it before the ` + `first batch and collect every \`timeline[]\` entry that carries a ` + `\`correction\` field. Each one is the operator's ruling, made with the ` + `clip playing, about a citation the previous pass got wrong — usually ` + `the speaker, who is being addressed, or the date — keyed by that ` + `entry's \`channel\`, \`video\` and \`start\`. Apply them: a finding ` + `at that video and moment carries the corrected attribution, and the ` + `version the correction rejects is never re-asserted, however the ` + `transcript reads. Say in the summary how many corrections you applied; ` + `with no manifest or no corrections, skip this step silently.`, ); steps.push(batchStep(req, ctx, reportPath)); steps.push(clipStep(ctx)); steps.push(archiveStep()); steps.push( `**Finish.** Work to the end of the worklist, then write a summary ` + `section: the scope swept, **how many of the N you actually read**, ` + `headline findings, and any partial-coverage caveat. Tell me the report ` + `path. Keep every citation clickable — \`[title @ mm:ss](url)\` for a ` + `video, \`[post by <author>, <date>](url)\` for a post.`, ); } const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join("\n\n"); const scopeNote = scopeProse(req); const head = `Run a **corpus sweep** for ${subject}` + (scopeNote ? `, scoped to ${scopeNote}` : "") + `, extracting **${directive}**, and maintain a running markdown report at ` + `\`${reportPath}\`.\n\n` + `You are the sweep engine — work the whole match set methodically, using ` + `the transcript MCP tools for evidence and your own Write/Edit tools for ` + `the report. The MCP never changes the archive itself, and nothing here ` + `asks the editor for a change unless I do. The ` + `corpus holds video transcripts AND archived social posts.\n\n` + `**Corpus: \`${ctx.corpus}\`.** Pass \`source: "${ctx.corpus}"\` on every ` + `single call, including the ones your subagents make. This server has no ` + `active source — a call that omits \`source\` reads the server default, ` + `which may be a different archive.\n\n` + `**Cite every video finding as a clickable \`[title @ mm:ss](<moment ` + `url>)\` link** (append the cited integer seconds to that video's ` + `\`moment_base\` from the tool output); **cite every post finding as ` + `\`[post by <author>, <date>](<source url>)\`** — posts have no timeline, ` + `so they never take a \`@ mm:ss\`.`; const notes = ctx.notes && ctx.notes.length > 0 ? `\n\nContext already resolved for you:\n${ctx.notes.map((n) => `- ${n}`).join("\n")}` : ""; return ( renderWarnings(req.warnings) + `${head}${notes}\n\nFollow these steps:\n\n${numbered}` ); } // The ask variant: the same evidence discipline, but answering a question in // the conversation rather than maintaining a report file. Kept deliberately // close to the sweep so citations look identical either way. export function buildAskInstructions( req: PromptRequest, ctx: PlanContext, ): string { const question = req.query || "the question below"; const args = scopeArgs(req, ctx); const askScope = scopeSteps(req, ctx); const steps: string[] = [...askScope.steps]; if (!askScope.halt && req.link) { steps.push( `**Decode the link.** Call \`open_link\` with link="${req.link}" — one ` + `call returns the plan and the first page of results, plus the corpus ` + `handle to use from then on.`, ); } if (!askScope.halt) { steps.push( `**Find the evidence.** Search with \`search_transcripts\` (${args}) for ` + `the terms the question implies — try more than one phrasing; the ` + `corpus is ASR text and the curated aliases only cover known ` + `mis-transcriptions. If you need to know HOW MANY, or to cover ` + `everything, use \`enumerate_matches\` instead: a search page is a ` + `slice, and its \`⚠ INCOMPLETE PAGE\` banner means exactly that. Both ` + `take \`states\` / \`date_from\` / \`date_to\` / \`media_type\` / ` + `\`age\` / \`exclude\` / \`scopes\` — reach for them when the question ` + `is about a period, or about videos that have since been REMOVED ` + `(\`states\`), which is otherwise unaskable. Reported totals count a ` + `recording mirrored across platforms ONCE.`, ); steps.push( `**Read before concluding.** Pull the surrounding context with ` + `\`get_transcripts\` (${args}) — pass all the terms at once in ` + `\`queries\` rather than re-reading the same videos per term. A snippet ` + `is not evidence of what was meant; the window around it is.`, ); steps.push( `**Answer with citations.** Answer ${question} directly, and cite every ` + `claim: \`[title @ mm:ss](<moment url>)\` for a video (append the ` + `integer seconds to that video's \`moment_base\`), ` + `\`[post by <author>, <date>](<source url>)\` for a post — a post has ` + `no timeline, so it never takes a \`@ mm:ss\`. Say plainly what the ` + `corpus does NOT show — an absence of matches is a finding, not a gap ` + `to paper over.`, ); steps.push(clipStep(ctx)); steps.push(archiveStep()); } const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join("\n\n"); const head = `Answer this question from the transcript archive: **${question}**\n\n` + `**Corpus: \`${ctx.corpus}\`.** Pass \`source: "${ctx.corpus}"\` on every ` + `call — this server has no active source, and a call that omits it reads ` + `the server default. The MCP never changes the archive itself; it asks ` + `the local editor only when I ask it to. The corpus holds video ` + `transcripts AND archived social posts.`; const notes = ctx.notes && ctx.notes.length > 0 ? `\n\nContext already resolved for you:\n${ctx.notes.map((n) => `- ${n}`).join("\n")}` : ""; return ( renderWarnings(req.warnings) + `${head}${notes}\n\nFollow these steps:\n\n${numbered}` ); }