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]()\`**, expanding each ` +
`kept \`[mm:ss|seconds]\` stamp by appending the integer after the \`|\` ` +
`to that video's moment_base (full link = \`\`; no ` +
`moment_base → link the \`source:\` URL instead). A POST finding has no ` +
`timestamp — cite it as **\`[post by , ]()\`**, ` +
`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 , ](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]()\` link** (append the cited integer seconds to that video's ` +
`\`moment_base\` from the tool output); **cite every post finding as ` +
`\`[post by , ]()\`** — 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]()\` for a video (append the ` +
`integer seconds to that video's \`moment_base\`), ` +
`\`[post by , ]()\` 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}`
);
}