Media Signal Refinery
The Media Signal Refinery is silicon-zack's personal content intelligence system. It pulls from a firehose of sources (Hacker News, RSS, X, podcasts, and ad-hoc URL submissions), normalizes and deduplicates everything into append-only daily logs, then runs a tiered cascade of LLM passes β cheap triage scoring, summarization, deep analysis, and a daily cross-item synthesis β escalating spend only on items worth it. Feedback (upvote / save / dismiss) trains an interest profile that feeds back into scoring, and the highest-signal items are pushed to Telegram as alerts.
Framing: silicon-zack is supposed to know what bio-zack cares about and surface it without being asked. The refinery is the engine for "continuous lightweight curation across consumption domains" β background ingestion of everything, retrievable on demand, ranked by a profile that sharpens with every vote.
Pipeline at a Glance
FETCH (source adapters, per-source scheduler loops)
Hacker News (Firebase) | RSS (feedparser) | X (browser scrape) | Podcasts (OPML) | User-submitted URLs
β β β β β
ββββββββββββββββββββββββββ΄βββββββββββββββββββββ΄βββββββββββββββββββ΄βββββββββββββββββββββ
βΌ
NORMALIZE + DEDUP (normalize.py)
- URL canonicalization, fingerprint, cross-source dedup
- append β data/media/items/YYYY-MM-DD.jsonl
βΌ
PASS 1 Β· TRIAGE (triage.py) batch of 10 β cheap LLM
- score 0-100 against interest profile
- append β data/media/scores/YYYY-MM-DD.jsonl
βΌ
βββββββββββββββββ score β₯ summary_score (70) βββββββββββββββββ
βΌ β
PASS 2 Β· SUMMARIZE (summarize.py) one item / call β
- Trafilatura content extraction β bullets + why_it_matters + tags β
- append β data/media/summaries/YYYY-MM-DD.jsonl β
βΌ β
PASS 3 Β· DEEP ANALYSIS (deep_analysis.py) score β₯ 85, 30-min loop β
- extended summary, key quotes, action items, goal relevance, connections β
- append β data/media/deep/YYYY-MM-DD.jsonl β
βΌ β
PASS 4 Β· SYNTHESIS (synthesis.py) 5am PT daily β
- themes, narrative briefing, consolidated action items, goal connections β
- write β data/media/synthesis/YYYY-MM-DD.json β
βΌ βΌ
ALERTS (alerts.py) score β₯ alert_score (85) REVIEW QUEUE (GET /media/queue)
β Telegram via proactive outreach + feedback (POST /media/feedback)
βΌ
INTEREST PROFILE (interest.py, time-decayed)
ββββββββββΊ feeds back into PASS 1 triage
Why This Shape
| Decision | Rationale |
|---|---|
| LLM triage over keyword rules | Rules can't read context. An LLM holding the interest profile understands that "AI in a medical trial" and "AI startup funding" are different signals to different people. The classifier improves as the profile grows. |
| Tiered escalation by score | Cheap model triages everything; a mid-tier model summarizes only items above the summary threshold; a deeper pass runs on the top sliver. Spend tracks value instead of volume. |
| Append-only JSONL per day | Each stage writes one file per UTC day. Trivial to inspect, back up, diff, and replay. Days are natural partitions for time-windowed queries. No central DB to migrate (the only SQLite is the user-submission queue, which needs ACID). |
| Feedback β profile β triage | Every vote/save adjusts topic and source weights; the profile is the only "configuration" the scorer needs, and it's learned, not hand-written. |
| Hard budget caps | LLM cost can run away. Daily/monthly caps and per-task budgets pause work silently rather than billing surprises. |
How the Pipeline Actually Runs
There is no cron. The refinery runs as in-process asyncio loops inside the API server (./run api, port 33800). Loops are declared once in api/src/loop_engine/registry.py, grouped, and started by api/src/scheduler_task_groups.py (start_media_tasks, start_operations_tasks). Each loop method lives on VitaScheduler in api/src/scheduler.py; the interval constants are class attributes there.
| Loop method | Task name | Group | Cadence | What it does |
|---|---|---|---|---|
_media_hn_loop |
media_hn |
media | 5 min (MEDIA_HN_INTERVAL) |
Fetch + score Hacker News |
_media_rss_loop |
media_rss |
media | 30 min (MEDIA_RSS_INTERVAL) |
Fetch + score RSS feeds |
_media_podcast_loop |
media_podcast |
media | 4 h (MEDIA_PODCAST_INTERVAL) |
Fetch recent episodes |
_podcast_curation_loop |
podcast_curation |
media | 12 h (PODCAST_CURATION_INTERVAL) |
Curate the Pocket Casts Up Next queue |
_listened_digest_loop |
listened_digest |
media | 2 h (LISTENED_DIGEST_INTERVAL) |
Bridge actual listening history into the transcript pipeline |
_media_x_loop |
media_x |
media | 6 h, 8amβ10pm PT (MEDIA_X_INTERVAL) |
Process browser-scraped X items |
_media_bookmarks_loop |
media_bookmarks |
media | 12 h, 8amβ10pm PT (MEDIA_BOOKMARKS_INTERVAL) |
Fresh Camofox bookmark scrape + analysis; alert on auth/fetch failure |
_deep_analysis_loop |
deep_analysis |
operations | 30 min (DEEP_ANALYSIS_INTERVAL) |
Pass 3 on top-scoring items |
_synthesis_loop |
synthesis |
operations | 5am PT daily | Pass 4 daily synthesis |
_user_submit_loop |
user_submit |
β | 60 s (USER_SUBMIT_INTERVAL) |
Drain the URL-submission queue |
_x_content_gen_loop |
x_content_gen |
operations | Sunday 10am PT | (related) generate X drafts |
Note: HN polls every 5 minutes, not 15. Earlier docs and the YAML's
poll_interval_minutes: 15are misleading β the live cadence is theMEDIA_HN_INTERVAL = 5 * 60constant inapi/src/scheduler.py, confirmed by the registry label "Every 5 min". The YAMLpoll_interval_minutesvalue is not the source of truth for the HN loop period.
You can also run any stage by hand:
# Full pipeline (fetch β score β summarize β transcribe β podcast-summary)
uv run python scripts/media/run_pipeline.py
# Single stage / single source
uv run python scripts/media/run_pipeline.py --fetch --source hn
uv run python scripts/media/run_pipeline.py --score
uv run python scripts/media/run_pipeline.py --summarize
# Standalone passes
uv run python scripts/media/deep_analysis.py # Pass 3 structure/self-test
uv run python scripts/media/synthesis.py # Pass 4
uv run python scripts/media/alerts.py # send pending alerts
uv run python scripts/media/bookmarks.py # X bookmark ingestion
run_pipeline.py itself covers fetch / score / summarize / transcribe / podcast-summary; Pass 3 (deep_analysis) and Pass 4 (synthesis) are separate scheduler loops, not steps inside run_pipeline.py.
Source Adapters
Location: scripts/media/sources/
Each adapter emits a common NormalizedItem (see below). Fetch concurrency is guarded by per-source lock files (data/media/.refresh-{source}.lock) with a 10-minute staleness reset, so a manual refresh and a scheduled poll can't double-fetch.
| Source | Adapter | Mechanism | Cadence |
|---|---|---|---|
| Hacker News | sources/hn.py |
Firebase topstories.json + per-item fetch; tracks last_seen_id in health.json |
5 min |
| RSS | sources/rss.py |
feedparser with conditional GET (etag / last-modified per feed) |
30 min |
| X / Twitter feed | sources/x_browser.py |
Passive helper β save_scraped_tweets() / parse_scraped_tweet() driven by /sync-x; the general feed adapter does not scrape autonomously. |
manual |
| X bookmarks | scripts/browser/x.py + media/bookmarks.py |
Dedicated Camofox scrape, then dedup/analyze/theme/Forge pipeline. This is separate from the general X source adapter. | 12 h, 8amβ10pm PT |
| Podcasts | sources/podcasts.py |
OPML parse + per-feed RSS episode fetch (max_age_days, max_episodes_per_feed) |
4 h |
| User submit | sources/user_submit.py |
Content extraction for queued URLs | on demand |
Important distinction: the general X feed remains browser/manual-only;
config.sources.x.modeis"browser"and/sync-xpopulates that adapter. Curated bookmarks have their own automated 12-hour Camofox loop and do not imply that the home-feed adapter is automated.
NormalizedItem & deduplication
Location: scripts/media/normalize.py
@dataclass
class NormalizedItem:
id: str # source-specific id
source: str # "hn" | "rss" | "x" | "podcast"
url: str # canonical URL
title: str
content: str # body text if available
author: str
timestamp: str # ISO
raw: dict # source-specific metadata (e.g. external_url, hn score)
fingerprint: str # content hash for dedup
URL normalization: lowercase scheme + host, strip www., remove tracking params (utm_*, fbclid, β¦), sort remaining query params, drop fragments. The Deduplicator tracks seen URLs and fingerprints in health.json and dedups across sources (the same link arriving from HN and an RSS feed collapses to one item).
Pass 1 β Triage Scoring
Location: scripts/media/triage.py
Unscored items are pulled in batches of 10 (TriageScorer(batch_size=10)) and scored 0β100 by a cheap model. The interest profile is injected into the system prompt, so scoring is personalized. A budget check runs before each batch β if the daily cap is hit, triage stops and items accumulate unscored until the next UTC day.
The scorer returns a structured block per item:
ITEM_ID: <id>
SCORE: <0-100>
REASON: <one sentence>
| Score band | Meaning | Downstream |
|---|---|---|
| 90β100 | act-on / deep-engage | summarize + deep analysis + alert |
| 70β89 | worth reading | summarize |
| 50β69 | skim-worthy | stored only |
| 0β49 | low signal | stored only |
Results append to data/media/scores/YYYY-MM-DD.jsonl.
Pass 2 β Summarization
Location: scripts/media/summarize.py
Items at or above summary_score (default 70) are summarized. Content is extracted via Trafilatura (ContentExtractor, with an httpx fallback). Summaries are produced one item per LLM call β summarize_item() iterated by summarize_high_scoring(). There is no batching here (only Pass 1 batches).
{
"bullets": ["Key point 1", "Key point 2", "Key point 3"],
"why_it_matters": "One sentence connecting it to the reader's interests",
"tags": ["topic-a", "topic-b", "tutorial"]
}
Stored at data/media/summaries/YYYY-MM-DD.jsonl.
Pass 3 β Deep Analysis
Location: scripts/media/deep_analysis.py
The deployed deep pass. Runs on a 30-minute scheduler loop over items scoring β₯ 85 (DeepAnalyzer(score_threshold=85)), capped at max_items per run, that haven't already been analyzed for the day. It re-extracts full article text (up to 8K chars), loads active goals from goals/active/, and gives the model the day's other high-scoring titles so it can find cross-item connections.
@dataclass
class DeepAnalysisRecord:
item_id: str
title: str
extended_summary: str # 200-400 word analysis
key_quotes: list[str] # 3-5 quotes
action_items: list[str] # what to do / watch
goal_relevance: list[dict] # [{goal_id, goal_name, relevance_note}]
connections: list[str] # links to other items / trends
tags_enriched: list[str] # granular tags
model: str
analyzed_at: str
...
Records append to data/media/deep/YYYY-MM-DD.jsonl.
Note β the deprecated deep dive.
scripts/media/deep_dive.pyis the never-deployed original premium-model pass (it used a Claude-tier model at much higher cost). It is superseded bydeep_analysis.pyand is dead code; its docstring says so. Do not treatdeep_dive.py, thedeep_dive_scorethreshold (90), or themedia_deep_divealoop mode as the live deep path β they describe the retired design. The live route is thedeep_analysisaloop mode at threshold 85.
Pass 4 β Daily Synthesis
Location: scripts/media/synthesis.py
Once a day (5am PT, before the daily brief) synthesis reads the day's deep-analysis records and summaries and produces a single cross-item briefing.
@dataclass
class DailySynthesis:
date: str
themes: list[dict] # [{theme, items, description}]
narrative: str # 3-5 paragraph briefing
top_items: list[dict] # ordered by signal
action_items: list[str] # consolidated across the day
goal_connections: list[dict] # [{goal_id, items, insight}]
model: str
synthesized_at: str
...
Written to data/media/synthesis/YYYY-MM-DD.json. The synthesis feeds into the "Media Intel" section of the daily brief (see VITA Daily Brief).
Alerts
Location: scripts/media/alerts.py
Items at or above alert_score (default 85) that haven't already been alerted are sent to Telegram through the proactive outreach queue (enqueue_message, trigger media_signal). The manager records sent URLs per day so the same item isn't re-alerted, and exposes send_pending_alerts() / get_alert_stats(). This is the "Alert" branch of the cascade. See Proactive Outreach.
User URL Submissions
Location: scripts/media/user_submit.py (queue logic), api/src/services/scheduler_user_submit.py (scheduler integration)
Submitted URLs go through a SQLite queue (data/media/submissions.db) for ACID guarantees, drained by the _user_submit_loop every 60 seconds (small batches, limit=3).
- Queue β URL normalized (tracking params stripped,
www.removed), storedpending. - Dedup β checked against existing submissions by normalized URL.
- Fetch β content extracted.
- Score β triaged with a lower threshold (40 vs 70) since the user explicitly asked for it.
- Summarize β bullets + why-it-matters if it clears 40.
- Store β enters the normal item pipeline.
Failure handling: transient errors retry up to 3 times (retry_count < 3); jobs stuck in processing longer than 10 min (PROCESSING_TIMEOUT_MINUTES = 10) reset to pending; permanent failures are marked failed with the error message.
Podcast Subsystem
The podcast path is richer than a single adapter:
| Module | Role |
|---|---|
sources/podcasts.py |
Fetch recent episodes from the OPML subscription list |
podcast_curator.py |
Curate the Pocket Casts Up Next queue: clean finished episodes, gather candidates, build identity context (goals/projects/observations), rubric-score via LLM, then select with diversity + dedup constraints |
listened_digest.py |
Bridge actual listening history (full Pocket Casts history, not just queued items) into the transcript/summary pipeline |
transcribe.py |
Download audio for high-scoring episodes and transcribe via AssemblyAI (speaker diarization, $0.012/min) |
podcast_summary.py |
Turn transcripts into executive summary + key topics + notable quotes + takeaways |
OPML lives at data/podcasts/subscriptions.opml (not under data/media/); refresh it via /sync-podcasts (re-export from Pocket Casts when the file is stale, default > 90 days). Transcription is gated by transcribe_score and a daily transcription budget. See Podcast Pipeline.
X Bookmark Ingestion
Location: scripts/media/bookmarks.py
A separate, media-pipeline-shaped flow for X bookmarks: fresh scrape β dedup β
analyze β cluster β surface. VitaScheduler runs it every 12 hours during the
8amβ10pm PT window with fetch_fresh=True; manual cached analysis remains
available. Fresh scraping uses scripts/browser/x.py, updates
data/x/bookmarks.json, and writes analysis outputs under
data/media/bookmarks/:
data/media/bookmarks/
βββ raw/ # fetched bookmark batches
βββ analyzed/ # per-item analysis (themes, vita relevance, actionable insights)
βββ seen.json # dedup ledger
βββ themes.json # current clustered themes
uv run python scripts/media/bookmarks.py # full pipeline
uv run python scripts/media/bookmarks.py analyze-only # skip fetch, use cache
uv run python scripts/media/bookmarks.py themes # show current themes
Fresh-fetch failure is explicit. The pipeline returns status: fetch_failed
instead of quietly serving the old cache, and the scheduler queues a Telegram
alert deduplicated to once per day. Camofox checks both the current URL and page
state for login redirects and can import cookies from a logged-in Chromium
profile; if recovery still fails, use the alert's noVNC login path. A run
analyzes at most 20 unseen bookmarks, creates Forge sparks for sufficiently
relevant rising themes, and exposes high-relevance results to the briefing.
Self-Tuning
Location: scripts/media/tuning.py
Thresholds aren't permanently fixed. tuning.py tracks missed signals (data/media/profile/missed_signals.jsonl) β items the user engaged with that the system under-scored β and adjusts thresholds to improve precision/recall, logging every change as a TuningRecord. Effective thresholds are persisted to data/media/profile/thresholds.json (overriding the config defaults); load_thresholds() returns the live values.
Feedback & Interest Profile
Feedback
Location: scripts/media/feedback.py
Feedback is state-based with an append-only audit log, all guarded by a 10-second FileLock.
| Action | Effect | Finality |
|---|---|---|
upvote |
positive signal, trains profile | final (once a vote is set it can't flip) |
downvote |
negative signal, trains profile | final |
save |
applies upvote + sets bookmark | reversible bookmark |
unsave |
removes bookmark (vote unchanged) | β |
dismiss |
hide from queue | reversible |
undismiss |
restore to queue | β |
class ItemFeedbackState(BaseModel):
vote: Literal["up", "down"] | None = None # final once set
saved: bool = False
dismissed: bool = False
Storage:
data/media/feedback/
βββ state.json # {item_id: ItemFeedbackState}
βββ saved_index.json # fast saved-items lookup
βββ history/
β βββ YYYY-MM-DD.jsonl # audit log of every action
βββ .state.lock # FileLock (timeout 10s)
Vote finality returns HTTP 400 on an attempted flip; a lock-acquisition timeout surfaces as HTTP 503 ("Service busy, please try again").
Interest profile
Location: scripts/media/interest.py
Votes and saves update topic and source weights, stored at data/media/profile/interest.json with its own .interest.lock. Weights are time-decayed (configurable half-life in days), so recent feedback outweighs old. The profile is what Pass 1 injects to personalize scoring.
{
"topics": [{"topic": "machine-learning", "weight": 0.85, "sources": ["..."]}],
"sources": [{"source": "hn", "weight": 1.15}],
"feedback_count": 247,
"updated_at": "2026-01-04T14:30:00Z"
}
Numbers above are illustrative placeholders.
API Endpoints
File: api/src/routers/media.py (base http://localhost:33800/media)
| Method | Path | Purpose |
|---|---|---|
| GET | /media/queue |
Paginated review queue with filters |
| GET | /media/stats |
Pipeline statistics for a window |
| GET | /media/sources |
Per-source health (returns an array) |
| POST | /media/sources/{source}/refresh |
Trigger a manual fetch |
| POST | /media/feedback |
Submit feedback on an item |
GET /media/queue
| Param | Type | Default | Notes |
|---|---|---|---|
days |
int 1β30 | 7 | look-back window |
min_score |
int 0β100 | 0 | minimum score |
sources |
list | all | filter by source |
search |
string (β€100) | β | title search |
saved_only |
bool | false | only saved |
include_dismissed |
bool | false | include dismissed |
sort |
string | score |
score | recent | oldest |
cursor |
string | β | base64 cursor (score + timestamp + id) |
limit |
int 1β200 | 50 | page size |
Response is MediaQueueResponse (items, total, has_more, source_counts, next_cursor). Each MediaReviewItem carries score, summary (if present), feedback_state, and source-specific fields confirmed in api/src/schemas/media.py: external_url / hn_score / hn_comments (HN), feed_name (RSS), podcast_duration (podcasts), content_preview (X β first 280 chars, grapheme-safe).
GET /media/stats
{
"window_days": 7,
"total_items": 0, "scored_items": 0, "summarized_items": 0,
"saved_items": 0, "dismissed_items": 0,
"scores": { "high": 0, "medium": 0, "low": 0 }
}
Score distribution buckets are high β₯ 80, 50 β€ medium < 80, low < 50.
GET /media/sources
Returns list[SourceStatus] β a JSON array, one object per source (hn, rss, x, podcast):
[
{
"source": "hn",
"display_name": "Hacker News",
"enabled": true,
"last_fetch_started": null,
"last_fetch_completed": null,
"last_success": "2026-01-04T07:05:12Z",
"items_today": 0,
"error_count": 0,
"is_refreshing": false,
"refresh_cooldown_until": null,
"feeds": null
}
]
(feeds is populated for RSS.) is_refreshing is derived from the lock file; a lock older than 10 minutes is treated as stale.
POST /media/sources/{source}/refresh
Returns RefreshResponse with status β started | already_running | cooldown | failed (plus optional job_id, cooldown_remaining_seconds, error).
POST /media/feedback
{ "item_id": "EXAMPLE_ID", "action": "upvote" }
Errors: 400 on a vote-change attempt or unknown action; 503 on feedback-lock timeout.
Configuration
File: config/media-intel.yaml
LLM provider/model/task routing moved to .aloop/config.json modes (2026-04-15). The YAML now carries only sources, budgets, thresholds, and AssemblyAI settings:
assemblyai:
api_key: ${ASSEMBLYAI_API_KEY}
price_per_minute: 0.012
sources:
hn: { enabled: true, poll_interval_minutes: 15 } # NOTE: live HN cadence is 5 min (scheduler constant)
rss: { enabled: true, poll_interval_minutes: 30, feeds: ["..."] }
x: { mode: browser } # manual only
podcasts:
enabled: true
poll_interval_hours: 4
max_episodes_per_feed: 3
budgets:
daily_usd: 5.00
monthly_usd: 50.00
deep_dive_daily_usd: 2.00
transcribe_daily_usd: 3.00 # YAML override; code default is 1.50
thresholds:
alert_score: 85
summary_score: 70
deep_dive_score: 90 # describes the RETIRED deep_dive path
transcribe_score: 25 # YAML override; code default is 75
Note: the loader (
scripts/media/config.py) supplies defaults when a key is absent βtranscribe_daily_usddefaults to1.50andtranscribe_scoreto75in code. The committed YAML overrides those (3.00 and 25). When a key is set in the YAML, the YAML wins; when it's missing, the code default applies. The live deep-analysis threshold is 85 (indeep_analysis.py), not the YAML'sdeep_dive_score: 90β that 90 belongs to the dead path.
Model routing (.aloop/config.json)
Models are selected by task_type through the inference factory (scripts.inference.factory.run_inference / complete), not hard-wired per module. The aloop mode names below were verified in .aloop/config.json, but model assignments drift β defer to Model Guidance for current models.
| Pass / task | aloop mode (task_type) |
Status |
|---|---|---|
| Triage | media_score |
live |
| Summarize | media_summarize |
live |
| Deep analysis (Pass 3) | deep_analysis |
live |
| Synthesis (Pass 4) | synthesis |
live |
| Deep dive (legacy) | media_deep_dive |
orphaned β only consumer is the dead deep_dive.py |
Triage and summarize currently route to a cheap Gemini-tier flash model; deep analysis and synthesis to a DeepSeek flash model; the orphaned
media_deep_divemode still points at a Claude-tier model but is never invoked. Treat specific model strings as volatile β read.aloop/config.jsonormodel-guidance.mdat the moment you need them.
Storage Layout
data/media/
βββ items/ YYYY-MM-DD.jsonl # normalized items (Pass 0)
βββ scores/ YYYY-MM-DD.jsonl # triage scores (Pass 1)
βββ summaries/ YYYY-MM-DD.jsonl # summaries (Pass 2)
βββ deep/ YYYY-MM-DD.jsonl # deep analysis (Pass 3, created on first run)
βββ synthesis/ YYYY-MM-DD.json # daily synthesis (Pass 4, created on first run)
βββ feedback/ state.json, saved_index.json, history/YYYY-MM-DD.jsonl, .state.lock
βββ profile/ interest.json, missed_signals.jsonl, thresholds.json, .interest.lock
βββ costs/ YYYY-MM.jsonl # monthly cost ledger
βββ transcripts/ podcast transcripts (AssemblyAI)
βββ podcast_summaries/ podcast summary records
βββ bookmarks/ raw/, analyzed/, seen.json, themes.json # X bookmark pipeline
βββ health.json # per-source last-seen / errors / dedup state
βββ submissions.db # SQLite URL-submission queue
βββ .refresh-{source}.lock # per-source fetch locks
Example records
items/YYYY-MM-DD.jsonl:
{"id":"EXAMPLE_ID","source":"hn","url":"https://example.com/article","title":"Example Title","content":"","author":"someuser","timestamp":"2026-01-04T05:02:16+00:00","raw":{"by":"someuser","score":90},"fingerprint":"abc123"}
scores/YYYY-MM-DD.jsonl:
{"item_id":"EXAMPLE_ID","score":92,"reason":"strong topic match from a trusted author","model":"<routed-by-aloop>","scored_at":"2026-01-04T05:15:00Z","tokens_in":45,"tokens_out":12,"cost_usd":0.000005}
summaries/YYYY-MM-DD.jsonl:
{"item_id":"EXAMPLE_ID","bullets":["Point one","Point two","Point three"],"why_it_matters":"Connects to an active interest","tags":["topic-a","tutorial"],"model":"<routed-by-aloop>","summarized_at":"2026-01-04T05:20:00Z"}
Cost & Budget Enforcement
Location: scripts/media/costs.py
Every LLM/API cost is appended to data/media/costs/YYYY-MM.jsonl. check_budget(task) is called before each batch; when a cap is hit the relevant stage stops silently and resumes at the next UTC day. Per the committed YAML:
| Budget | Value | Scope |
|---|---|---|
daily_usd |
$5.00 | all tasks combined |
monthly_usd |
$50.00 | all tasks combined |
deep_dive_daily_usd |
$2.00 | deep analysis |
transcribe_daily_usd |
$3.00 (code default 1.50) | podcast transcription |
Health Monitoring
Location: scripts/media/health.py, data/media/health.json
Tracks per-source last_success, error_count, dedup state (seen URLs + fingerprints), and cooldown. RSS feeds with 3+ consecutive errors trigger a deduped Telegram alert (rss_feed_down) from run_pipeline.py. The same health data backs GET /media/sources.
Frontend
Location: web/src/routes/media/, web/src/components/media/ (served on 33801 in dev)
The /media route is an infinite-scroll review queue with URL-persisted filters, a collapsible source-status panel, and optimistic feedback. Source-typed cards render the source-specific fields (HN points/comments, RSS feed badge, podcast duration, X content preview). React Query hooks live in web/src/hooks/useMedia.ts.
File Locations
| File | Purpose |
|---|---|
api/src/routers/media.py |
API endpoints |
api/src/schemas/media.py |
Pydantic models (MediaReviewItem, SourceStatus, β¦) |
api/src/scheduler.py |
scheduler loop methods + interval constants |
api/src/loop_engine/registry.py |
loop registration catalog (cadences, labels) |
api/src/services/scheduler_user_submit.py |
user-submission scheduler integration |
scripts/media/run_pipeline.py |
fetch/score/summarize/transcribe orchestrator |
scripts/media/normalize.py |
URL normalization + dedup |
scripts/media/triage.py |
Pass 1 scoring |
scripts/media/summarize.py |
Pass 2 summaries + content extraction |
scripts/media/deep_analysis.py |
Pass 3 deep analysis (live) |
scripts/media/synthesis.py |
Pass 4 daily synthesis |
scripts/media/deep_dive.py |
retired / never deployed |
scripts/media/alerts.py |
high-signal Telegram alerts |
scripts/media/tuning.py |
self-tuning thresholds |
scripts/media/feedback.py |
feedback state + audit log |
scripts/media/interest.py |
interest profile (time-decayed) |
scripts/media/costs.py |
cost ledger + budget checks |
scripts/media/health.py |
per-source health |
scripts/media/bookmarks.py |
X bookmark ingestion |
scripts/media/podcast_curator.py |
Pocket Casts Up Next curation |
scripts/media/listened_digest.py |
listening-history bridge |
scripts/media/transcribe.py |
AssemblyAI transcription |
scripts/media/podcast_summary.py |
transcript summarization |
scripts/media/vdb_section.py |
"Media Intel" section for the daily brief |
config/media-intel.yaml |
sources / budgets / thresholds / AssemblyAI |
.aloop/config.json |
model routing by task_type |
Where to Go Next
- VITA Daily Brief β consumes synthesis output as the "Media Intel" section
- Feed β the broader personalized feed system
- Podcast Pipeline β podcast-specific depth
- Proactive Outreach β how alerts reach Telegram
- Model Guidance β current model routing (authoritative)
- Scheduling β how the in-process loops are run