Summary
Add a caption-track-first transcript path to the researcher plugin's fetch_media.py so YouTube (and other yt-dlp-supported) videos contribute their transcript text to research notes. Prefer YouTube's own captions; fall back to Whisper only when no English captions exist.
Why
Today the video → transcript path is unwired end-to-end, not just missing captions:
- The pipeline never requests transcription. Stage 4c invokes
fetch_media.py without --transcribe (skills/researcher/SKILL.md:585), so a YouTube URL only gets its audio downloaded and embedded as ![[video_id.mp3]] — no transcript is ever produced on a real run.
- Even with
--transcribe, the transcript is discarded. transcribe_audio() returns text and it's stashed on the manifest dict (result["transcript"]), but rewrite_media_refs() only substitutes the URL with an ![[local_path]] embed. The transcript string never enters the content, so it never reaches the summarizer or the note (scripts/fetch_media.py main loop, ~L485–510).
scripts/prompts/extract_transcript.txt is a dead prompt — referenced nowhere.
- Separately, the Whisper CLI currently crashes on Windows with a
UnicodeEncodeError (cp1252 codec can't encode a 。 character) — a broken fallback.
Design (decisions locked via brainstorming)
Transcript ladder (stop at first hit)
- Manual English captions (
en, en-US, en-GB, …)
- Auto-generated English captions
- Whisper on downloaded audio — only if
--whisper-fallback enabled and whisper installed
- Nothing — leave the video as a plain source link, log, continue
Auto-translated captions are never used. Steps 1–2 need only yt-dlp (metadata + subtitle fetch, no media download). English throughout.
Changes in fetch_media.py
New functions (module's "never raise, return None on failure" contract):
_probe_video(url) -> dict | None — yt-dlp --dump-json --skip-download; returns metadata incl. subtitles/automatic_captions maps and the video title in one call.
_vtt_to_text(vtt: str) -> str — pure, unit-tested: strips WEBVTT header, NOTE blocks, cue-timing lines, inline <...> timing tags; de-duplicates YouTube's rolling auto-caption repetition into clean paragraphs.
fetch_captions(url, lang="en") -> dict | None — probe → pick track by ladder → download just that track (--write-subs/--write-auto-subs --sub-langs --skip-download --sub-format vtt) → _vtt_to_text. Returns {"text", "source": "manual"|"auto", "lang", "title"}.
get_video_transcript(...) — full ladder orchestrator.
transcribe_audio() gets the cp1252 fix: run whisper subprocess with PYTHONUTF8=1 / PYTHONIOENCODING=utf-8 in env and errors="replace". The Whisper path reuses download_video() to pull audio into assets, then deletes the MP3 + .meta sidecar after transcription (transcript replaces audio; no dead code).
What the note gets
- Video URL rewritten from
![[video_id.mp3]] embed → plain source link [{title}]({url}).
- Each transcript appended to the end of the content body (robust against multiple videos; no mid-paragraph splicing):
## Transcript — {title} (source: auto-generated captions)
{clean transcript text}
- Summarizer + Haiku fallback read the raw
content string, so the transcript flows into the note summary as ordinary article text.
- Videos are pulled out of the
rewrite_media_refs manifest path; images/documents keep going through it unchanged.
CLI surface & config
New/changed flags on fetch_media.py:
--transcribe-videos — captions-first, on by default; --no-transcribe-videos opts out
--sub-lang en — preferred caption language (default en)
--whisper-fallback — enable step 3 (default off)
--whisper-model base — kept
--save-transcript-artifact — also write a standalone {video_id}.transcript.md asset (opt-in)
- Remove the old
--transcribe flag (never wired into the pipeline; output was discarded)
New keys in config_manager.default_config: "caption_language": "en", "whisper_fallback": false. Stage 4c reads these and translates to flags.
SKILL.md Stage 4c + cleanup
- Stage 4c invocation (
SKILL.md:585) gains --transcribe-videos --sub-lang {caption_language}, plus --whisper-fallback when config enables it and whisper is available.
- Update "Media embeds" prose (SKILL.md ~604, ~1126–1128): videos now yield an inline
## Transcript section, not an audio embed.
- Remove dead
scripts/prompts/extract_transcript.txt.
Testing
_vtt_to_text: fixtures for clean manual VTT and messy auto-generated rolling VTT; assert dedup + tag stripping.
fetch_captions: mock subprocess.run so probe returns crafted subtitles/automatic_captions maps and track download writes a fixture VTT; assert ladder prefers manual over auto, returns right source/title, returns None when no English captions.
get_video_transcript: captions-win, whisper-fallback-win, whisper-disabled paths.
- Content transform: assert
## Transcript appended, URL replaced with plain link, no ![[...mp3]] embed.
transcribe_audio: assert utf-8 env passed to subprocess.
- Existing image/document/video/
transcribe_audio tests stay green (download_video retained).
Edge cases
- Vimeo and other yt-dlp sites ride the same path (yt-dlp abstracts site differences).
Open question (has a recommended default — not blocking)
Transcript length cap. A 1-hour auto-caption transcript can run ~10k words, inflating summarizer input. Proposed: --max-transcript-chars default 100,000 (effectively unlimited for normal content, a guard against pathological cases), appending a [transcript truncated] marker when exceeded. Implementer may take this default unless changed.
Files touched
scripts/fetch_media.py (new functions, cp1252 fix, video handling rewrite, CLI flags)
scripts/config_manager.py (two new default-config keys)
skills/researcher/SKILL.md (Stage 4c invocation + media-embeds prose)
scripts/prompts/extract_transcript.txt (delete)
tests/test_fetch_media.py (new tests)
requirements.txt (note: captions need only yt-dlp; whisper optional)
Context
Design produced in a brainstorming session on 2026-07-13. A throwaway standalone caption downloader was built separately for immediate in-session use; this issue tracks the real plugin integration.
Summary
Add a caption-track-first transcript path to the researcher plugin's
fetch_media.pyso YouTube (and other yt-dlp-supported) videos contribute their transcript text to research notes. Prefer YouTube's own captions; fall back to Whisper only when no English captions exist.Why
Today the video → transcript path is unwired end-to-end, not just missing captions:
fetch_media.pywithout--transcribe(skills/researcher/SKILL.md:585), so a YouTube URL only gets its audio downloaded and embedded as![[video_id.mp3]]— no transcript is ever produced on a real run.--transcribe, the transcript is discarded.transcribe_audio()returns text and it's stashed on the manifest dict (result["transcript"]), butrewrite_media_refs()only substitutes the URL with an![[local_path]]embed. The transcript string never enters the content, so it never reaches the summarizer or the note (scripts/fetch_media.pymain loop, ~L485–510).scripts/prompts/extract_transcript.txtis a dead prompt — referenced nowhere.UnicodeEncodeError(cp1252 codec can't encode a。character) — a broken fallback.Design (decisions locked via brainstorming)
Transcript ladder (stop at first hit)
en,en-US,en-GB, …)--whisper-fallbackenabled and whisper installedAuto-translated captions are never used. Steps 1–2 need only
yt-dlp(metadata + subtitle fetch, no media download). English throughout.Changes in
fetch_media.pyNew functions (module's "never raise, return None on failure" contract):
_probe_video(url) -> dict | None—yt-dlp --dump-json --skip-download; returns metadata incl.subtitles/automatic_captionsmaps and the videotitlein one call._vtt_to_text(vtt: str) -> str— pure, unit-tested: stripsWEBVTTheader,NOTEblocks, cue-timing lines, inline<...>timing tags; de-duplicates YouTube's rolling auto-caption repetition into clean paragraphs.fetch_captions(url, lang="en") -> dict | None— probe → pick track by ladder → download just that track (--write-subs/--write-auto-subs --sub-langs --skip-download --sub-format vtt) →_vtt_to_text. Returns{"text", "source": "manual"|"auto", "lang", "title"}.get_video_transcript(...)— full ladder orchestrator.transcribe_audio()gets the cp1252 fix: run whisper subprocess withPYTHONUTF8=1/PYTHONIOENCODING=utf-8in env anderrors="replace". The Whisper path reusesdownload_video()to pull audio into assets, then deletes the MP3 +.metasidecar after transcription (transcript replaces audio; no dead code).What the note gets
![[video_id.mp3]]embed → plain source link[{title}]({url}).## Transcript — {title} (source: auto-generated captions) {clean transcript text}contentstring, so the transcript flows into the note summary as ordinary article text.rewrite_media_refsmanifest path; images/documents keep going through it unchanged.CLI surface & config
New/changed flags on
fetch_media.py:--transcribe-videos— captions-first, on by default;--no-transcribe-videosopts out--sub-lang en— preferred caption language (defaulten)--whisper-fallback— enable step 3 (default off)--whisper-model base— kept--save-transcript-artifact— also write a standalone{video_id}.transcript.mdasset (opt-in)--transcribeflag (never wired into the pipeline; output was discarded)New keys in
config_manager.default_config:"caption_language": "en","whisper_fallback": false. Stage 4c reads these and translates to flags.SKILL.md Stage 4c + cleanup
SKILL.md:585) gains--transcribe-videos --sub-lang {caption_language}, plus--whisper-fallbackwhen config enables it and whisper is available.## Transcriptsection, not an audio embed.scripts/prompts/extract_transcript.txt.Testing
_vtt_to_text: fixtures for clean manual VTT and messy auto-generated rolling VTT; assert dedup + tag stripping.fetch_captions: mocksubprocess.runso probe returns craftedsubtitles/automatic_captionsmaps and track download writes a fixture VTT; assert ladder prefers manual over auto, returns rightsource/title, returnsNonewhen no English captions.get_video_transcript: captions-win, whisper-fallback-win, whisper-disabled paths.## Transcriptappended, URL replaced with plain link, no![[...mp3]]embed.transcribe_audio: assert utf-8 env passed to subprocess.transcribe_audiotests stay green (download_videoretained).Edge cases
Open question (has a recommended default — not blocking)
Transcript length cap. A 1-hour auto-caption transcript can run ~10k words, inflating summarizer input. Proposed:
--max-transcript-charsdefault 100,000 (effectively unlimited for normal content, a guard against pathological cases), appending a[transcript truncated]marker when exceeded. Implementer may take this default unless changed.Files touched
scripts/fetch_media.py(new functions, cp1252 fix, video handling rewrite, CLI flags)scripts/config_manager.py(two new default-config keys)skills/researcher/SKILL.md(Stage 4c invocation + media-embeds prose)scripts/prompts/extract_transcript.txt(delete)tests/test_fetch_media.py(new tests)requirements.txt(note: captions need only yt-dlp; whisper optional)Context
Design produced in a brainstorming session on 2026-07-13. A throwaway standalone caption downloader was built separately for immediate in-session use; this issue tracks the real plugin integration.