Pipeline Map¶
src/orchestrator.py is the largest file in the project (~950 lines) and wires
every stage together. This page is the map: what runs in what order, which
method owns it, and which config keys steer it. Read this before opening the
file — it is a long file, not a complicated one.
Why it is not split into stage modules. Upstream (Thysrael/Horizon) edits
orchestrator.pyon most feature releases, and this fork stays merge-friendly on purpose (seeAGENTS.md§9). Splitting the single most-edited upstream file intostages/*.pywould turn every future merge into a conflict, in exchange for readability that this document provides for free. If the fork ever stops tracking upstream, revisit that trade.
The Run¶
HorizonOrchestrator.run() is the whole story, numbered 1–7 in the source.
| # | Stage | Method | Config |
|---|---|---|---|
| 0 | Email subscription check | EmailManager.check_subscriptions |
email.imap_enabled |
| 1 | Time window | _determine_time_window() |
collection.time_window_hours, --hours |
| 2 | Fetch every enabled source, concurrently | fetch_all_sources() |
sources.* |
| 3 | Merge same-URL items across sources | merge_cross_source_duplicates() |
— |
| 4 | Score with the LLM (1st pass) | analyze_items() |
ai.*, profile analysis.md |
| 5 | Filter, dedup by topic, balance | select_digest_items() |
processing.profile_settings, digest.* |
| 6 | Enrich with web search (2nd pass) | enrich_items() |
profile enrichment.md |
| 7 | Render + deliver per language | _generate_summary(), DailySummarizer |
ai.languages, digest.profile_order |
Stage 5 expands into filter_items() → merge_topic_duplicates() →
apply_balanced_digest() → passes_profile_filter(). Stage 2 fans out through
_fetch_with_progress(), which catches per-source exceptions so one dead source
cannot end the run.
Reporting Types¶
Defined at the top of orchestrator.py, before the class:
SourceFetchOutcome— one source's result:success/empty/failure.FetchReport— all outcomes;.status,.all_failed,.failure_message().run()aborts only when every source failed.FilteringPipelineResult,BalancedDigestResult— stage-5 diagnostics._deduplication_url_key()— URL normalisation shared by the dedup stages.
These are also what the MCP server surfaces, so treat their shapes as public.
Adding a Source¶
Five files, in this order (AGENTS.md §4 has the same list):
src/models.py— config model +SOURCE_REGISTRYentry.src/scrapers/<name>.py— subclassBaseScraper, implementfetch(since).src/orchestrator.py— a wiring block infetch_all_sources().data/config.example.json— a documented section.tests/— offline coverage with the network mocked.
The registry parity test (tests/test_mcp_adapter.py) fails if you skip step 1,
and its fixture needs the new source too.
What This Fork Changed¶
Everything above is upstream. This fork adds:
| Area | Files |
|---|---|
| YouTube video source | src/scrapers/video.py, src/services/video_cli.py, profiles/video/, tests/test_video.py |
| Config for it | VideoConfig / VideoChannelConfig in src/models.py |
| Wiring | ~6 lines in fetch_all_sources() |
| Deployment | deploy/ |
src/orchestrator.py is otherwise untouched, which is what keeps upstream
merges cheap. Keep it that way: put video logic in video.py, not here.
See docs/video-source.md for the video module itself.